feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761)
Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
26c78bbc03
commit
f1a05f4725
@@ -1,25 +1,29 @@
|
||||
import { logger } from "@medusajs/admin-ui"
|
||||
import express, { Request, Response, Router } from "express"
|
||||
import fse from "fs-extra"
|
||||
import { ServerResponse } from "http"
|
||||
import { resolve } from "path"
|
||||
import colors from "picocolors"
|
||||
import { PluginOptions } from "../types"
|
||||
import { reporter } from "../utils"
|
||||
import { PluginOptions } from "../lib"
|
||||
|
||||
export default function (_rootDirectory: string, options: PluginOptions) {
|
||||
const app = Router()
|
||||
|
||||
const { serve = true, path = "app", outDir } = options
|
||||
const { serve = true, path = "/app", outDir } = options
|
||||
|
||||
const isDevelop = process.env.COMMAND_INITIATED_BY === "develop"
|
||||
|
||||
if (isDevelop) {
|
||||
app.get(`${path}`, (_req, res) => {
|
||||
res.send(
|
||||
"Admin is running in development mode. See the terminal for the URL."
|
||||
)
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
let buildPath: string
|
||||
|
||||
// If an outDir is provided we use that, otherwise we default to "build".
|
||||
if (outDir) {
|
||||
buildPath = resolve(process.cwd(), outDir)
|
||||
} else {
|
||||
buildPath = resolve(process.cwd(), "build")
|
||||
}
|
||||
const buildPath: string = resolve(process.cwd(), outDir || "build")
|
||||
|
||||
const htmlPath = resolve(buildPath, "index.html")
|
||||
|
||||
@@ -34,14 +38,8 @@ export default function (_rootDirectory: string, options: PluginOptions) {
|
||||
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`"
|
||||
)} or enable ${colors.bold(
|
||||
`autoRebuild`
|
||||
)} in the plugin options to build the admin UI.`
|
||||
)
|
||||
logger.panic(
|
||||
`Could not find the admin UI build files. Please run "medusa-admin build" or enable "autoRebuild" in the plugin options to build the admin UI.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,16 +56,16 @@ export default function (_rootDirectory: string, options: PluginOptions) {
|
||||
res.setHeader("Vary", "Origin, Cache-Control")
|
||||
}
|
||||
|
||||
app.get(`/${path}`, sendHtml)
|
||||
app.get(`${path}`, sendHtml)
|
||||
app.use(
|
||||
`/${path}`,
|
||||
`${path}`,
|
||||
express.static(buildPath, {
|
||||
setHeaders: setStaticHeaders,
|
||||
})
|
||||
)
|
||||
app.get(`/${path}/*`, sendHtml)
|
||||
app.get(`${path}/*`, sendHtml)
|
||||
} else {
|
||||
app.get(`/${path}`, (_req, res) => {
|
||||
app.get(`${path}`, (_req, res) => {
|
||||
res.send("Admin not enabled")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,100 +1,50 @@
|
||||
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 { build as adminBuild, clean } from "@medusajs/admin-ui"
|
||||
import { resolve } from "path"
|
||||
import { loadConfig, reporter, validatePath } from "../utils"
|
||||
import { BuildOptions } from "../types"
|
||||
import { getPluginPaths, loadConfig } from "../utils"
|
||||
import { createBuildManifest } from "../utils/build-manifest"
|
||||
|
||||
type BuildArgs = {
|
||||
deployment?: boolean
|
||||
outDir?: string
|
||||
backend?: string
|
||||
include?: string[]
|
||||
includeDist?: string
|
||||
}
|
||||
export default async function build({
|
||||
backend,
|
||||
path,
|
||||
outDir,
|
||||
deployment,
|
||||
}: BuildOptions) {
|
||||
const {
|
||||
path: configPath,
|
||||
backend: configBackend,
|
||||
outDir: configOutDir,
|
||||
} = loadConfig()
|
||||
|
||||
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
|
||||
}
|
||||
const plugins = await getPluginPaths()
|
||||
const appDir = process.cwd()
|
||||
|
||||
try {
|
||||
dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME })
|
||||
} catch (e) {
|
||||
reporter.warn(`Failed to load environment variables from ${ENV_FILE_NAME}`)
|
||||
}
|
||||
const outDirOption = resolve(appDir, outDir || configOutDir)
|
||||
const pathOption = deployment ? path || "/" : path || configPath
|
||||
const backendOption = deployment
|
||||
? backend || process.env.MEDUSA_ADMIN_BACKEND_URL
|
||||
: backend || configBackend
|
||||
|
||||
export default async function build(args: BuildArgs) {
|
||||
const { deployment, outDir: outDirArg, backend, include, includeDist } = args
|
||||
|
||||
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({
|
||||
...config,
|
||||
}).catch((err) => {
|
||||
spinner.fail(`Failed to build Admin UI${EOL}`)
|
||||
reporter.panic(err)
|
||||
await clean({
|
||||
appDir: appDir,
|
||||
outDir: outDirOption,
|
||||
})
|
||||
|
||||
/**
|
||||
* 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")
|
||||
await adminBuild({
|
||||
appDir: appDir,
|
||||
buildDir: outDirOption,
|
||||
plugins,
|
||||
options: {
|
||||
path: pathOption,
|
||||
backend: backendOption,
|
||||
outDir: outDirOption,
|
||||
},
|
||||
})
|
||||
|
||||
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`)
|
||||
await createBuildManifest(appDir, {
|
||||
outDir: outDir || configOutDir,
|
||||
path: path || configPath,
|
||||
backend: backend || configBackend,
|
||||
deployment,
|
||||
})
|
||||
}
|
||||
|
||||
188
packages/admin/src/commands/bundle.ts
Normal file
188
packages/admin/src/commands/bundle.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
ALIASED_PACKAGES,
|
||||
findAllValidRoutes,
|
||||
findAllValidSettings,
|
||||
findAllValidWidgets,
|
||||
logger,
|
||||
normalizePath,
|
||||
} from "@medusajs/admin-ui"
|
||||
import commonjs from "@rollup/plugin-commonjs"
|
||||
import json from "@rollup/plugin-json"
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve"
|
||||
import replace from "@rollup/plugin-replace"
|
||||
import virtual from "@rollup/plugin-virtual"
|
||||
import fse from "fs-extra"
|
||||
import path from "path"
|
||||
import { rollup } from "rollup"
|
||||
import esbuild from "rollup-plugin-esbuild"
|
||||
import dedent from "ts-dedent"
|
||||
|
||||
export async function bundle() {
|
||||
const adminDir = path.resolve(process.cwd(), "src", "admin")
|
||||
|
||||
const pathExists = await fse.pathExists(adminDir)
|
||||
|
||||
if (!pathExists) {
|
||||
logger.panic(
|
||||
"The `src/admin` directory could not be found. It appears that your project does not contain any admin extensions."
|
||||
)
|
||||
}
|
||||
|
||||
const pkg = await fse.readJSON(path.join(process.cwd(), "package.json"))
|
||||
|
||||
if (!pkg) {
|
||||
logger.panic(
|
||||
"The `package.json` file could not be found. Your project does not meet the requirements of a valid Medusa plugin."
|
||||
)
|
||||
}
|
||||
|
||||
if (!pkg.name) {
|
||||
logger.panic(
|
||||
"The `package.json` does not contain a `name` field. Your project does not meet the requirements of a valid Medusa plugin."
|
||||
)
|
||||
}
|
||||
|
||||
const identifier = pkg.name as string
|
||||
|
||||
const [routes, widgets, settings] = await Promise.all([
|
||||
findAllValidRoutes(path.resolve(adminDir, "routes")),
|
||||
findAllValidWidgets(path.resolve(adminDir, "widgets")),
|
||||
findAllValidSettings(path.resolve(adminDir, "settings")),
|
||||
])
|
||||
|
||||
const widgetArray = widgets.map((file, index) => {
|
||||
return {
|
||||
importStatement: `import Widget${index}, { config as widgetConfig${index} } from "${normalizePath(
|
||||
file
|
||||
)}"`,
|
||||
extension: `{ Component: Widget${index}, config: { ...widgetConfig${index}, type: "widget" } }`,
|
||||
}
|
||||
})
|
||||
|
||||
const routeArray = routes.map((route, index) => {
|
||||
return {
|
||||
importStatement: dedent`
|
||||
import Route${index}${
|
||||
route.hasConfig ? `, { config as routeConfig${index} }` : ""
|
||||
} from "${normalizePath(route.file)}"`,
|
||||
extension: `{
|
||||
Component: Route${index},
|
||||
config: { path: "${route.path}", type: "route"${
|
||||
route.hasConfig ? `, ...routeConfig${index}` : ""
|
||||
} }
|
||||
}`,
|
||||
}
|
||||
})
|
||||
|
||||
const settingArray = settings.map((setting, index) => {
|
||||
return {
|
||||
importStatement: dedent`
|
||||
import Setting${index}, { config as settingConfig${index} } from "${normalizePath(
|
||||
setting.file
|
||||
)}"`,
|
||||
extension: `{ Component: Setting${index}, config: { path: "${setting.path}", type: "setting", ...settingConfig${index} } }`,
|
||||
}
|
||||
})
|
||||
|
||||
const extensionsArray = [...routeArray, ...settingArray, ...widgetArray]
|
||||
|
||||
const virtualEntry = dedent`
|
||||
${extensionsArray.map((e) => e.importStatement).join("\n")}
|
||||
|
||||
const entry = {
|
||||
identifier: "${identifier}",
|
||||
extensions: [
|
||||
${extensionsArray.map((e) => e.extension).join(",\n")}
|
||||
],
|
||||
}
|
||||
|
||||
export default entry
|
||||
`
|
||||
|
||||
const dependencies = Object.keys(pkg.dependencies || {})
|
||||
|
||||
const peerDependencies = Object.keys(pkg.peerDependencies || {})
|
||||
|
||||
const external = [
|
||||
...ALIASED_PACKAGES,
|
||||
...dependencies,
|
||||
...peerDependencies,
|
||||
"react/jsx-runtime",
|
||||
]
|
||||
|
||||
const dist = path.resolve(process.cwd(), "dist", "admin")
|
||||
|
||||
const distExists = await fse.pathExists(dist)
|
||||
|
||||
if (distExists) {
|
||||
try {
|
||||
await fse.remove(dist)
|
||||
} catch (error) {
|
||||
logger.panic(
|
||||
`Failed to clean ${dist}. Make sure that you have write access to the folder.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await rollup({
|
||||
input: ["entry"],
|
||||
external,
|
||||
plugins: [
|
||||
virtual({
|
||||
entry: virtualEntry,
|
||||
}),
|
||||
nodeResolve({
|
||||
preferBuiltins: true,
|
||||
browser: true,
|
||||
extensions: [".mjs", ".js", ".json", ".node", "jsx", "ts", "tsx"],
|
||||
}),
|
||||
commonjs(),
|
||||
esbuild({
|
||||
include: /\.[jt]sx?$/, // Transpile .js, .jsx, .ts, and .tsx files
|
||||
exclude: /node_modules/,
|
||||
minify: process.env.NODE_ENV === "production",
|
||||
target: "es2017",
|
||||
jsxFactory: "React.createElement",
|
||||
jsxFragment: "React.Fragment",
|
||||
jsx: "automatic",
|
||||
}),
|
||||
json(),
|
||||
replace({
|
||||
values: {
|
||||
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
|
||||
},
|
||||
preventAssignment: true,
|
||||
}),
|
||||
],
|
||||
onwarn: (warning, warn) => {
|
||||
if (
|
||||
warning.code === "CIRCULAR_DEPENDENCY" &&
|
||||
warning.ids?.every((id) => /\bnode_modules\b/.test(id))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
warn(warning)
|
||||
},
|
||||
}).catch((error) => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await bundle.write({
|
||||
dir: path.resolve(process.cwd(), "dist", "admin"),
|
||||
chunkFileNames: "[name].js",
|
||||
format: "esm",
|
||||
exports: "named",
|
||||
})
|
||||
|
||||
await bundle.close()
|
||||
|
||||
logger.info("The extension bundle has been built successfully")
|
||||
} catch (error) {
|
||||
logger.panic(
|
||||
`Error encountered while building the extension bundle: ${error}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,34 @@
|
||||
import { Command } from "commander"
|
||||
import build from "./build"
|
||||
import dev from "./dev"
|
||||
import eject from "./eject"
|
||||
import { bundle } from "./bundle"
|
||||
import develop from "./develop"
|
||||
|
||||
export async function createCli(): Promise<Command> {
|
||||
const program = new Command()
|
||||
|
||||
const buildCommand = program.command("build")
|
||||
buildCommand.description("Build the admin dashboard")
|
||||
program
|
||||
.command("bundle")
|
||||
.description("Bundle extensions to the admin dashboard")
|
||||
.action(bundle)
|
||||
|
||||
buildCommand.option(
|
||||
"--deployment",
|
||||
"Build for deploying to and external host (e.g. Vercel)"
|
||||
)
|
||||
program
|
||||
.command("develop")
|
||||
.description("Start the admin dashboard in development mode")
|
||||
.option("--port <port>", "Port to run the admin dashboard on")
|
||||
.option("--path <path>", "Public path to serve the admin dashboard on")
|
||||
.option("--backend <url>", "URL to the Medusa backend")
|
||||
.action(develop)
|
||||
|
||||
buildCommand.option("-o, --out-dir <path>", "Output directory")
|
||||
buildCommand.option("-b, --backend <url>", "Backend URL")
|
||||
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)
|
||||
program
|
||||
.command("build")
|
||||
.description("Build the admin dashboard for production")
|
||||
.option("--path <path>", "Public path to serve the admin dashboard on")
|
||||
.option("--backend <url>", "URL to the Medusa backend")
|
||||
.option(
|
||||
"--deployment",
|
||||
"Build the admin dashboard for deployment to an exernal host"
|
||||
)
|
||||
.action(build)
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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)
|
||||
}
|
||||
32
packages/admin/src/commands/develop.ts
Normal file
32
packages/admin/src/commands/develop.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AdminOptions, develop as adminDevelop } from "@medusajs/admin-ui"
|
||||
import { getPluginPaths, loadConfig } from "../utils"
|
||||
|
||||
type DevelopArgs = AdminOptions & {
|
||||
port: number
|
||||
}
|
||||
|
||||
export default async function develop({ backend, path, port }: DevelopArgs) {
|
||||
const config = loadConfig(true)
|
||||
|
||||
if (!config) {
|
||||
// @medusajs/admin is not part of the projects plugins
|
||||
// so we return early
|
||||
return
|
||||
}
|
||||
|
||||
const plugins = await getPluginPaths()
|
||||
|
||||
await adminDevelop({
|
||||
appDir: process.cwd(),
|
||||
buildDir: config.outDir,
|
||||
plugins,
|
||||
options: {
|
||||
backend: backend || config.backend,
|
||||
path: path || config.path,
|
||||
develop: {
|
||||
port: port || config.develop.port,
|
||||
open: config.develop.open,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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)
|
||||
}
|
||||
3
packages/admin/src/lib/index.ts
Normal file
3
packages/admin/src/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { withCustomWebpackConfig } from "@medusajs/admin-ui"
|
||||
export * from "../types"
|
||||
export { withCustomWebpackConfig }
|
||||
@@ -1,92 +1,57 @@
|
||||
import { build } from "@medusajs/admin-ui"
|
||||
import fse from "fs-extra"
|
||||
import ora from "ora"
|
||||
import { EOL } from "os"
|
||||
import { resolve } from "path"
|
||||
import { loadConfig, reporter, validatePath } from "../utils"
|
||||
import { build, clean, logger } from "@medusajs/admin-ui"
|
||||
import { getPluginPaths, loadConfig } from "../utils"
|
||||
import { createBuildManifest, shouldBuild } from "../utils/build-manifest"
|
||||
|
||||
export default async function setupAdmin() {
|
||||
const { path, outDir, serve, autoRebuild } = loadConfig()
|
||||
const { autoRebuild, serve, backend, outDir, path } = 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) {
|
||||
if (process.env.COMMAND_INITIATED_BY === "develop") {
|
||||
logger.info("Running in development mode. Skipping setup.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
validatePath(path)
|
||||
} catch (err) {
|
||||
reporter.panic(err)
|
||||
if (!serve) {
|
||||
return
|
||||
}
|
||||
|
||||
let dir: string
|
||||
let shouldBuild = false
|
||||
|
||||
/**
|
||||
* If no outDir is provided we default to "build".
|
||||
*/
|
||||
if (outDir) {
|
||||
dir = resolve(process.cwd(), outDir)
|
||||
} else {
|
||||
dir = resolve(process.cwd(), "build")
|
||||
if (!autoRebuild) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fse.ensureDir(dir)
|
||||
} catch (_e) {
|
||||
shouldBuild = true
|
||||
const appDir = process.cwd()
|
||||
|
||||
const shouldContinue = await shouldBuild(appDir, {
|
||||
backend,
|
||||
path,
|
||||
outDir,
|
||||
})
|
||||
|
||||
if (!shouldContinue) {
|
||||
return
|
||||
}
|
||||
|
||||
const manifestPath = resolve(dir, "build-manifest.json")
|
||||
const plugins = await getPluginPaths()
|
||||
|
||||
const buildOptions = {
|
||||
build: {
|
||||
await clean({
|
||||
appDir,
|
||||
outDir: outDir,
|
||||
})
|
||||
|
||||
await build({
|
||||
appDir,
|
||||
buildDir: outDir,
|
||||
options: {
|
||||
backend,
|
||||
path,
|
||||
outDir,
|
||||
},
|
||||
globals: {
|
||||
base: path,
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}
|
||||
plugins,
|
||||
reporting: "minimal", // The fancy reporting does not work well when run as part of the setup script
|
||||
})
|
||||
|
||||
try {
|
||||
const manifest = await fse.readJSON(manifestPath)
|
||||
|
||||
/**
|
||||
* If the manifest is not the same as the current build options,
|
||||
* we should rebuild the admin UI.
|
||||
*/
|
||||
if (JSON.stringify(manifest) !== JSON.stringify(buildOptions)) {
|
||||
shouldBuild = true
|
||||
}
|
||||
} catch (_e) {
|
||||
/**
|
||||
* If the manifest file does not exist, we should rebuild the admin UI.
|
||||
* This is the case when the admin UI is built for the first time.
|
||||
*/
|
||||
shouldBuild = true
|
||||
}
|
||||
|
||||
if (shouldBuild) {
|
||||
const time = Date.now()
|
||||
const spinner = ora().start(
|
||||
`Admin build is out of sync with the current configuration. Rebuild initialized${EOL}`
|
||||
)
|
||||
|
||||
await build({
|
||||
...buildOptions,
|
||||
}).catch((err) => {
|
||||
spinner.fail(`Failed to build Admin UI${EOL}`)
|
||||
reporter.panic(err)
|
||||
})
|
||||
|
||||
spinner.succeed(`Admin UI build - ${Date.now() - time}ms`)
|
||||
}
|
||||
await createBuildManifest(appDir, {
|
||||
backend,
|
||||
path,
|
||||
outDir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
export type PluginOptions = {
|
||||
/**
|
||||
* Determines whether the admin dashboard should be served.
|
||||
*/
|
||||
serve?: boolean
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* The directory to output the build to. By default the plugin will build
|
||||
* 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 = {
|
||||
resolve: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ConfigModule = {
|
||||
plugins: [PluginObject | string]
|
||||
}
|
||||
export * from "./options"
|
||||
export * from "./routes"
|
||||
export * from "./setting"
|
||||
export * from "./widgets"
|
||||
|
||||
22
packages/admin/src/types/options.ts
Normal file
22
packages/admin/src/types/options.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AdminOptions } from "@medusajs/admin-ui"
|
||||
|
||||
export type PluginOptions = AdminOptions & {
|
||||
/**
|
||||
* Determines whether the admin dashboard should be served.
|
||||
*/
|
||||
serve?: boolean
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export type BuildOptions = AdminOptions & {
|
||||
deployment?: boolean
|
||||
}
|
||||
3
packages/admin/src/types/routes.ts
Normal file
3
packages/admin/src/types/routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { RouteConfig } from "@medusajs/admin-ui"
|
||||
|
||||
export { RouteConfig }
|
||||
3
packages/admin/src/types/setting.ts
Normal file
3
packages/admin/src/types/setting.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { SettingConfig } from "@medusajs/admin-ui"
|
||||
|
||||
export { SettingConfig }
|
||||
50
packages/admin/src/types/widgets.ts
Normal file
50
packages/admin/src/types/widgets.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
Customer,
|
||||
CustomerGroup,
|
||||
Discount,
|
||||
DraftOrder,
|
||||
GiftCard,
|
||||
Order,
|
||||
PriceList,
|
||||
Product,
|
||||
} from "@medusajs/medusa"
|
||||
|
||||
import type { WidgetConfig, WidgetProps } from "@medusajs/admin-ui"
|
||||
|
||||
export interface ProductDetailsWidgetProps extends WidgetProps {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export interface OrderDetailsWidgetProps extends WidgetProps {
|
||||
order: Order
|
||||
}
|
||||
|
||||
export interface DraftOrderDetailsWidgetProps extends WidgetProps {
|
||||
draftOrder: DraftOrder
|
||||
}
|
||||
|
||||
export interface DiscountDetailsWidgetProps extends WidgetProps {
|
||||
discount: Discount
|
||||
}
|
||||
|
||||
export interface PriceListDetailsWidgetProps extends WidgetProps {
|
||||
priceList: PriceList
|
||||
}
|
||||
|
||||
export interface GiftCardDetailsWidgetProps extends WidgetProps {
|
||||
giftCard: Product
|
||||
}
|
||||
|
||||
export interface CustomGiftCardWidgetProps extends WidgetProps {
|
||||
giftCard: GiftCard
|
||||
}
|
||||
|
||||
export interface CustomerDetailsWidgetProps extends WidgetProps {
|
||||
customer: Customer
|
||||
}
|
||||
|
||||
export interface CustomerGroupDetailsWidgetProps extends WidgetProps {
|
||||
customerGroup: CustomerGroup
|
||||
}
|
||||
|
||||
export { WidgetProps, WidgetConfig }
|
||||
146
packages/admin/src/utils/build-manifest.ts
Normal file
146
packages/admin/src/utils/build-manifest.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { AdminOptions } from "@medusajs/admin-ui"
|
||||
import fse from "fs-extra"
|
||||
import isEqual from "lodash/isEqual"
|
||||
import isNil from "lodash/isNil"
|
||||
import path from "path"
|
||||
import { BuildOptions } from "../types"
|
||||
import { getPluginPaths } from "./get-plugin-paths"
|
||||
|
||||
const MANIFEST_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
".cache",
|
||||
"admin-build-manifest.json"
|
||||
)
|
||||
|
||||
async function getPackageVersions(appDir: string) {
|
||||
const packageJsonPath = path.resolve(appDir, "package.json")
|
||||
|
||||
try {
|
||||
const { dependencies } = await fse.readJson(packageJsonPath)
|
||||
|
||||
return {
|
||||
dependencies,
|
||||
}
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getLastTimeModifiedAt(appDir: string) {
|
||||
const adminPath = path.resolve(appDir, "src", "admin")
|
||||
|
||||
// Get the most recent time a file in the admin directory was modified and do it recursively for all subdirectories and files
|
||||
let mostRecentTimestamp = 0
|
||||
|
||||
const pathExists = await fse.pathExists(adminPath)
|
||||
|
||||
if (!pathExists) {
|
||||
return mostRecentTimestamp
|
||||
}
|
||||
|
||||
async function processFolder(dir: string) {
|
||||
const files = await fse.readdir(dir)
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file)
|
||||
const stats = await fse.stat(filePath)
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await processFolder(filePath) // Recursively process subfolders
|
||||
} else {
|
||||
const { mtimeMs } = stats
|
||||
mostRecentTimestamp = Math.max(mostRecentTimestamp, mtimeMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await processFolder(adminPath)
|
||||
|
||||
return mostRecentTimestamp
|
||||
}
|
||||
|
||||
export async function createBuildManifest(
|
||||
appDir: string,
|
||||
options: BuildOptions
|
||||
) {
|
||||
const packageVersions = await getPackageVersions(appDir)
|
||||
const lastModificationTime = await getLastTimeModifiedAt(appDir)
|
||||
const plugins = await getPluginPaths()
|
||||
|
||||
const { dependencies } = packageVersions
|
||||
|
||||
const buildManifest = {
|
||||
dependencies: dependencies,
|
||||
modifiedAt: lastModificationTime,
|
||||
plugins: plugins,
|
||||
options,
|
||||
}
|
||||
|
||||
await fse.outputFile(MANIFEST_PATH, JSON.stringify(buildManifest, null, 2))
|
||||
}
|
||||
|
||||
export async function shouldBuild(appDir: string, options: AdminOptions) {
|
||||
try {
|
||||
const manifestExists = await fse.pathExists(MANIFEST_PATH)
|
||||
|
||||
if (!manifestExists) {
|
||||
return true
|
||||
}
|
||||
|
||||
const buildManifest = await fse.readJson(MANIFEST_PATH)
|
||||
|
||||
const {
|
||||
dependencies: buildManifestDependencies,
|
||||
modifiedAt: buildManifestModifiedAt,
|
||||
plugins: buildManifestPlugins,
|
||||
options: buildManifestOptions,
|
||||
} = buildManifest
|
||||
|
||||
const optionsChanged = !isEqual(options, buildManifestOptions)
|
||||
|
||||
if (optionsChanged) {
|
||||
return true
|
||||
}
|
||||
|
||||
const packageVersions = await getPackageVersions(appDir)
|
||||
|
||||
if (!packageVersions) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { dependencies } = packageVersions
|
||||
|
||||
const dependenciesChanged = !isEqual(
|
||||
dependencies,
|
||||
buildManifestDependencies
|
||||
)
|
||||
|
||||
if (dependenciesChanged) {
|
||||
return true
|
||||
}
|
||||
|
||||
const modifiedAt = await getLastTimeModifiedAt(appDir)
|
||||
|
||||
if (isNil(modifiedAt)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const lastModificationTimeChanged = modifiedAt !== buildManifestModifiedAt
|
||||
|
||||
if (lastModificationTimeChanged) {
|
||||
return true
|
||||
}
|
||||
|
||||
const plugins = await getPluginPaths()
|
||||
|
||||
const pluginsChanged = !isEqual(plugins, buildManifestPlugins)
|
||||
|
||||
if (pluginsChanged) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (_error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
42
packages/admin/src/utils/get-plugin-paths.ts
Normal file
42
packages/admin/src/utils/get-plugin-paths.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { logger } from "@medusajs/admin-ui"
|
||||
import type { ConfigModule } from "@medusajs/medusa"
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
import path from "path"
|
||||
|
||||
function hasEnabledUI(options: Record<string, unknown>) {
|
||||
return "enableUI" in options && options.enableUI === true
|
||||
}
|
||||
|
||||
export async function getPluginPaths() {
|
||||
const { configModule, error } = getConfigFile<ConfigModule>(
|
||||
path.resolve(process.cwd()),
|
||||
"medusa-config.js"
|
||||
)
|
||||
|
||||
if (error) {
|
||||
logger.panic("Error loading `medusa-config.js`")
|
||||
}
|
||||
|
||||
const plugins = configModule.plugins || []
|
||||
|
||||
const paths: string[] = []
|
||||
|
||||
for (const p of plugins) {
|
||||
if (typeof p === "string") {
|
||||
continue
|
||||
} else {
|
||||
const options = p.options || {}
|
||||
|
||||
/**
|
||||
* While the feature is in beta, we only want to load plugins that have
|
||||
* enabled the UI explicitly. In the future, we will flip this check so
|
||||
* we only exclude plugins that have set `enableUI` to false.
|
||||
*/
|
||||
if (hasEnabledUI(options)) {
|
||||
paths.push(p.resolve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { getPluginPaths } from "./get-plugin-paths"
|
||||
export { loadConfig } from "./load-config"
|
||||
export { reporter } from "./reporter"
|
||||
export { validatePath } from "./validate-path"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ConfigModule } from "@medusajs/medusa"
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
import { ConfigModule, PluginOptions } from "../types"
|
||||
import { PluginOptions } from "../types"
|
||||
|
||||
export const loadConfig = () => {
|
||||
export const loadConfig = (isDev?: boolean): PluginOptions | null => {
|
||||
const { configModule } = getConfigFile<ConfigModule>(
|
||||
process.cwd(),
|
||||
"medusa-config"
|
||||
@@ -13,21 +14,45 @@ export const loadConfig = () => {
|
||||
(typeof p === "object" && p.resolve === "@medusajs/admin")
|
||||
)
|
||||
|
||||
let defaultConfig: PluginOptions = {
|
||||
if (!plugin) {
|
||||
return null
|
||||
}
|
||||
|
||||
let config: PluginOptions = {
|
||||
serve: true,
|
||||
autoRebuild: false,
|
||||
path: "app",
|
||||
path: isDev ? "/" : "/app",
|
||||
outDir: "build",
|
||||
backend: isDev ? "http://localhost:9000" : "/",
|
||||
develop: {
|
||||
open: true,
|
||||
port: 7001,
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof plugin !== "string") {
|
||||
const { options } = plugin as { options: PluginOptions }
|
||||
defaultConfig = {
|
||||
serve: options.serve ?? defaultConfig.serve,
|
||||
autoRebuild: options.autoRebuild ?? defaultConfig.autoRebuild,
|
||||
path: options.path ?? defaultConfig.path,
|
||||
outDir: options.outDir ?? defaultConfig.outDir,
|
||||
const options = (plugin as { options: PluginOptions }).options ?? {}
|
||||
|
||||
const serve = options.serve !== undefined ? options.serve : config.serve
|
||||
|
||||
const serverUrl = serve
|
||||
? config.backend
|
||||
: options.backend
|
||||
? options.backend
|
||||
: "/"
|
||||
|
||||
config = {
|
||||
serve,
|
||||
autoRebuild: options.autoRebuild ?? config.autoRebuild,
|
||||
path: options.path ?? config.path,
|
||||
outDir: options.outDir ?? config.outDir,
|
||||
backend: serverUrl,
|
||||
develop: {
|
||||
open: options.develop?.open ?? config.develop.open,
|
||||
port: options.develop?.port ?? config.develop.port,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return defaultConfig
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import colors from "picocolors"
|
||||
|
||||
const PREFIX = colors.cyan("[@medusajs/admin]")
|
||||
|
||||
export const reporter = {
|
||||
panic: (err: Error) => {
|
||||
console.error(`${PREFIX} ${colors.red(err.message)}`)
|
||||
process.exit(1)
|
||||
},
|
||||
error: (message: string) => {
|
||||
console.error(`${PREFIX} ${colors.red(message)}`)
|
||||
},
|
||||
info: (message: string) => {
|
||||
console.log(`${PREFIX} ${colors.blue(message)}`)
|
||||
},
|
||||
warn: (message: string) => {
|
||||
console.warn(`${PREFIX} ${colors.yellow(message)}`)
|
||||
},
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export const validatePath = (path?: string) => {
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
throw new Error(`Path cannot start with a slash.`)
|
||||
}
|
||||
|
||||
if (path.endsWith("/")) {
|
||||
throw new Error(`Path cannot end with a slash.`)
|
||||
}
|
||||
|
||||
if (path === "admin" || path === "store") {
|
||||
throw new Error(
|
||||
`Path cannot be one of the reserved paths: "admin", "store".`
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user