Explorar el Código

feat(create): Add Next.js storefront option to create command (#4015)

David Höck hace 1 mes
padre
commit
3713923b3d

+ 1 - 0
packages/create/package.json

@@ -46,6 +46,7 @@
         "open": "^10.2.0",
         "picocolors": "^1.1.1",
         "semver": "^7.7.2",
+        "tar": "^7.4.3",
         "tcp-port-used": "^1.0.2"
     }
 }

+ 41 - 1
packages/create/src/constants.ts

@@ -1,7 +1,47 @@
-export const REQUIRED_NODE_VERSION = '>=18.0.0';
+export const REQUIRED_NODE_VERSION = '>=20.0.0';
 export const SERVER_PORT = 3000;
+export const STOREFRONT_PORT = 3001;
+export const STOREFRONT_REPO = 'vendure-ecommerce/nextjs-starter-vendure';
+export const STOREFRONT_BRANCH = 'main';
 /**
  * The TypeScript version needs to pinned because minor versions often
  * introduce breaking changes.
  */
 export const TYPESCRIPT_VERSION = '5.8.2';
+
+// Port scanning
+export const PORT_SCAN_RANGE = 20;
+
+// Timing constants (milliseconds)
+export const SCAFFOLD_DELAY_MS = 500;
+export const TIP_INTERVAL_MS = 10_000;
+export const CI_PAUSE_BEFORE_CLOSE_MS = 30_000;
+export const CI_PAUSE_AFTER_CLOSE_MS = 10_000;
+export const NORMAL_PAUSE_BEFORE_CLOSE_MS = 2_000;
+export const AUTO_RUN_DELAY_MS = 10_000;
+
+// Default project values
+export const DEFAULT_PROJECT_VERSION = '0.1.0';
+export const TIPS_WHILE_WAITING = [
+    '☕ This can take a minute or two, so grab a coffee',
+    `✨ We'd love it if you drop us a star on GitHub: https://github.com/vendure-ecommerce/vendure`,
+    `📖 Check out the Vendure documentation at https://docs.vendure.io`,
+    `💬 Join our Discord community to chat with other Vendure developers: https://vendure.io/community`,
+    '💡 In the mean time, here are some tips to get you started',
+    `Vendure provides dedicated GraphQL APIs for both the Admin and Shop`,
+    `Almost every aspect of Vendure is customizable via plugins`,
+    `You can run 'vendure add' from the command line to add new plugins & features`,
+    `Use the EventBus in your plugins to react to events in the system`,
+    `Vendure supports multiple languages & currencies out of the box`,
+    `☕ Did we mention this can take a while?`,
+    `Our custom fields feature allows you to add any kind of data to your entities`,
+    `Vendure is built with TypeScript, so you get full type safety`,
+    `Combined with GraphQL's static schema, your type safety is end-to-end`,
+    `☕ Almost there now... thanks for your patience!`,
+    `Collections allow you to group products together`,
+    `Our AssetServerPlugin allows you to dynamically resize & optimize images`,
+    `Order flows are fully customizable to suit your business requirements`,
+    `Role-based permissions allow you to control access to every part of the system`,
+    `Customers can be grouped for targeted promotions & custom pricing`,
+    `You can find integrations in the Vendure Hub: https://vendure.io/hub`,
+];

+ 354 - 132
packages/create/src/create-vendure-app.ts

@@ -1,6 +1,9 @@
 import { intro, note, outro, select, spinner } from '@clack/prompts';
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
 import { program } from 'commander';
+import { randomBytes } from 'crypto';
 import fs from 'fs-extra';
+import Handlebars from 'handlebars';
 import { ChildProcess, spawn } from 'node:child_process';
 import { setTimeout as sleep } from 'node:timers/promises';
 import open from 'open';
@@ -8,7 +11,20 @@ import os from 'os';
 import path from 'path';
 import pc from 'picocolors';
 
-import { REQUIRED_NODE_VERSION, SERVER_PORT } from './constants';
+import {
+    AUTO_RUN_DELAY_MS,
+    CI_PAUSE_AFTER_CLOSE_MS,
+    CI_PAUSE_BEFORE_CLOSE_MS,
+    DEFAULT_PROJECT_VERSION,
+    NORMAL_PAUSE_BEFORE_CLOSE_MS,
+    PORT_SCAN_RANGE,
+    REQUIRED_NODE_VERSION,
+    SCAFFOLD_DELAY_MS,
+    SERVER_PORT,
+    STOREFRONT_PORT,
+    TIP_INTERVAL_MS,
+    TIPS_WHILE_WAITING,
+} from './constants';
 import {
     getCiConfiguration,
     getManualConfiguration,
@@ -20,10 +36,11 @@ import {
     checkNodeVersion,
     checkThatNpmCanReadCwd,
     cleanUpDockerResources,
+    downloadAndExtractStorefront,
+    findAvailablePort,
     getDependencies,
     installPackages,
     isSafeToCreateProjectIn,
-    isServerPortInUse,
     resolvePackageRootDir,
     scaffoldAlreadyExists,
     startPostgresDatabase,
@@ -62,6 +79,7 @@ program
         'Uses npm rather than as the default package manager. DEPRECATED: Npm is now the default',
     )
     .option('--ci', 'Runs without prompts for use in CI scenarios', false)
+    .option('--with-storefront', 'Include Next.js storefront (only used with --ci)', false)
     .parse(process.argv);
 
 const options = program.opts();
@@ -70,6 +88,7 @@ void createVendureApp(
     options.useNpm,
     options.verbose ? 'verbose' : options.logLevel || 'info',
     options.ci,
+    options.withStorefront,
 ).catch(err => {
     log(err);
     process.exit(1);
@@ -77,12 +96,13 @@ void createVendureApp(
 
 export async function createVendureApp(
     name: string | undefined,
-    useNpm: boolean,
+    _useNpm: boolean, // Deprecated: npm is now the default package manager
     logLevel: CliLogLevel,
     isCi: boolean = false,
+    withStorefront: boolean = false,
 ) {
     setLogLevel(logLevel);
-    if (!runPreChecks(name, useNpm)) {
+    if (!runPreChecks(name)) {
         return;
     }
 
@@ -107,22 +127,16 @@ export async function createVendureApp(
     checkCancel(mode);
 
     const portSpinner = spinner();
-    let port = SERVER_PORT;
-    const attemptedPortRange = 20;
+    let port: number;
     portSpinner.start(`Establishing port...`);
-    while (await isServerPortInUse(port)) {
-        const nextPort = port + 1;
-        portSpinner.message(pc.yellow(`Port ${port} is in use. Attempting to use ${nextPort}`));
-        port = nextPort;
-        if (port > SERVER_PORT + attemptedPortRange) {
-            portSpinner.stop(pc.red('Could not find an available port'));
-            outro(
-                `Please ensure there is a port available between ${SERVER_PORT} and ${SERVER_PORT + attemptedPortRange}`,
-            );
-            process.exit(1);
-        }
+    try {
+        port = await findAvailablePort(SERVER_PORT, PORT_SCAN_RANGE);
+        portSpinner.stop(`Using port ${port}`);
+    } catch (e: any) {
+        portSpinner.stop(pc.red('Could not find an available port'));
+        outro(e.message);
+        process.exit(1);
     }
-    portSpinner.stop(`Using port ${port}`);
     process.env.PORT = port.toString();
 
     const root = path.resolve(name);
@@ -149,66 +163,157 @@ export async function createVendureApp(
         readmeSource,
         dockerfileSource,
         dockerComposeSource,
+        tsconfigDashboardSource,
         populateProducts,
+        includeStorefront,
     } =
         mode === 'ci'
-            ? await getCiConfiguration(root, packageManager)
+            ? await getCiConfiguration(root, packageManager, port, withStorefront)
             : mode === 'manual'
-              ? await getManualConfiguration(root, packageManager)
-              : await getQuickStartConfiguration(root, packageManager);
+              ? await getManualConfiguration(root, packageManager, port)
+              : await getQuickStartConfiguration(root, packageManager, port);
+    // Determine the server root directory (either root or apps/server for monorepo)
+    const serverRoot = includeStorefront ? path.join(root, 'apps', 'server') : root;
+    const storefrontRoot = path.join(root, 'apps', 'storefront');
+    const storefrontPort = STOREFRONT_PORT;
+
     process.chdir(root);
     if (packageManager !== 'npm' && !checkThatNpmCanReadCwd()) {
         process.exit(1);
     }
 
-    const packageJsonContents = {
-        name: appName,
-        version: '0.1.0',
-        private: true,
-        scripts: {
-            'dev:server': 'ts-node ./src/index.ts',
-            'dev:worker': 'ts-node ./src/index-worker.ts',
-            dev: 'concurrently npm:dev:*',
-            build: 'tsc',
-            'start:server': 'node ./dist/index.js',
-            'start:worker': 'node ./dist/index-worker.js',
-            start: 'concurrently npm:start:*',
-        },
-    };
-
     const setupSpinner = spinner();
+    const projectType = includeStorefront ? 'monorepo' : 'project';
     setupSpinner.start(
-        `Setting up your new Vendure project in ${pc.green(root)}\nThis may take a few minutes...`,
+        `Setting up your new Vendure ${projectType} in ${pc.green(root)}\nThis may take a few minutes...`,
     );
 
-    const srcPathScript = (fileName: string): string => path.join(root, 'src', `${fileName}.ts`);
+    const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName);
+    const templatePath = (fileName: string) => path.join(__dirname, '../assets/monorepo', fileName);
+
+    if (includeStorefront) {
+        // Create monorepo structure
+        await fs.ensureDir(path.join(root, 'apps'));
+        await fs.ensureDir(serverRoot);
+        await fs.ensureDir(path.join(serverRoot, 'src'));
+
+        // Generate root package.json from template
+        const rootPackageTemplate = await fs.readFile(templatePath('root-package.json.hbs'), 'utf-8');
+        const rootPackageContent = Handlebars.compile(rootPackageTemplate)({ name: appName });
+        fs.writeFileSync(path.join(root, 'package.json'), rootPackageContent + os.EOL);
+
+        // Generate root README from template
+        const rootReadmeTemplate = await fs.readFile(templatePath('root-readme.hbs'), 'utf-8');
+        const rootReadmeContent = Handlebars.compile(rootReadmeTemplate)({
+            name: appName,
+            serverPort: port,
+            storefrontPort,
+            superadminIdentifier: SUPER_ADMIN_USER_IDENTIFIER,
+            superadminPassword: SUPER_ADMIN_USER_PASSWORD,
+        });
+        fs.writeFileSync(path.join(root, 'README.md'), rootReadmeContent);
+
+        // Copy root .gitignore
+        await fs.copyFile(templatePath('root-gitignore.template'), path.join(root, '.gitignore'));
+
+        // Create server package.json
+        const serverPackageJsonContents = {
+            name: 'server',
+            version: DEFAULT_PROJECT_VERSION,
+            private: true,
+            scripts: getServerPackageScripts(),
+        };
+        fs.writeFileSync(
+            path.join(serverRoot, 'package.json'),
+            JSON.stringify(serverPackageJsonContents, null, 2) + os.EOL,
+        );
+    } else {
+        // Single project structure (original behavior)
+        const packageJsonContents = {
+            name: appName,
+            version: DEFAULT_PROJECT_VERSION,
+            private: true,
+            scripts: getServerPackageScripts(),
+        };
+        fs.writeFileSync(
+            path.join(root, 'package.json'),
+            JSON.stringify(packageJsonContents, null, 2) + os.EOL,
+        );
+        fs.ensureDirSync(path.join(root, 'src'));
+    }
 
-    fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(packageJsonContents, null, 2) + os.EOL);
-    const { dependencies, devDependencies } = getDependencies(dbType, `@${packageJson.version as string}`);
     setupSpinner.stop(`Created ${pc.green('package.json')}`);
 
-    const installSpinner = spinner();
-    installSpinner.start(`Installing ${dependencies[0]} + ${dependencies.length - 1} more dependencies`);
-    try {
-        await installPackages({ dependencies, logLevel });
-    } catch (e) {
-        outro(pc.red(`Failed to inst all dependencies. Please try again.`));
-        process.exit(1);
+    // Download storefront if needed
+    if (includeStorefront) {
+        const storefrontSpinner = spinner();
+        storefrontSpinner.start(`Downloading Next.js storefront...`);
+        try {
+            await downloadAndExtractStorefront(storefrontRoot);
+            // Update storefront package.json name
+            const storefrontPackageJsonPath = path.join(storefrontRoot, 'package.json');
+            const storefrontPackageJson = await fs.readJson(storefrontPackageJsonPath);
+            storefrontPackageJson.name = 'storefront';
+            await fs.writeJson(storefrontPackageJsonPath, storefrontPackageJson, { spaces: 2 });
+
+            // Generate storefront .env.local from template
+            const storefrontEnvTemplate = await fs.readFile(templatePath('storefront-env.hbs'), 'utf-8');
+            const storefrontEnvContent = Handlebars.compile(storefrontEnvTemplate)({
+                serverPort: port,
+                storefrontPort,
+                name: appName,
+                revalidationSecret: randomBytes(32).toString('base64'),
+            });
+            fs.writeFileSync(path.join(storefrontRoot, '.env.local'), storefrontEnvContent);
+
+            storefrontSpinner.stop(`Downloaded Next.js storefront`);
+        } catch (e: any) {
+            storefrontSpinner.stop(pc.red(`Failed to download storefront`));
+            log(e.message, { level: 'verbose' });
+            outro(pc.red(`Failed to download storefront: ${e.message as string}`));
+            process.exit(1);
+        }
     }
-    installSpinner.stop(`Successfully installed ${dependencies.length} dependencies`);
+
+    // Install dependencies
+    const { dependencies, devDependencies } = getDependencies(dbType, `@${packageJson.version as string}`);
+
+    // Install server dependencies
+    await installDependenciesWithSpinner({
+        dependencies,
+        logLevel,
+        cwd: serverRoot,
+        spinnerMessage: `Installing ${dependencies[0]} + ${dependencies.length - 1} more dependencies`,
+        successMessage: `Successfully installed ${dependencies.length} dependencies`,
+        failureMessage: 'Failed to install dependencies. Please try again.',
+    });
 
     if (devDependencies.length) {
-        const installDevSpinner = spinner();
-        installDevSpinner.start(
-            `Installing ${devDependencies[0]} + ${devDependencies.length - 1} more dev dependencies`,
-        );
-        try {
-            await installPackages({ dependencies: devDependencies, isDevDependencies: true, logLevel });
-        } catch (e) {
-            outro(pc.red(`Failed to install dev dependencies. Please try again.`));
-            process.exit(1);
+        await installDependenciesWithSpinner({
+            dependencies: devDependencies,
+            isDevDependencies: true,
+            logLevel,
+            cwd: serverRoot,
+            spinnerMessage: `Installing ${devDependencies[0]} + ${devDependencies.length - 1} more dev dependencies`,
+            successMessage: `Successfully installed ${devDependencies.length} dev dependencies`,
+            failureMessage: 'Failed to install dev dependencies. Please try again.',
+        });
+    }
+
+    if (includeStorefront) {
+        // Install storefront dependencies
+        const storefrontInstalled = await installDependenciesWithSpinner({
+            dependencies: [],
+            logLevel,
+            cwd: storefrontRoot,
+            spinnerMessage: 'Installing storefront dependencies...',
+            successMessage: 'Installed storefront dependencies',
+            failureMessage: 'Failed to install storefront dependencies',
+            warnOnFailure: true,
+        });
+        if (!storefrontInstalled) {
+            log('You may need to run npm install in the storefront directory manually.', { level: 'info' });
         }
-        installDevSpinner.stop(`Successfully installed ${devDependencies.length} dev dependencies`);
     }
 
     const scaffoldSpinner = spinner();
@@ -216,35 +321,39 @@ export async function createVendureApp(
     // We add this pause so that the above output is displayed before the
     // potentially lengthy file operations begin, which can prevent that
     // from displaying and thus make the user think that the process has hung.
-    await sleep(500);
-    fs.ensureDirSync(path.join(root, 'src'));
-    const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName);
+    await sleep(SCAFFOLD_DELAY_MS);
+
+    const srcPathScript = (fileName: string): string => path.join(serverRoot, 'src', `${fileName}.ts`);
+
+    if (!includeStorefront) {
+        fs.ensureDirSync(path.join(serverRoot, 'src'));
+    }
+
     const configFile = srcPathScript('vendure-config');
 
     try {
         await fs
             .writeFile(configFile, configSource)
-            .then(() => fs.writeFile(path.join(root, '.env'), envSource))
+            .then(() => fs.writeFile(path.join(serverRoot, '.env'), envSource))
             .then(() => fs.writeFile(srcPathScript('environment.d'), envDtsSource))
             .then(() => fs.writeFile(srcPathScript('index'), indexSource))
             .then(() => fs.writeFile(srcPathScript('index-worker'), indexWorkerSource))
-            .then(() => fs.writeFile(path.join(root, 'README.md'), readmeSource))
-            .then(() => fs.writeFile(path.join(root, 'Dockerfile'), dockerfileSource))
-            .then(() => fs.writeFile(path.join(root, 'docker-compose.yml'), dockerComposeSource))
-            .then(() => fs.ensureDir(path.join(root, 'src/plugins')))
-            .then(() => fs.copyFile(assetPath('gitignore.template'), path.join(root, '.gitignore')))
-            .then(() => fs.copyFile(assetPath('tsconfig.template.json'), path.join(root, 'tsconfig.json')))
+            .then(() => fs.writeFile(path.join(serverRoot, 'README.md'), readmeSource))
+            .then(() => fs.writeFile(path.join(serverRoot, 'Dockerfile'), dockerfileSource))
+            .then(() => fs.writeFile(path.join(serverRoot, 'docker-compose.yml'), dockerComposeSource))
+            .then(() => fs.ensureDir(path.join(serverRoot, 'src/plugins')))
+            .then(() => fs.copyFile(assetPath('gitignore.template'), path.join(serverRoot, '.gitignore')))
             .then(() =>
-                fs.copyFile(
-                    assetPath('tsconfig.dashboard.template.json'),
-                    path.join(root, 'tsconfig.dashboard.json'),
-                ),
+                fs.copyFile(assetPath('tsconfig.template.json'), path.join(serverRoot, 'tsconfig.json')),
             )
             .then(() =>
-                fs.copyFile(assetPath('vite.config.template.mts'), path.join(root, 'vite.config.mts')),
+                fs.writeFile(path.join(serverRoot, 'tsconfig.dashboard.json'), tsconfigDashboardSource),
             )
-            .then(() => createDirectoryStructure(root))
-            .then(() => copyEmailTemplates(root));
+            .then(() =>
+                fs.copyFile(assetPath('vite.config.template.mts'), path.join(serverRoot, 'vite.config.mts')),
+            )
+            .then(() => createDirectoryStructure(serverRoot))
+            .then(() => copyEmailTemplates(serverRoot));
     } catch (e: any) {
         outro(pc.red(`Failed to create app scaffold: ${e.message as string}`));
         process.exit(1);
@@ -253,7 +362,7 @@ export async function createVendureApp(
 
     if (mode === 'quick' && dbType === 'postgres') {
         cleanUpDockerResources(name);
-        await startPostgresDatabase(root);
+        await startPostgresDatabase(serverRoot);
     }
 
     const populateSpinner = spinner();
@@ -266,32 +375,11 @@ export async function createVendureApp(
         populateProducts
             ? 'We are populating sample data so that you can start testing right away'
             : 'We are setting up your Vendure server',
-        '☕ This can take a minute or two, so grab a coffee',
-        `✨ We'd love it if you drop us a star on GitHub: https://github.com/vendure-ecommerce/vendure`,
-        `📖 Check out the Vendure documentation at https://docs.vendure.io`,
-        `💬 Join our Discord community to chat with other Vendure developers: https://vendure.io/community`,
-        '💡 In the mean time, here are some tips to get you started',
-        `Vendure provides dedicated GraphQL APIs for both the Admin and Shop`,
-        `Almost every aspect of Vendure is customizable via plugins`,
-        `You can run 'vendure add' from the command line to add new plugins & features`,
-        `Use the EventBus in your plugins to react to events in the system`,
-        `Vendure supports multiple languages & currencies out of the box`,
-        `☕ Did we mention this can take a while?`,
-        `Our custom fields feature allows you to add any kind of data to your entities`,
-        `Vendure is built with TypeScript, so you get full type safety`,
-        `Combined with GraphQL's static schema, your type safety is end-to-end`,
-        `☕ Almost there now... thanks for your patience!`,
-        `Collections allow you to group products together`,
-        `Our AssetServerPlugin allows you to dynamically resize & optimize images`,
-        `Order flows are fully customizable to suit your business requirements`,
-        `Role-based permissions allow you to control access to every part of the system`,
-        `Customers can be grouped for targeted promotions & custom pricing`,
-        `You can find integrations in the Vendure Hub: https://vendure.io/hub`,
+        ...TIPS_WHILE_WAITING,
     ];
 
     let tipIndex = 0;
     let timer: any;
-    const tipInterval = 10_000;
 
     function displayTip() {
         populateSpinner.message(tips[tipIndex]);
@@ -300,22 +388,35 @@ export async function createVendureApp(
             // skip the intro tips if looping
             tipIndex = 3;
         }
-        timer = setTimeout(displayTip, tipInterval);
+        timer = setTimeout(displayTip, TIP_INTERVAL_MS);
     }
 
-    timer = setTimeout(displayTip, tipInterval);
+    timer = setTimeout(displayTip, TIP_INTERVAL_MS);
+
+    // Change to serverRoot so that ts-node can correctly resolve modules.
+    // In monorepo mode, dependencies are hoisted to the root node_modules,
+    // but ts-node needs to be anchored in the server directory for proper
+    // module resolution and to find the tsconfig.json.
+    process.chdir(serverRoot);
 
     // register ts-node so that the config file can be loaded
+    // We use transpileOnly to skip type checking during bootstrap, as the
+    // complex module resolution with npm workspaces and ESM packages can
+    // cause false TypeScript errors. Type checking happens when users run
+    // their own build/dev commands.
     // eslint-disable-next-line @typescript-eslint/no-var-requires
-    require(resolvePackageRootDir('ts-node', root)).register();
+    require(resolvePackageRootDir('ts-node', serverRoot)).register({
+        project: path.join(serverRoot, 'tsconfig.json'),
+        transpileOnly: true,
+    });
 
     let superAdminCredentials: { identifier: string; password: string } | undefined;
     try {
         const { populate } = await import(
-            path.join(resolvePackageRootDir('@vendure/core', root), 'cli', 'populate')
+            path.join(resolvePackageRootDir('@vendure/core', serverRoot), 'cli', 'populate')
         );
         const { bootstrap, DefaultLogger, LogLevel, JobQueueService } = await import(
-            path.join(resolvePackageRootDir('@vendure/core', root), 'dist', 'index')
+            path.join(resolvePackageRootDir('@vendure/core', serverRoot), 'dist', 'index')
         );
         const { config } = await import(configFile);
         const assetsDir = path.join(__dirname, '../assets');
@@ -329,7 +430,7 @@ export async function createVendureApp(
                   : LogLevel.Info;
 
         const bootstrapFn = async () => {
-            await checkDbConnection(config.dbConnectionOptions, root);
+            await checkDbConnection(config.dbConnectionOptions, serverRoot);
             const _app = await bootstrap({
                 ...config,
                 apiOptions: {
@@ -359,11 +460,11 @@ export async function createVendureApp(
         if (isCi) {
             log('[CI] Pausing before close...');
         }
-        await sleep(isCi ? 30000 : 2000);
+        await sleep(isCi ? CI_PAUSE_BEFORE_CLOSE_MS : NORMAL_PAUSE_BEFORE_CLOSE_MS);
         await app.close();
         if (isCi) {
             log('[CI] Pausing after close...');
-            await sleep(10000);
+            await sleep(CI_PAUSE_AFTER_CLOSE_MS);
         }
         populateSpinner.stop(`Server successfully initialized${populateProducts ? ' and populated' : ''}`);
         clearTimeout(timer);
@@ -404,14 +505,21 @@ export async function createVendureApp(
 
                 // process.stdin.resume();
                 process.on('SIGINT', function () {
-                    displayOutro(root, name, superAdminCredentials);
+                    displayOutro({
+                        root,
+                        name,
+                        superAdminCredentials,
+                        includeStorefront,
+                        serverPort: port,
+                        storefrontPort,
+                    });
                     quickStartProcess?.kill('SIGINT');
                     process.exit(0);
                 });
 
                 // Give enough time for the server to get up and running
                 // before opening the window.
-                await sleep(10_000);
+                await sleep(AUTO_RUN_DELAY_MS);
                 try {
                     await open(dashboardUrl, {
                         newInstance: true,
@@ -427,7 +535,14 @@ export async function createVendureApp(
             }
         } else {
             clearTimeout(timer);
-            displayOutro(root, name, superAdminCredentials);
+            displayOutro({
+                root,
+                name,
+                superAdminCredentials,
+                includeStorefront,
+                serverPort: port,
+                storefrontPort,
+            });
             process.exit(0);
         }
     } catch (e: any) {
@@ -437,37 +552,144 @@ export async function createVendureApp(
     }
 }
 
-function displayOutro(
-    root: string,
-    name: string,
-    superAdminCredentials?: { identifier: string; password: string },
-) {
+/**
+ * Returns the standard npm scripts for the server package.json.
+ */
+function getServerPackageScripts(): Record<string, string> {
+    return {
+        'dev:server': 'ts-node ./src/index.ts',
+        'dev:worker': 'ts-node ./src/index-worker.ts',
+        dev: 'concurrently npm:dev:*',
+        build: 'tsc',
+        'start:server': 'node ./dist/index.js',
+        'start:worker': 'node ./dist/index-worker.js',
+        start: 'concurrently npm:start:*',
+    };
+}
+
+interface InstallDependenciesOptions {
+    dependencies: string[];
+    isDevDependencies?: boolean;
+    logLevel: CliLogLevel;
+    cwd: string;
+    spinnerMessage: string;
+    successMessage: string;
+    failureMessage: string;
+    warnOnFailure?: boolean;
+}
+
+/**
+ * Installs dependencies with a spinner, handling success/failure messaging.
+ * Returns true if installation succeeded, false otherwise.
+ */
+async function installDependenciesWithSpinner(installOptions: InstallDependenciesOptions): Promise<boolean> {
+    const {
+        dependencies,
+        isDevDependencies = false,
+        logLevel,
+        cwd,
+        spinnerMessage,
+        successMessage,
+        failureMessage,
+        warnOnFailure = false,
+    } = installOptions;
+
+    const installSpinner = spinner();
+    installSpinner.start(spinnerMessage);
+
+    try {
+        await installPackages({ dependencies, isDevDependencies, logLevel, cwd });
+        installSpinner.stop(successMessage);
+        return true;
+    } catch (e) {
+        if (warnOnFailure) {
+            installSpinner.stop(pc.yellow(`Warning: ${failureMessage}`));
+            return false;
+        } else {
+            outro(pc.red(failureMessage));
+            process.exit(1);
+        }
+    }
+}
+
+interface OutroOptions {
+    root: string;
+    name: string;
+    superAdminCredentials?: { identifier: string; password: string };
+    includeStorefront?: boolean;
+    serverPort?: number;
+    storefrontPort?: number;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-shadow
+function displayOutro(outroOptions: OutroOptions) {
+    const {
+        root,
+        name,
+        superAdminCredentials,
+        includeStorefront,
+        serverPort = SERVER_PORT,
+        storefrontPort = STOREFRONT_PORT,
+    } = outroOptions;
     const startCommand = 'npm run dev';
-    const nextSteps = [
-        `Your new Vendure server was created!`,
-        pc.gray(root),
-        `\n`,
-        `Next, run:`,
-        pc.gray('$ ') + pc.blue(pc.bold(`cd ${name}`)),
-        pc.gray('$ ') + pc.blue(pc.bold(`${startCommand}`)),
-        `\n`,
-        `This will start the server in development mode.`,
-        `\n`,
-        `To run the Dashboard, in a new terminal navigate to your project directory and run:`,
-        pc.gray('$ ') + pc.blue(pc.bold(`npx vite`)),
-        `\n`,
-        `To access the Dashboard, open your browser and navigate to:`,
-        pc.green(`http://localhost:3000/dashboard`),
+    const identifier = superAdminCredentials?.identifier ?? SUPER_ADMIN_USER_IDENTIFIER;
+    const password = superAdminCredentials?.password ?? SUPER_ADMIN_USER_PASSWORD;
+
+    // Common footer for both modes
+    const commonFooter = [
         `\n`,
         `Use the following credentials to log in:`,
-        `Username: ${pc.green(superAdminCredentials?.identifier ?? 'superadmin')}`,
-        `Password: ${pc.green(superAdminCredentials?.password ?? 'superadmin')}`,
+        `Username: ${pc.green(identifier)}`,
+        `Password: ${pc.green(password)}`,
         '\n',
         '➡️ Docs: https://docs.vendure.io',
         '➡️ Discord community: https://vendure.io/community',
         '➡️ Star us on GitHub:',
         '   https://github.com/vendure-ecommerce/vendure',
     ];
+
+    let nextSteps: string[];
+
+    if (includeStorefront) {
+        nextSteps = [
+            `Your new Vendure project was created!`,
+            pc.gray(root),
+            `\n`,
+            `This is a monorepo with the following apps:`,
+            `  ${pc.cyan('apps/server')}     - Vendure backend`,
+            `  ${pc.cyan('apps/storefront')} - Next.js frontend`,
+            `\n`,
+            `Next, run:`,
+            pc.gray('$ ') + pc.blue(pc.bold(`cd ${name}`)),
+            pc.gray('$ ') + pc.blue(pc.bold(`${startCommand}`)),
+            `\n`,
+            `This will start both the server and storefront.`,
+            `\n`,
+            `Access points:`,
+            `  Dashboard:  ${pc.green(`http://localhost:${serverPort}/dashboard`)}`,
+            `  Storefront: ${pc.green(`http://localhost:${storefrontPort}`)}`,
+            ...commonFooter,
+        ];
+    } else {
+        nextSteps = [
+            `Your new Vendure server was created!`,
+            pc.gray(root),
+            `\n`,
+            `Next, run:`,
+            pc.gray('$ ') + pc.blue(pc.bold(`cd ${name}`)),
+            pc.gray('$ ') + pc.blue(pc.bold(`${startCommand}`)),
+            `\n`,
+            `This will start the server in development mode.`,
+            `\n`,
+            `To run the Dashboard, in a new terminal navigate to your project directory and run:`,
+            pc.gray('$ ') + pc.blue(pc.bold(`npx vite`)),
+            `\n`,
+            `To access the Dashboard, open your browser and navigate to:`,
+            pc.green(`http://localhost:${serverPort}/dashboard`),
+            ...commonFooter,
+        ];
+    }
+
     note(nextSteps.join('\n'), pc.green('Setup complete!'));
     outro(`Happy hacking!`);
 }
@@ -476,7 +698,7 @@ function displayOutro(
  * Run some initial checks to ensure that it is okay to proceed with creating
  * a new Vendure project in the given location.
  */
-function runPreChecks(name: string | undefined, useNpm: boolean): name is string {
+function runPreChecks(name: string | undefined): name is string {
     if (typeof name === 'undefined') {
         log(pc.red(`Please specify the project directory:`));
         log(`  ${pc.cyan(program.name())} ${pc.green('<project-directory>')}`, { newline: 'after' });

+ 47 - 3
packages/create/src/gather-user-responses.ts

@@ -20,6 +20,7 @@ interface PromptAnswers {
     superadminIdentifier: string | symbol;
     superadminPassword: string | symbol;
     populateProducts: boolean | symbol;
+    includeStorefront: boolean | symbol;
 }
 
 /* eslint-disable no-console */
@@ -27,6 +28,7 @@ interface PromptAnswers {
 export async function getQuickStartConfiguration(
     root: string,
     packageManager: PackageManager,
+    port: number,
 ): Promise<UserResponses> {
     // First we want to detect whether Docker is running
     const { result: dockerStatus } = await isDockerAvailable();
@@ -61,6 +63,21 @@ export async function getQuickStartConfiguration(
             break;
         }
     }
+
+    const includeStorefront = await select({
+        message: 'Would you like to include the Next.js storefront?',
+        options: [
+            { label: 'No', value: false },
+            {
+                label: 'Yes',
+                value: true,
+                hint: 'Adds a ready-to-use Next.js storefront connected to your Vendure server',
+            },
+        ],
+        initialValue: false,
+    });
+    checkCancel(includeStorefront);
+
     const quickStartAnswers: PromptAnswers = {
         dbType: usePostgres ? 'postgres' : 'sqlite',
         dbHost: usePostgres ? 'localhost' : '',
@@ -72,14 +89,16 @@ export async function getQuickStartConfiguration(
         populateProducts: true,
         superadminIdentifier: SUPER_ADMIN_USER_IDENTIFIER,
         superadminPassword: SUPER_ADMIN_USER_PASSWORD,
+        includeStorefront,
     };
 
     const responses = {
-        ...(await generateSources(root, quickStartAnswers, packageManager)),
+        ...(await generateSources(root, quickStartAnswers, packageManager, port)),
         dbType: quickStartAnswers.dbType,
         populateProducts: quickStartAnswers.populateProducts as boolean,
         superadminIdentifier: quickStartAnswers.superadminIdentifier as string,
         superadminPassword: quickStartAnswers.superadminPassword as string,
+        includeStorefront: includeStorefront as boolean,
     };
 
     return responses;
@@ -91,6 +110,7 @@ export async function getQuickStartConfiguration(
 export async function getManualConfiguration(
     root: string,
     packageManager: PackageManager,
+    port: number,
 ): Promise<UserResponses> {
     const dbType = (await select({
         message: 'Which database are you using?',
@@ -180,6 +200,20 @@ export async function getManualConfiguration(
     });
     checkCancel(populateProducts);
 
+    const includeStorefront = await select({
+        message: 'Would you like to include the Next.js storefront?',
+        options: [
+            { label: 'No', value: false },
+            {
+                label: 'Yes',
+                value: true,
+                hint: 'Adds a ready-to-use Next.js storefront connected to your Vendure server',
+            },
+        ],
+        initialValue: false,
+    });
+    checkCancel(includeStorefront);
+
     const answers: PromptAnswers = {
         dbType,
         dbHost,
@@ -192,14 +226,16 @@ export async function getManualConfiguration(
         superadminIdentifier,
         superadminPassword,
         populateProducts,
+        includeStorefront,
     };
 
     return {
-        ...(await generateSources(root, answers, packageManager)),
+        ...(await generateSources(root, answers, packageManager, port)),
         dbType,
         populateProducts: answers.populateProducts as boolean,
         superadminIdentifier: answers.superadminIdentifier as string,
         superadminPassword: answers.superadminPassword as string,
+        includeStorefront: includeStorefront as boolean,
     };
 }
 
@@ -209,6 +245,8 @@ export async function getManualConfiguration(
 export async function getCiConfiguration(
     root: string,
     packageManager: PackageManager,
+    port: number,
+    includeStorefront: boolean = false,
 ): Promise<UserResponses> {
     const ciAnswers = {
         dbType: 'sqlite' as const,
@@ -220,14 +258,16 @@ export async function getCiConfiguration(
         populateProducts: true,
         superadminIdentifier: SUPER_ADMIN_USER_IDENTIFIER,
         superadminPassword: SUPER_ADMIN_USER_PASSWORD,
+        includeStorefront,
     };
 
     return {
-        ...(await generateSources(root, ciAnswers, packageManager)),
+        ...(await generateSources(root, ciAnswers, packageManager, port)),
         dbType: ciAnswers.dbType,
         populateProducts: ciAnswers.populateProducts,
         superadminIdentifier: ciAnswers.superadminIdentifier,
         superadminPassword: ciAnswers.superadminPassword,
+        includeStorefront,
     };
 }
 
@@ -238,6 +278,7 @@ async function generateSources(
     root: string,
     answers: PromptAnswers,
     packageManager: PackageManager,
+    port: number,
 ): Promise<FileSources> {
     const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName);
 
@@ -257,6 +298,8 @@ async function generateSources(
         isSQLite: answers.dbType === 'sqlite',
         requiresConnection: answers.dbType !== 'sqlite',
         cookieSecret: randomBytes(16).toString('base64url'),
+        port,
+        isMonorepo: answers.includeStorefront,
     };
 
     async function createSourceFile(filename: string, noEscape = false): Promise<string> {
@@ -273,6 +316,7 @@ async function generateSources(
         readmeSource: await createSourceFile('readme.hbs'),
         dockerfileSource: await createSourceFile('Dockerfile.hbs'),
         dockerComposeSource: await createSourceFile('docker-compose.hbs'),
+        tsconfigDashboardSource: await createSourceFile('tsconfig.dashboard.hbs'),
     };
 }
 

+ 81 - 5
packages/create/src/helpers.ts

@@ -2,13 +2,17 @@ import { cancel, isCancel, spinner } from '@clack/prompts';
 import spawn from 'cross-spawn';
 import fs from 'fs-extra';
 import { execFile, execFileSync, execSync } from 'node:child_process';
+import { createWriteStream } from 'node:fs';
 import { platform } from 'node:os';
+import { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
 import { promisify } from 'node:util';
 import path from 'path';
 import pc from 'picocolors';
 import semver from 'semver';
+import * as tar from 'tar';
 
-import { TYPESCRIPT_VERSION } from './constants';
+import { STOREFRONT_BRANCH, STOREFRONT_REPO, TYPESCRIPT_VERSION } from './constants';
 import { log } from './logger';
 import { CliLogLevel, DbType } from './types';
 
@@ -179,8 +183,9 @@ export function installPackages(options: {
     dependencies: string[];
     isDevDependencies?: boolean;
     logLevel: CliLogLevel;
+    cwd?: string;
 }): Promise<void> {
-    const { dependencies, isDevDependencies = false, logLevel } = options;
+    const { dependencies, isDevDependencies = false, logLevel, cwd } = options;
     return new Promise((resolve, reject) => {
         const command = 'npm';
         const args = ['install', '--save', '--save-exact', '--loglevel', 'error'].concat(dependencies);
@@ -192,7 +197,10 @@ export function installPackages(options: {
             args.push('--verbose');
         }
 
-        const child = spawn(command, args, { stdio: logLevel === 'verbose' ? 'inherit' : 'ignore' });
+        const child = spawn(command, args, {
+            stdio: logLevel === 'verbose' ? 'inherit' : 'ignore',
+            cwd,
+        });
         child.on('close', code => {
             if (code !== 0) {
                 let message = 'An error occurred when installing dependencies.';
@@ -267,7 +275,9 @@ export function checkDbConnection(options: any, root: string): Promise<true> {
 }
 
 async function checkMysqlDbExists(options: any, root: string): Promise<true> {
-    const mysql = await import(path.join(root, 'node_modules/mysql2/promise'));
+    // Use require.resolve to find the package, which handles npm workspace hoisting
+    const mysqlPath = require.resolve('mysql2/promise', { paths: [root] });
+    const mysql = await import(mysqlPath);
     const connectionOptions = {
         host: options.host,
         user: options.username,
@@ -297,7 +307,9 @@ async function checkMysqlDbExists(options: any, root: string): Promise<true> {
 }
 
 async function checkPostgresDbExists(options: any, root: string): Promise<true> {
-    const { Client } = await import(path.join(root, 'node_modules/pg'));
+    // Use require.resolve to find the package, which handles npm workspace hoisting
+    const pgPath = require.resolve('pg', { paths: [root] });
+    const { Client } = await import(pgPath);
     const connectionOptions = {
         host: options.host,
         user: options.username,
@@ -561,3 +573,67 @@ export function resolvePackageRootDir(packageName: string, rootDir: string) {
     }
     return dir;
 }
+
+/**
+ * Downloads the Next.js storefront starter from GitHub and extracts it to the target directory.
+ * Uses the GitHub API tarball endpoint to avoid requiring git.
+ */
+export async function downloadAndExtractStorefront(targetDir: string): Promise<void> {
+    const tarballUrl = `https://api.github.com/repos/${STOREFRONT_REPO}/tarball/${STOREFRONT_BRANCH}`;
+    const tempTarPath = path.join(targetDir, '..', 'storefront-temp.tar.gz');
+
+    try {
+        // Fetch the tarball from GitHub
+        const response = await fetch(tarballUrl, {
+            headers: {
+                Accept: 'application/vnd.github+json',
+                'User-Agent': 'vendure-create',
+            },
+        });
+
+        if (!response.ok) {
+            throw new Error(`Failed to download storefront: ${response.status} ${response.statusText}`);
+        }
+
+        // Save the tarball to a temp file
+        const fileStream = createWriteStream(tempTarPath);
+        // Convert the web ReadableStream to a Node.js Readable stream
+        const nodeReadable = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
+        await pipeline(nodeReadable, fileStream);
+
+        // Create target directory
+        await fs.ensureDir(targetDir);
+
+        // Extract the tarball
+        await tar.extract({
+            file: tempTarPath,
+            cwd: targetDir,
+            strip: 1, // Remove the top-level directory from the archive
+        });
+
+        // Clean up temp file
+        await fs.remove(tempTarPath);
+    } catch (error) {
+        // Clean up on error
+        await fs.remove(tempTarPath).catch(() => {
+            // eslint-disable-next-line
+            console.error(error);
+        });
+        throw error;
+    }
+}
+
+/**
+ * Finds an available port starting from the given port.
+ * Returns the first available port within the specified range.
+ */
+export async function findAvailablePort(startPort: number, range: number = 20): Promise<number> {
+    let port = startPort;
+    while (await isServerPortInUse(port)) {
+        port++;
+        if (port > startPort + range) {
+            throw new Error(`Could not find an available port between ${startPort} and ${startPort + range}`);
+        }
+    }
+    return port;
+}

+ 2 - 0
packages/create/src/types.ts

@@ -9,6 +9,7 @@ export interface FileSources {
     readmeSource: string;
     dockerfileSource: string;
     dockerComposeSource: string;
+    tsconfigDashboardSource: string;
 }
 
 export interface UserResponses extends FileSources {
@@ -16,6 +17,7 @@ export interface UserResponses extends FileSources {
     populateProducts: boolean;
     superadminIdentifier: string;
     superadminPassword: string;
+    includeStorefront: boolean;
 }
 
 export type PackageManager = 'npm';

+ 1 - 1
packages/create/templates/.env.hbs

@@ -1,5 +1,5 @@
 APP_ENV=dev
-PORT=3000
+PORT={{ port }}
 COOKIE_SECRET={{ cookieSecret }}
 SUPERADMIN_USERNAME={{{ escapeSingle superadminIdentifier }}}
 SUPERADMIN_PASSWORD={{{ escapeSingle superadminPassword }}}

+ 5 - 0
packages/create/templates/monorepo/root-gitignore.template

@@ -0,0 +1,5 @@
+node_modules
+.env
+.env.local
+*.log
+.DS_Store

+ 22 - 0
packages/create/templates/monorepo/root-package.json.hbs

@@ -0,0 +1,22 @@
+{
+  "name": "{{ name }}",
+  "version": "0.1.0",
+  "private": true,
+  "workspaces": [
+    "apps/*"
+  ],
+  "scripts": {
+    "dev": "concurrently -n server,storefront -c blue,magenta \"npm run dev -w server\" \"npm run dev -w storefront\"",
+    "dev:server": "npm run dev -w server",
+    "dev:storefront": "npm run dev -w storefront",
+    "build": "npm run build -w server && npm run build -w storefront",
+    "build:server": "npm run build -w server",
+    "build:storefront": "npm run build -w storefront",
+    "start": "concurrently -n server,storefront -c blue,magenta \"npm run start -w server\" \"npm run start -w storefront\"",
+    "start:server": "npm run start -w server",
+    "start:storefront": "npm run start -w storefront"
+  },
+  "devDependencies": {
+    "concurrently": "^9.0.0"
+  }
+}

+ 69 - 0
packages/create/templates/monorepo/root-readme.hbs

@@ -0,0 +1,69 @@
+# {{ name }}
+
+A full-stack e-commerce application built with [Vendure](https://www.vendure.io/) and [Next.js](https://nextjs.org/).
+
+## Project Structure
+
+This is a monorepo using npm workspaces:
+
+```
+{{ name }}/
+├── apps/
+│   ├── server/       # Vendure backend (GraphQL API, Admin Dashboard)
+│   └── storefront/   # Next.js frontend
+└── package.json      # Root workspace configuration
+```
+
+## Getting Started
+
+### Development
+
+Start both the server and storefront in development mode:
+
+```bash
+npm run dev
+```
+
+Or run them individually:
+
+```bash
+# Start only the server
+npm run dev:server
+
+# Start only the storefront
+npm run dev:storefront
+```
+
+### Access Points
+
+- **Vendure Dashboard**: http://localhost:{{ serverPort }}/dashboard
+- **Shop GraphQL API**: http://localhost:{{ serverPort }}/shop-api
+- **Admin GraphQL API**: http://localhost:{{ serverPort }}/admin-api
+- **Storefront**: http://localhost:{{ storefrontPort }}
+
+### Admin Credentials
+
+Use these credentials to log in to the Vendure Dashboard:
+
+- **Username**: {{ superadminIdentifier }}
+- **Password**: {{ superadminPassword }}
+
+## Production Build
+
+Build all packages:
+
+```bash
+npm run build
+```
+
+Start the production server:
+
+```bash
+npm run start
+```
+
+## Learn More
+
+- [Vendure Documentation](https://docs.vendure.io)
+- [Next.js Documentation](https://nextjs.org/docs)
+- [Vendure Discord Community](https://vendure.io/community)

+ 5 - 0
packages/create/templates/monorepo/storefront-env.hbs

@@ -0,0 +1,5 @@
+VENDURE_SHOP_API_URL=http://localhost:{{ serverPort }}/shop-api
+VENDURE_CHANNEL_TOKEN=__default_channel__
+NEXT_PUBLIC_SITE_URL=http://localhost:{{ storefrontPort }}
+NEXT_PUBLIC_SITE_NAME={{ name }}
+REVALIDATION_SECRET={{ revalidationSecret }}

+ 21 - 0
packages/create/templates/tsconfig.dashboard.hbs

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "jsx": "react-jsx",
+    "paths": {
+      "@/gql": [
+        "./src/gql/graphql.ts"
+      ],
+      "@/vdb/*": [
+        "{{#if isMonorepo}}../../{{/if}}./node_modules/@vendure/dashboard/src/lib/*"
+      ]
+    }
+  },
+  "include": [
+    "src/plugins/**/dashboard/*",
+    "src/gql/**/*.ts",
+    "vite.*.*ts"
+  ]
+}

+ 0 - 21
packages/create/templates/tsconfig.dashboard.template.json

@@ -1,21 +0,0 @@
-{
-  "compilerOptions": {
-    "composite": true,
-    "module": "ESNext",
-    "moduleResolution": "bundler",
-    "jsx": "react-jsx",
-    "paths": {
-      // Import alias for the GraphQL types
-      // This corresponds to the location that you have set in your `vite.config.mts`
-      // resolve.alias property
-      "@/gql": ["./src/gql/graphql.ts"],
-      // This line allows TypeScript to properly resolve internal
-      // Vendure Dashboard imports, which is necessary for
-      // type safety in your dashboard extensions.
-      // This path assumes a root-level tsconfig.json file.
-      // You may need to adjust it if your project structure is different.
-      "@/vdb/*": ["./node_modules/@vendure/dashboard/src/lib/*"]
-    }
-  },
-  "include": ["src/plugins/**/dashboard/*", "src/gql/**/*.ts"]
-}

+ 1 - 0
packages/create/templates/tsconfig.template.json

@@ -14,6 +14,7 @@
     "outDir": "./dist",
     "baseUrl": "./"
   },
+  "include": ["src/**/*.ts"],
   "exclude": [
     "node_modules",
     "migration.ts",

+ 21 - 2
packages/dashboard/plugin/constants.ts

@@ -5,7 +5,26 @@ export const DEFAULT_APP_PATH = join(__dirname, 'dist');
 export const loggerCtx = 'DashboardPlugin';
 export const defaultLanguage = 'en';
 export const defaultLocale = undefined;
-export const defaultAvailableLanguages = [ 'en', 'de', 'es', 'cs', 'zh_Hans', 'pt_BR', 'pt_PT', 'zh_Hant', 'bg'];
-export const defaultAvailableLocales = [ 'en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'pt-BR', 'pt-PT', 'bg_BG'];
+export const defaultAvailableLanguages = [
+    'en',
+    'de',
+    'es',
+    'cs',
+    'zh_Hans',
+    'pt_BR',
+    'pt_PT',
+    'zh_Hant',
+    'bg',
+];
+export const defaultAvailableLocales = [
+    'en-US',
+    'de-DE',
+    'es-ES',
+    'zh-CN',
+    'zh-TW',
+    'pt-BR',
+    'pt-PT',
+    'bg_BG',
+];
 
 export const manageDashboardGlobalViews = new RwPermissionDefinition('DashboardGlobalViews');