feat(admin): Improve DX for deploying admin externally (#3418)

* init deploy command

* add include flag

* add 'shortcut' flag

* add dev command, fix var replacement, change default behaviour

* cleanup params of build command

* fix defaults when using the plugin to serve admin

* add changeset

* fix globals

* update README

* throw error on no build found

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2023-03-17 13:18:51 +01:00
committed by GitHub
parent 9ad15d3a88
commit 8a7421db5b
20 changed files with 358 additions and 85 deletions

View File

@@ -13,21 +13,16 @@ export default function (_rootDirectory: string, options: PluginOptions) {
if (serve) {
let buildPath: string
let htmlPath: string
// If an outDir is provided we use that, otherwise we default to "build".
if (outDir) {
buildPath = resolve(process.cwd(), outDir)
htmlPath = resolve(buildPath, "index.html")
} else {
buildPath = resolve(
require.resolve("@medusajs/admin-ui"),
"..",
"..",
"build"
)
htmlPath = resolve(buildPath, "index.html")
buildPath = resolve(process.cwd(), "build")
}
const htmlPath = resolve(buildPath, "index.html")
/**
* The admin UI should always be built at this point, but in the
* rare case that another plugin terminated a previous startup, the admin
@@ -35,14 +30,17 @@ export default function (_rootDirectory: string, options: PluginOptions) {
* build files exist, and if not, we throw an error, providing the
* user with instructions on how to fix their build.
*/
try {
fse.ensureFileSync(htmlPath)
} catch (_err) {
const indexExists = fse.existsSync(htmlPath)
if (!indexExists) {
reporter.panic(
new Error(
`Could not find the admin UI build files. Please run ${colors.bold(
"`medusa-admin build`"
)} to build the admin UI.`
)} or enable ${colors.bold(
`autoRebuild`
)} in the plugin options to build the admin UI.`
)
)
}

View File

@@ -1,48 +1,100 @@
import { build as buildAdmin } from "@medusajs/admin-ui"
import { AdminBuildConfig, build as buildAdmin } from "@medusajs/admin-ui"
import dotenv from "dotenv"
import fse from "fs-extra"
import ora from "ora"
import { EOL } from "os"
import { resolve } from "path"
import { loadConfig, reporter, validatePath } from "../utils"
type BuildArgs = {
deployment?: boolean
outDir?: string
backend?: string
path?: string
include?: string[]
includeDist?: string
}
let ENV_FILE_NAME = ""
switch (process.env.NODE_ENV) {
case "production":
ENV_FILE_NAME = ".env.production"
break
case "staging":
ENV_FILE_NAME = ".env.staging"
break
case "test":
ENV_FILE_NAME = ".env.test"
break
case "development":
default:
ENV_FILE_NAME = ".env"
break
}
try {
dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME })
} catch (e) {
reporter.warn(`Failed to load environment variables from ${ENV_FILE_NAME}`)
}
export default async function build(args: BuildArgs) {
const { path, backend, outDir } = mergeArgs(args)
const { deployment, outDir: outDirArg, backend, include, includeDist } = args
try {
validatePath(path)
} catch (err) {
reporter.panic(err)
let config: AdminBuildConfig = {}
if (deployment) {
config = {
build: {
outDir: outDirArg,
},
globals: {
backend: backend || process.env.MEDUSA_BACKEND_URL,
},
}
} else {
const { path, outDir } = loadConfig()
try {
validatePath(path)
} catch (err) {
reporter.panic(err)
}
config = {
build: {
outDir: outDir,
},
globals: {
base: path,
},
}
}
const time = Date.now()
const spinner = ora().start(`Building Admin UI${EOL}`)
await buildAdmin({
build: {
outDir: outDir,
},
globals: {
base: path,
backend: backend,
},
...config,
}).catch((err) => {
spinner.fail(`Failed to build Admin UI${EOL}`)
reporter.panic(err)
})
/**
* If we have specified files to include in the build, we copy them
* to the build directory.
*/
if (include && include.length > 0) {
const dist = outDirArg || resolve(process.cwd(), "build")
try {
for (const filePath of include) {
await fse.copy(filePath, resolve(dist, includeDist, filePath))
}
} catch (err) {
reporter.panic(err)
}
}
spinner.succeed(`Admin UI build - ${Date.now() - time}ms`)
}
const mergeArgs = (args: BuildArgs) => {
const { path, backend, outDir } = loadConfig()
return {
path: args.path || path,
backend: args.backend || backend,
outDir: args.outDir || outDir,
}
}

View File

@@ -1,15 +1,47 @@
import { Command } from "commander"
import build from "./build"
import dev from "./dev"
import eject from "./eject"
export async function createCli(): Promise<Command> {
const program = new Command()
const buildCommand = program.command("build")
buildCommand.description("Build the admin dashboard")
buildCommand.option(
"--deployment",
"Build for deploying to and external host (e.g. Vercel)"
)
buildCommand.option("-o, --out-dir <path>", "Output directory")
buildCommand.option("-b, --backend <url>", "Backend URL")
buildCommand.option("-p, --path <path>", "Base path")
buildCommand.option(
"-i, --include [paths...]]",
"Paths to files that should be included in the build"
)
buildCommand.option(
"-d, --include-dist <path>",
"Path to where the files specified in the include option should be placed. Relative to the root of the build directory."
)
buildCommand.action(build)
const devCommand = program.command("dev")
devCommand.description("Start the admin dashboard in development mode")
devCommand.option("-p, --port <port>", "Port (default: 7001))")
devCommand.option(
"-b, --backend <url>",
"Backend URL (default http://localhost:9000)"
)
devCommand.action(dev)
const deployCommand = program.command("eject")
deployCommand.description(
"Eject the admin dashboard source code to a custom directory"
)
deployCommand.option("-o, --out-dir <path>", "Output directory")
deployCommand.action(eject)
return program
}

View File

@@ -0,0 +1,6 @@
import type { AdminDevConfig } from "@medusajs/admin-ui"
import { dev as devAdmin } from "@medusajs/admin-ui"
export default async function dev(args: AdminDevConfig) {
await devAdmin(args)
}

View File

@@ -0,0 +1,111 @@
import * as fse from "fs-extra"
import path from "path"
import dedent from "ts-dedent"
type EjectParams = {
outDir?: string
}
const DEFAULT_DESTINATION = "medusa-admin-ui"
export default async function eject({
outDir = DEFAULT_DESTINATION,
}: EjectParams) {
const projectPath = require.resolve("@medusajs/admin-ui")
const uiPath = path.join(projectPath, "..", "..", "ui")
const packageJsonPath = path.join(projectPath, "..", "..", "package.json")
const pkg = await fse.readJSON(packageJsonPath)
const fieldsToRemove = ["exports", "types", "files", "main", "packageManager"]
fieldsToRemove.forEach((field) => delete pkg[field])
pkg.type = "module"
const dependenciesToMove = [
"tailwindcss",
"autoprefixer",
"postcss",
"tailwindcss-radix",
"@tailwindcss/forms",
"vite",
"@vitejs/plugin-react",
]
// Get dependencies in array from pkg.dependencies and move them to pkg.devDependencies
const dependencies = Object.keys(pkg.dependencies).filter((dep) =>
dependenciesToMove.includes(dep)
)
dependencies.forEach((dep) => {
pkg.devDependencies[dep] = pkg.dependencies[dep]
delete pkg.dependencies[dep]
})
pkg.scripts = {
build: "vite build",
dev: "vite --port 7001",
preview: "vite preview",
}
const viteConfig = dedent`
import { defineConfig } from "vite"
import dns from "dns"
import react from "@vitejs/plugin-react"
// Resolve localhost for Node v16 and older.
// @see https://vitejs.dev/config/server-options.html#server-host.
dns.setDefaultResultOrder("verbatim")
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
__BASE__: JSON.stringify("/"),
__MEDUSA_BACKEND_URL__: JSON.stringify("http://localhost:9000"),
},
})
`
// Create new tailwind.config.js file based on the current one
const tailwindConfig = await fse.readFile(
path.join(uiPath, "tailwind.config.js"),
"utf-8"
)
// Overwrite content field of module.exports in tailwind.config.js
let newTailwindConfig = tailwindConfig.replace(
/content:\s*\[[\s\S]*?\]/,
`content: ["src/**/*.{js,ts,jsx,tsx}", "./index.html"]`
)
// Remove require of "path" in tailwind.config.js
newTailwindConfig = newTailwindConfig.replace(
/const path = require\("path"\)/,
""
)
// Create a new postcss.config.js file
const postcssConfig = dedent`
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
}
}
`
const tmpPath = path.join(process.cwd(), outDir)
await fse.copy(uiPath, tmpPath)
await fse.remove(path.join(tmpPath, "tailwind.config.js"))
await fse.remove(path.join(tmpPath, "postcss.config.js"))
await fse.writeJSON(path.join(tmpPath, "package.json"), pkg)
await fse.writeFile(path.join(tmpPath, "vite.config.ts"), viteConfig)
await fse.writeFile(
path.join(tmpPath, "tailwind.config.cjs"),
newTailwindConfig
)
await fse.writeFile(path.join(tmpPath, "postcss.config.cjs"), postcssConfig)
}

View File

@@ -6,7 +6,14 @@ import { resolve } from "path"
import { loadConfig, reporter, validatePath } from "../utils"
export default async function setupAdmin() {
const { path, backend, outDir } = loadConfig()
const { path, outDir, serve, autoRebuild } = loadConfig()
// If the user has not specified that the admin UI should be served,
// we should not build it. Furthermore, if the user has not specified that they want
// the admin UI to be rebuilt on changes, we should not build it here.
if (!serve || !autoRebuild) {
return
}
try {
validatePath(path)
@@ -17,11 +24,13 @@ export default async function setupAdmin() {
let dir: string
let shouldBuild = false
/**
* If no outDir is provided we default to "build".
*/
if (outDir) {
dir = resolve(process.cwd(), outDir)
} else {
const uiPath = require.resolve("@medusajs/admin-ui")
dir = resolve(uiPath, "..", "..", "build")
dir = resolve(process.cwd(), "build")
}
try {
@@ -34,11 +43,16 @@ export default async function setupAdmin() {
const buildOptions = {
build: {
outDir: outDir,
outDir,
},
globals: {
base: path,
backend: backend,
/**
* We only build the admin UI as part of the Medusa startup process if
* the user has specified that they want to serve the admin UI. When this
* is the case, we should always set the backend to `undefined`.
*/
backend: undefined,
},
}
@@ -67,13 +81,7 @@ export default async function setupAdmin() {
)
await build({
build: {
outDir: outDir,
},
globals: {
base: path,
backend: backend,
},
...buildOptions,
}).catch((err) => {
spinner.fail(`Failed to build Admin UI${EOL}`)
reporter.panic(err)

View File

@@ -3,27 +3,30 @@ export type PluginOptions = {
* Determines whether the admin dashboard should be served.
*/
serve?: boolean
/**
* The path to the admin dashboard. Should not be either prefixed or suffixed with a slash.
* The path to the admin dashboard. Should not be prefixed or suffixed with a slash.
* The chosen path cannot be one of the reserved paths: "admin", "store".
* @default "app"
*/
path?: string
/**
* Backend to use for the admin dashboard. This should only be used if you
* intend on hosting the dashboard separately from your Medusa server.
* @default undefined
*/
backend?: string
/**
* The directory to output the build to. By default the plugin will build
* the dashboard to the `build` directory of the `@medusajs/admin-ui` package.
* If you intend on hosting the dashboard separately from your Medusa server,
* you should use this option to specify a custom build directory, that you can
* deploy to your host of choice.
* the dashboard to the `build` directory in `/node_modules/@medusajs/admin-ui`.
* @default undefined
*/
outDir?: string
/**
* Re-build the admin automatically when options have changed since the last server start.
* Be aware that building the dashboard is a memory intensive process. Therefore, this option should
* only be used in production if your server has the memory required to support it.
*
* Only used if `serve` is `true`.
* @default false
*/
autoRebuild?: boolean
}
type PluginObject = {

View File

@@ -15,6 +15,7 @@ export const loadConfig = () => {
let defaultConfig: PluginOptions = {
serve: true,
autoRebuild: false,
path: "app",
}
@@ -22,8 +23,8 @@ export const loadConfig = () => {
const { options } = plugin as { options: PluginOptions }
defaultConfig = {
serve: options.serve ?? defaultConfig.serve,
autoRebuild: options.autoRebuild ?? defaultConfig.autoRebuild,
path: options.path ?? defaultConfig.path,
backend: options.backend ?? defaultConfig.backend,
outDir: options.outDir ?? defaultConfig.outDir,
}
}

View File

@@ -1,4 +1,8 @@
export const validatePath = (path: string) => {
export const validatePath = (path?: string) => {
if (!path) {
return
}
if (path.startsWith("/")) {
throw new Error(`Path cannot start with a slash.`)
}