Преглед изворни кода

feat(cli): Improved monorepo detection and support

Michael Bromley пре 3 месеци
родитељ
комит
99e0b11abc

+ 10 - 4
packages/cli/src/commands/add/plugin/create-new-plugin.ts

@@ -40,7 +40,10 @@ export async function createNewPlugin(
     if (!isNonInteractive) {
         intro('Adding a new Vendure plugin!');
     }
-    const { project, config } = await analyzeProject({ cancelledMessage, config: options.config });
+    const { project, config, vendureTsConfig } = await analyzeProject({
+        cancelledMessage,
+        config: options.config,
+    });
     if (!options.name) {
         const name = await text({
             message: 'What is the name of the plugin?',
@@ -60,7 +63,8 @@ export async function createNewPlugin(
         }
     }
     const existingPluginDir = findExistingPluginsDir(project);
-    const pluginDir = getPluginDirName(options.name, existingPluginDir);
+    const vendureProjectDir = vendureTsConfig ? path.dirname(vendureTsConfig) : process.cwd();
+    const pluginDir = getPluginDirName(options.name, existingPluginDir, vendureProjectDir);
 
     if (isNonInteractive) {
         options.pluginDir = pluginDir;
@@ -237,8 +241,8 @@ function findExistingPluginsDir(project: Project): { prefix: string; suffix: str
 function getPluginDirName(
     name: string,
     existingPluginDirPattern: { prefix: string; suffix: string } | undefined,
+    vendureProjectDir: string,
 ) {
-    const cwd = process.cwd();
     const nameWithoutPlugin = name.replace(/-?plugin$/i, '');
     if (existingPluginDirPattern) {
         return path.join(
@@ -247,7 +251,9 @@ function getPluginDirName(
             existingPluginDirPattern.suffix,
         );
     } else {
-        return path.join(cwd, 'src', 'plugins', paramCase(nameWithoutPlugin));
+        // Use the Vendure project directory (which may be in a monorepo package)
+        // instead of cwd (which could be the monorepo root)
+        return path.join(vendureProjectDir, 'src', 'plugins', paramCase(nameWithoutPlugin));
     }
 }
 

+ 2 - 2
packages/cli/src/commands/schema/generate-schema/generate-schema.ts

@@ -22,10 +22,10 @@ const cancelledMessage = 'Generate schema cancelled';
 export async function generateSchema(options: SchemaOptions) {
     resetConfig();
     try {
-        const { project, tsConfigPath } = await analyzeProject({ cancelledMessage });
+        const { project, vendureTsConfig } = await analyzeProject({ cancelledMessage });
         const vendureConfig = new VendureConfigRef(project);
         log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
-        const config = await loadVendureConfigFile(vendureConfig, tsConfigPath);
+        const config = await loadVendureConfigFile(vendureConfig, vendureTsConfig);
         await setConfig(config);
 
         const apiType = options.api === 'shop' ? 'shop' : 'admin';

+ 2 - 2
packages/cli/src/commands/schema/schema.ts

@@ -6,7 +6,7 @@ import { withInteractiveTimeout } from '../../utilities/utils';
 const cancelledMessage = 'Schema generation cancelled.';
 
 export interface SchemaOptions {
-    api?: 'admin' | 'shop';
+    api: 'admin' | 'shop';
     format?: 'sdl' | 'json';
     fileName?: string;
     outputDir?: string;
@@ -18,7 +18,7 @@ export interface SchemaOptions {
  */
 export async function schemaCommand(options?: SchemaOptions) {
     // Check if any non-interactive options are provided
-    if (options) {
+    if (options?.api) {
         // Non-interactive mode
         await handleNonInteractiveMode(options);
         return;

+ 6 - 25
packages/cli/src/shared/package-json-ref.ts

@@ -4,6 +4,8 @@ import fs from 'fs-extra';
 import path from 'path';
 import { Project } from 'ts-morph';
 
+import { findPackageJsonWithDependency } from '../utilities/monorepo-utils';
+
 export interface PackageToInstall {
     pkg: string;
     version?: string;
@@ -151,34 +153,13 @@ export class PackageJson {
             return this._vendurePackageJsonPath;
         }
         const rootDir = this.getPackageRootDir().getPath();
-        const potentialMonorepoDirs = ['packages', 'apps', 'libs'];
+        const packageJsonPath = findPackageJsonWithDependency(rootDir, '@vendure/core');
 
-        const rootPackageJsonPath = path.join(this.getPackageRootDir().getPath(), 'package.json');
-        if (this.hasVendureDependency(rootPackageJsonPath)) {
-            return rootPackageJsonPath;
-        }
-        for (const dir of potentialMonorepoDirs) {
-            const monorepoDir = path.join(rootDir, dir);
-            // Check for a package.json in all subdirs
-            if (fs.existsSync(monorepoDir)) {
-                for (const subDir of fs.readdirSync(monorepoDir)) {
-                    const packageJsonPath = path.join(monorepoDir, subDir, 'package.json');
-                    if (this.hasVendureDependency(packageJsonPath)) {
-                        this._vendurePackageJsonPath = packageJsonPath;
-                        return packageJsonPath;
-                    }
-                }
-            }
+        if (packageJsonPath) {
+            this._vendurePackageJsonPath = packageJsonPath;
         }
-        return null;
-    }
 
-    private hasVendureDependency(packageJsonPath: string) {
-        if (!fs.existsSync(packageJsonPath)) {
-            return false;
-        }
-        const packageJson = fs.readJsonSync(packageJsonPath);
-        return !!packageJson.dependencies?.['@vendure/core'];
+        return packageJsonPath;
     }
 
     private async runPackageManagerInstall(dependencies: string[], isDev: boolean) {

+ 17 - 4
packages/cli/src/shared/shared-prompts.ts

@@ -57,13 +57,16 @@ export async function analyzeProject(options: {
     const providedVendurePlugin = options.providedVendurePlugin;
     let project = providedVendurePlugin?.classDeclaration.getProject();
     let tsConfigPath: string | undefined;
+    let vendureTsConfig: string | undefined;
+    let rootTsConfig: string | undefined;
+    let isMonorepo: boolean | undefined;
 
     if (!providedVendurePlugin) {
         const projectSpinner = spinner();
         const tsConfigFile = selectTsConfigFile();
         projectSpinner.start('Analyzing project...');
         await pauseForPromptDisplay();
-        const { project: _project, tsConfigPath: _tsConfigPath } = await getTsMorphProject(
+        const result = await getTsMorphProject(
             {
                 compilerOptions: {
                     // When running via the CLI, we want to find all source files,
@@ -73,11 +76,21 @@ export async function analyzeProject(options: {
             },
             tsConfigFile,
         );
-        project = _project;
-        tsConfigPath = _tsConfigPath;
+        project = result.project;
+        tsConfigPath = result.tsConfigPath;
+        vendureTsConfig = result.vendureTsConfig;
+        rootTsConfig = result.rootTsConfig;
+        isMonorepo = result.isMonorepo;
         projectSpinner.stop('Project analyzed');
     }
-    return { project: project as Project, tsConfigPath, config: options.config };
+    return {
+        project: project as Project,
+        tsConfigPath,
+        vendureTsConfig,
+        rootTsConfig,
+        isMonorepo,
+        config: options.config,
+    };
 }
 
 export async function selectPlugin(project: Project, cancelledMessage: string): Promise<VendurePluginRef> {

+ 62 - 1
packages/cli/src/utilities/ast-utils.ts

@@ -5,6 +5,9 @@ import { Directory, Node, Project, ProjectOptions, ScriptKind, SourceFile } from
 
 import { defaultManipulationSettings } from '../constants';
 import { EntityRef } from '../shared/entity-ref';
+import { PackageJson } from '../shared/package-json-ref';
+
+import { detectMonorepoStructure, findTsConfigInDir } from './monorepo-utils';
 
 export function selectTsConfigFile() {
     const tsConfigFiles = fs.readdirSync(process.cwd()).filter(f => /^tsconfig.*\.json$/.test(f));
@@ -38,7 +41,65 @@ export async function getTsMorphProject(options: ProjectOptions = {}, providedTs
         ...options,
     });
     project.enableLogging(false);
-    return { project, tsConfigPath };
+
+    const { vendureTsConfig, rootTsConfig, isMonorepo } = detectTsConfigPaths(tsConfigPath, project);
+
+    return { project, tsConfigPath, vendureTsConfig, rootTsConfig, isMonorepo };
+}
+
+/**
+ * Detects whether we're in a monorepo setup and returns the appropriate tsconfig paths.
+ * In a regular repo, vendureTsConfig and rootTsConfig will be the same.
+ * In a monorepo, vendureTsConfig points to the package-level config (e.g., packages/backend/tsconfig.json)
+ * and rootTsConfig points to the root-level config.
+ */
+function detectTsConfigPaths(
+    currentTsConfigPath: string,
+    project: Project,
+): {
+    vendureTsConfig: string;
+    rootTsConfig: string;
+    isMonorepo: boolean;
+} {
+    const packageJson = new PackageJson(project);
+
+    // Find vendure package.json (this handles monorepo search automatically)
+    const vendurePackageJsonPath = packageJson.locatePackageJsonWithVendureDependency();
+
+    if (!vendurePackageJsonPath) {
+        // Couldn't find vendure package, use current tsconfig
+        return {
+            vendureTsConfig: currentTsConfigPath,
+            rootTsConfig: currentTsConfigPath,
+            isMonorepo: false,
+        };
+    }
+
+    const vendureDir = path.dirname(vendurePackageJsonPath);
+
+    // Find vendure tsconfig (in the same dir as vendure package.json)
+    const vendureTsConfig = findTsConfigInDir(vendureDir) || currentTsConfigPath;
+
+    // Detect if we're in a monorepo by checking if vendureDir is nested under a packages/apps/libs structure
+    const monorepoInfo = detectMonorepoStructure(vendureDir);
+
+    if (!monorepoInfo.isMonorepo || !monorepoInfo.root) {
+        // Not in a monorepo structure, regular mode
+        return {
+            vendureTsConfig,
+            rootTsConfig: vendureTsConfig,
+            isMonorepo: false,
+        };
+    }
+
+    // Find root tsconfig
+    const rootTsConfig = findTsConfigInDir(monorepoInfo.root) || vendureTsConfig;
+
+    return {
+        vendureTsConfig,
+        rootTsConfig,
+        isMonorepo: true,
+    };
 }
 
 export function getPluginClasses(project: Project) {

+ 125 - 0
packages/cli/src/utilities/monorepo-utils.ts

@@ -0,0 +1,125 @@
+import fs from 'fs-extra';
+import path from 'path';
+
+/**
+ * Common monorepo directory names (e.g., Nx, Turborepo, Lerna conventions)
+ * - packages: Most common, used by most tools for shared libraries
+ * - apps: Turborepo/Nx convention for applications
+ * - libs: Nx convention for libraries
+ * - services: Common for backend services/microservices
+ * - modules: Alternative to packages (some projects prefer this naming)
+ */
+export const MONOREPO_PACKAGE_DIRS = ['packages', 'apps', 'libs', 'services', 'modules'] as const;
+
+export interface MonorepoInfo {
+    isMonorepo: boolean;
+    /**
+     * The root directory of the monorepo (if in a monorepo)
+     */
+    root?: string;
+    /**
+     * The package directory name that contains this path (e.g., 'packages', 'apps', 'libs')
+     */
+    packageDir?: (typeof MONOREPO_PACKAGE_DIRS)[number];
+}
+
+/**
+ * Detects if a given path is inside a monorepo structure and extracts the monorepo root.
+ * Handles cases where multiple monorepo directory types exist (e.g., both 'apps' and 'libs').
+ *
+ * @example
+ * detectMonorepoStructure('/monorepo/packages/backend')
+ * // => { isMonorepo: true, root: '/monorepo', packageDir: 'packages' }
+ *
+ * detectMonorepoStructure('/monorepo/apps/frontend')
+ * // => { isMonorepo: true, root: '/monorepo', packageDir: 'apps' }
+ *
+ * detectMonorepoStructure('/regular-project')
+ * // => { isMonorepo: false }
+ */
+export function detectMonorepoStructure(dirPath: string): MonorepoInfo {
+    const normalizedPath = path.normalize(dirPath);
+
+    for (const dir of MONOREPO_PACKAGE_DIRS) {
+        const pattern = path.sep + dir + path.sep;
+        if (normalizedPath.includes(pattern)) {
+            // Extract the monorepo root (the part before /packages/, /apps/, or /libs/)
+            const parts = normalizedPath.split(pattern);
+            return {
+                isMonorepo: true,
+                root: parts[0],
+                packageDir: dir,
+            };
+        }
+    }
+
+    return { isMonorepo: false };
+}
+
+/**
+ * Searches for a package.json file with a specific dependency within monorepo structures.
+ * Searches common monorepo directories (packages, apps, libs) for subdirectories containing
+ * a package.json with the specified dependency.
+ *
+ * @param rootDir - The root directory to search from
+ * @param dependencyName - The dependency name to look for (e.g., '@vendure/core')
+ * @returns The path to the package.json file, or null if not found
+ */
+export function findPackageJsonWithDependency(rootDir: string, dependencyName: string): string | null {
+    // First check if the root package.json has the dependency
+    const rootPackageJsonPath = path.join(rootDir, 'package.json');
+    if (hasNamedDependency(rootPackageJsonPath, dependencyName)) {
+        return rootPackageJsonPath;
+    }
+
+    // Search in monorepo package directories
+    for (const dir of MONOREPO_PACKAGE_DIRS) {
+        const monorepoDir = path.join(rootDir, dir);
+        if (fs.existsSync(monorepoDir)) {
+            for (const subDir of fs.readdirSync(monorepoDir)) {
+                const packageJsonPath = path.join(monorepoDir, subDir, 'package.json');
+                if (hasNamedDependency(packageJsonPath, dependencyName)) {
+                    return packageJsonPath;
+                }
+            }
+        }
+    }
+
+    return null;
+}
+
+/**
+ * Checks if a package.json file exists and has the specified dependency.
+ */
+function hasNamedDependency(packageJsonPath: string, dependencyName: string): boolean {
+    if (!fs.existsSync(packageJsonPath)) {
+        return false;
+    }
+    try {
+        const packageJson = fs.readJsonSync(packageJsonPath);
+        return !!packageJson.dependencies?.[dependencyName];
+    } catch {
+        return false;
+    }
+}
+
+/**
+ * Finds tsconfig files in a directory, preferring 'tsconfig.json' if it exists.
+ */
+export function findTsConfigInDir(dir: string): string | null {
+    if (!fs.existsSync(dir)) {
+        return null;
+    }
+
+    const tsConfigCandidates = fs.readdirSync(dir).filter(f => /^tsconfig.*\.json$/.test(f));
+
+    if (tsConfigCandidates.includes('tsconfig.json')) {
+        return path.join(dir, 'tsconfig.json');
+    }
+
+    if (tsConfigCandidates.length > 0) {
+        return path.join(dir, tsConfigCandidates[0]);
+    }
+
+    return null;
+}