diff --git a/.changeset/mean-ligers-fry.md b/.changeset/mean-ligers-fry.md new file mode 100644 index 0000000000..68efffd274 --- /dev/null +++ b/.changeset/mean-ligers-fry.md @@ -0,0 +1,6 @@ +--- +"@medusajs/admin-ui": patch +"@medusajs/admin": patch +--- + +feat(admin,admin-ui): Updates the default behaviour of the plugin, and makes building for external deployment easier diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index 39f937d089..d95c44ceb7 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -1,8 +1,10 @@ +import dns from "dns" import fse from "fs-extra" import { resolve } from "path" import vite from "vite" import { AdminBuildConfig } from "./types" -import { getCustomViteConfig } from "./utils" +import { AdminDevConfig } from "./types/dev" +import { getCustomViteConfig, getCustomViteDevConfig } from "./utils" async function build(options?: AdminBuildConfig) { const config = getCustomViteConfig(options) @@ -25,4 +27,16 @@ async function clean() { throw new Error("Not implemented") } -export { build, watch, clean } +async function dev(options: AdminDevConfig) { + // Resolve localhost for Node v16 and older. + // @see https://vitejs.dev/config/server-options.html#server-host. + dns.setDefaultResultOrder("verbatim") + + const server = await vite.createServer(getCustomViteDevConfig(options)) + await server.listen() + + server.printUrls() +} + +export { build, dev, watch, clean } +export type { AdminBuildConfig, AdminDevConfig } diff --git a/packages/admin-ui/src/types/dev.ts b/packages/admin-ui/src/types/dev.ts new file mode 100644 index 0000000000..b58d2e32ea --- /dev/null +++ b/packages/admin-ui/src/types/dev.ts @@ -0,0 +1,4 @@ +export type AdminDevConfig = { + backend?: string + port?: number +} diff --git a/packages/admin-ui/src/types/index.ts b/packages/admin-ui/src/types/index.ts index 348f635b62..70119ba8ed 100644 --- a/packages/admin-ui/src/types/index.ts +++ b/packages/admin-ui/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./build" +export * from "./dev" export * from "./misc" diff --git a/packages/admin-ui/src/utils/format-base.ts b/packages/admin-ui/src/utils/format-base.ts index cbf2e0f14a..5ac7d2c4cb 100644 --- a/packages/admin-ui/src/utils/format-base.ts +++ b/packages/admin-ui/src/utils/format-base.ts @@ -1,5 +1,9 @@ import { Base } from "../types" -export const formatBase = (base: T): Base => { +export const formatBase = (base?: T): Base => { + if (!base) { + return undefined + } + return `/${base}/` } diff --git a/packages/admin-ui/src/utils/get-custom-vite-config.ts b/packages/admin-ui/src/utils/get-custom-vite-config.ts index 7bc4af9554..3cdb8d8baa 100644 --- a/packages/admin-ui/src/utils/get-custom-vite-config.ts +++ b/packages/admin-ui/src/utils/get-custom-vite-config.ts @@ -10,9 +10,7 @@ export const getCustomViteConfig = (config: AdminBuildConfig): InlineConfig => { const uiPath = resolve(__dirname, "..", "..", "ui") const globalReplacements = () => { - const base = globals.base || "app" - - let backend = "/" + let backend = undefined if (globals.backend) { try { @@ -26,10 +24,12 @@ export const getCustomViteConfig = (config: AdminBuildConfig): InlineConfig => { } } - return { - __BASE__: JSON.stringify(`/${base}`), - __MEDUSA_BACKEND_URL__: JSON.stringify(backend), - } + const global = {} + + global["__BASE__"] = JSON.stringify(globals.base ? `/${globals.base}` : "/") + global["__MEDUSA_BACKEND_URL__"] = JSON.stringify(backend ? backend : "/") + + return global } const buildConfig = (): BuildOptions => { @@ -41,7 +41,7 @@ export const getCustomViteConfig = (config: AdminBuildConfig): InlineConfig => { /** * Default build directory is at the root of the `@medusajs/admin-ui` package. */ - destDir = resolve(__dirname, "..", "..", "build") + destDir = resolve(process.cwd(), "build") } else { /** * If a custom build directory is specified, it is resolved relative to the diff --git a/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts b/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts new file mode 100644 index 0000000000..b0def537b7 --- /dev/null +++ b/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts @@ -0,0 +1,24 @@ +import react from "@vitejs/plugin-react" +import { resolve } from "path" +import { InlineConfig } from "vite" +import { AdminDevConfig } from "../types/dev" + +export const getCustomViteDevConfig = ({ + backend = "http://localhost:9000", + port = 7001, +}: AdminDevConfig): InlineConfig => { + const uiPath = resolve(__dirname, "..", "..", "ui") + + return { + define: { + __BASE__: JSON.stringify("/"), + __MEDUSA_BACKEND_URL__: JSON.stringify(backend), + }, + plugins: [react()], + root: uiPath, + mode: "development", + server: { + port, + }, + } +} diff --git a/packages/admin-ui/src/utils/index.ts b/packages/admin-ui/src/utils/index.ts index 06ac389b4c..8e0a7575a5 100644 --- a/packages/admin-ui/src/utils/index.ts +++ b/packages/admin-ui/src/utils/index.ts @@ -1,2 +1,3 @@ export * from "./format-base" export * from "./get-custom-vite-config" +export * from "./get-custom-vite-dev-config" diff --git a/packages/admin/README.md b/packages/admin/README.md index 1a247607dc..053c645f0b 100644 --- a/packages/admin/README.md +++ b/packages/admin/README.md @@ -64,12 +64,12 @@ module.exports = { The plugin can be configured with the following options: -| Option | Type | Description | Default | -| --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `serve` | `boolean?` | Whether to serve the admin dashboard or not. | `true` | -| `path` | `string?` | The path the admin server should run on. Should not be prefixed or suffixed with a slash. Cannot be one of the reserved paths: `"admin"` and `"store"`. | `"app"` | -| `outDir` | `string?` | Optional path for where to output the admin build files | `undefined` | -| `backend` | `string?` | URL to server. Should only be set if you plan on hosting the admin dashboard separately from your server | `undefined` | +| Option | Type | Description | Default | +| ------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `serve` | `boolean?` | Whether to serve the admin dashboard or not. | `true` | +| `path` | `string?` | The path the admin server should run on. Should not be prefixed or suffixed with a slash. Cannot be one of the reserved paths: `"admin"` and `"store"`. | `"app"` | +| `outDir` | `string?` | Optional path for where to output the admin build files | `undefined` | +| `autoRebuild` | `boolean?` | Decides whether the admin UI should be rebuild if any changes or a missing build is detected during server startup | `false` | **Hint**: You can import the PluginOptions type for inline documentation for the different options: @@ -91,9 +91,9 @@ module.exports = { ## Building the admin dashboard -The admin will be built automatically the first time you start your server. Any subsequent changes to the plugin options will result in a rebuild of the admin dashboard. +The admin will be built automatically the first time you start your server if you have enabled `autoRebuild`. Any subsequent changes to the plugin options will result in a rebuild of the admin dashboard. -You may need to manually trigger a rebuild sometimes, for example after you have upgraded to a newer version of `@medusajs/admin`. You can do so by adding the following script to your `package.json`: +You may need to manually trigger a rebuild sometimes, for example after you have upgraded to a newer version of `@medusajs/admin`, or if you have disabled `autoRebuild`. You can do so by adding the following script to your `package.json`: ```json { diff --git a/packages/admin/package.json b/packages/admin/package.json index 625404f3bf..a548f5c4d5 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -28,11 +28,13 @@ "dependencies": { "@medusajs/admin-ui": "*", "commander": "^10.0.0", + "dotenv": "^16.0.3", "express": "^4.17.1", "fs-extra": "^11.1.0", "medusa-core-utils": "*", "ora": "5.4.0", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "ts-dedent": "^2.2.0" }, "peerDependencies": { "@medusajs/medusa": "*" diff --git a/packages/admin/src/api/index.ts b/packages/admin/src/api/index.ts index d01e6c5c84..419e171ea1 100644 --- a/packages/admin/src/api/index.ts +++ b/packages/admin/src/api/index.ts @@ -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.` ) ) } diff --git a/packages/admin/src/commands/build.ts b/packages/admin/src/commands/build.ts index 74d700ccc5..1128338552 100644 --- a/packages/admin/src/commands/build.ts +++ b/packages/admin/src/commands/build.ts @@ -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, - } -} diff --git a/packages/admin/src/commands/create-cli.ts b/packages/admin/src/commands/create-cli.ts index 764594b557..149631fd18 100644 --- a/packages/admin/src/commands/create-cli.ts +++ b/packages/admin/src/commands/create-cli.ts @@ -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 { 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 ", "Output directory") buildCommand.option("-b, --backend ", "Backend URL") - buildCommand.option("-p, --path ", "Base path") + buildCommand.option( + "-i, --include [paths...]]", + "Paths to files that should be included in the build" + ) + buildCommand.option( + "-d, --include-dist ", + "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 (default: 7001))") + devCommand.option( + "-b, --backend ", + "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 ", "Output directory") + deployCommand.action(eject) + return program } diff --git a/packages/admin/src/commands/dev.ts b/packages/admin/src/commands/dev.ts new file mode 100644 index 0000000000..b362d8f28e --- /dev/null +++ b/packages/admin/src/commands/dev.ts @@ -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) +} diff --git a/packages/admin/src/commands/eject.ts b/packages/admin/src/commands/eject.ts new file mode 100644 index 0000000000..46d94eaca0 --- /dev/null +++ b/packages/admin/src/commands/eject.ts @@ -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) +} diff --git a/packages/admin/src/setup/index.ts b/packages/admin/src/setup/index.ts index 8832dd594d..3fc58ee856 100644 --- a/packages/admin/src/setup/index.ts +++ b/packages/admin/src/setup/index.ts @@ -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) diff --git a/packages/admin/src/types/index.ts b/packages/admin/src/types/index.ts index bf4fb74a97..f9705c2c14 100644 --- a/packages/admin/src/types/index.ts +++ b/packages/admin/src/types/index.ts @@ -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 = { diff --git a/packages/admin/src/utils/load-config.ts b/packages/admin/src/utils/load-config.ts index b395cfda84..1c9f5fc0a2 100644 --- a/packages/admin/src/utils/load-config.ts +++ b/packages/admin/src/utils/load-config.ts @@ -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, } } diff --git a/packages/admin/src/utils/validate-path.ts b/packages/admin/src/utils/validate-path.ts index 6ce66a7e12..4e47da07ee 100644 --- a/packages/admin/src/utils/validate-path.ts +++ b/packages/admin/src/utils/validate-path.ts @@ -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.`) } diff --git a/yarn.lock b/yarn.lock index 6a62685a60..51a47aeb20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5680,11 +5680,13 @@ __metadata: "@medusajs/admin-ui": "*" "@types/express": ^4.17.13 commander: ^10.0.0 + dotenv: ^16.0.3 express: ^4.17.1 fs-extra: ^11.1.0 medusa-core-utils: "*" ora: 5.4.0 picocolors: ^1.0.0 + ts-dedent: ^2.2.0 typescript: ^4.9.3 peerDependencies: "@medusajs/medusa": "*" @@ -18025,7 +18027,7 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.0.0": +"dotenv@npm:^16.0.0, dotenv@npm:^16.0.3": version: 16.0.3 resolution: "dotenv@npm:16.0.3" checksum: 109457ac5f9e930ca8066ea33887b6f839ab24d647a7a8b49ddcd1f32662e2c35591c5e5b9819063e430148a664d0927f0cbe60cf9575d89bc524f47ff7e78f0 @@ -38219,7 +38221,7 @@ __metadata: languageName: node linkType: hard -"ts-dedent@npm:^2.0.0": +"ts-dedent@npm:^2.0.0, ts-dedent@npm:^2.2.0": version: 2.2.0 resolution: "ts-dedent@npm:2.2.0" checksum: 175adea838468cc2ff7d5e97f970dcb798bbcb623f29c6088cb21aa2880d207c5784be81ab1741f56b9ac37840cbaba0c0d79f7f8b67ffe61c02634cafa5c303