Browse Source

feat(cli): Add codegen command

Michael Bromley 1 year ago
parent
commit
de5544c8b6

+ 5 - 0
packages/cli/src/commands/add/add.ts

@@ -1,6 +1,7 @@
 import { cancel, isCancel, log, select } from '@clack/prompts';
 import { cancel, isCancel, log, select } from '@clack/prompts';
 import { Command } from 'commander';
 import { Command } from 'commander';
 
 
+import { addCodegen } from './codegen/add-codegen';
 import { addEntity } from './entity/add-entity';
 import { addEntity } from './entity/add-entity';
 import { createNewPlugin } from './plugin/create-new-plugin';
 import { createNewPlugin } from './plugin/create-new-plugin';
 import { addUiExtensions } from './ui-extensions/add-ui-extensions';
 import { addUiExtensions } from './ui-extensions/add-ui-extensions';
@@ -18,6 +19,7 @@ export function registerAddCommand(program: Command) {
                     { value: 'plugin', label: '[Plugin] Add a new plugin' },
                     { value: 'plugin', label: '[Plugin] Add a new plugin' },
                     { value: 'entity', label: '[Plugin: Entity] Add a new entity to a plugin' },
                     { value: 'entity', label: '[Plugin: Entity] Add a new entity to a plugin' },
                     { value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
                     { value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
+                    { value: 'codegen', label: '[Project: Codegen] Set up GraphQL code generation' },
                 ],
                 ],
             });
             });
             if (isCancel(featureType)) {
             if (isCancel(featureType)) {
@@ -34,6 +36,9 @@ export function registerAddCommand(program: Command) {
                 if (featureType === 'entity') {
                 if (featureType === 'entity') {
                     await addEntity();
                     await addEntity();
                 }
                 }
+                if (featureType === 'codegen') {
+                    await addCodegen();
+                }
             } catch (e: any) {
             } catch (e: any) {
                 log.error(e.message as string);
                 log.error(e.message as string);
             }
             }

+ 94 - 0
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -0,0 +1,94 @@
+import { log, note, outro, spinner } from '@clack/prompts';
+import path from 'path';
+import pc from 'picocolors';
+import { ClassDeclaration, StructureKind, SyntaxKind } from 'ts-morph';
+
+import { selectMultiplePluginClasses } from '../../../shared/shared-prompts';
+import { createFile, getRelativeImportPath, getTsMorphProject } from '../../../utilities/ast-utils';
+import { addNpmScriptToPackageJson, installRequiredPackages } from '../../../utilities/package-utils';
+
+export async function addCodegen(providedPluginClass?: ClassDeclaration) {
+    let pluginClasses = providedPluginClass ? [providedPluginClass] : [];
+    let project = providedPluginClass?.getProject();
+    if (!pluginClasses.length || !project) {
+        const projectSpinner = spinner();
+        projectSpinner.start('Analyzing project...');
+        await new Promise(resolve => setTimeout(resolve, 100));
+        project = getTsMorphProject();
+        projectSpinner.stop('Project analyzed');
+        pluginClasses = await selectMultiplePluginClasses(project, 'Add codegen cancelled');
+    }
+
+    const installSpinner = spinner();
+    installSpinner.start(`Installing dependencies...`);
+    try {
+        await installRequiredPackages(project, [
+            {
+                pkg: '@graphql-codegen/cli',
+                isDevDependency: true,
+            },
+            {
+                pkg: '@graphql-codegen/typescript',
+                isDevDependency: true,
+            },
+        ]);
+    } catch (e: any) {
+        log.error(`Failed to install dependencies: ${e.message as string}.`);
+    }
+    installSpinner.stop('Dependencies installed');
+
+    const configSpinner = spinner();
+    configSpinner.start('Configuring codegen file...');
+    await new Promise(resolve => setTimeout(resolve, 100));
+
+    const tempProject = getTsMorphProject({ skipAddingFilesFromTsConfig: true });
+    const codegenFile = createFile(tempProject, path.join(__dirname, 'templates/codegen.template.ts'));
+    const codegenConfig = codegenFile
+        .getVariableDeclaration('config')
+        ?.getChildrenOfKind(SyntaxKind.ObjectLiteralExpression)[0];
+    if (!codegenConfig) {
+        throw new Error('Could not find the config variable in the template codegen file');
+    }
+    const generatesProp = codegenConfig
+        .getProperty('generates')
+        ?.getFirstChildByKind(SyntaxKind.ObjectLiteralExpression);
+    if (!generatesProp) {
+        throw new Error('Could not find the generates property in the template codegen file');
+    }
+    const rootDir = tempProject.getDirectory('.');
+    if (!rootDir) {
+        throw new Error('Could not find the root directory of the project');
+    }
+    for (const pluginClass of pluginClasses) {
+        const relativePluginPath = getRelativeImportPath({
+            from: pluginClass.getSourceFile(),
+            to: rootDir,
+        });
+        const generatedTypesPath = `${path.dirname(relativePluginPath)}/gql/generated.ts`;
+        generatesProp
+            .addProperty({
+                name: `'${generatedTypesPath}'`,
+                kind: StructureKind.PropertyAssignment,
+                initializer: `{ plugins: ['typescript'] }`,
+            })
+            .formatText();
+    }
+    codegenFile.move(path.join(rootDir.getPath(), 'codegen.ts'));
+
+    addNpmScriptToPackageJson(tempProject, 'codegen', 'graphql-codegen --config codegen.ts');
+
+    configSpinner.stop('Configured codegen file');
+
+    project.saveSync();
+
+    const nextSteps = [
+        `You can run codegen by doing the following:`,
+        `1. Ensure your dev server is running`,
+        `2. Run "npm run codegen"`,
+    ];
+    note(nextSteps.join('\n'));
+
+    if (!providedPluginClass) {
+        outro('✅ Codegen setup complete!');
+    }
+}

+ 17 - 0
packages/cli/src/commands/add/codegen/templates/codegen.template.ts

@@ -0,0 +1,17 @@
+import type { CodegenConfig } from '@graphql-codegen/cli';
+
+const config: CodegenConfig = {
+    overwrite: true,
+    // This assumes your server is running on the standard port
+    // and with the default admin API path. Adjust accordingly.
+    schema: 'http://localhost:3000/admin-api',
+    config: {
+        // This tells codegen that the `Money` scalar is a number
+        scalars: { Money: 'number' },
+        // This ensures generated enums do not conflict with the built-in types.
+        namingConvention: { enumValues: 'keep' },
+    },
+    generates: {},
+};
+
+export default config;

+ 2 - 2
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -36,8 +36,8 @@ export async function addUiExtensions(providedPluginClass?: ClassDeclaration) {
     const installSpinner = spinner();
     const installSpinner = spinner();
     installSpinner.start(`Installing dependencies...`);
     installSpinner.start(`Installing dependencies...`);
     try {
     try {
-        const version = determineVendureVersion();
-        await installRequiredPackages([
+        const version = determineVendureVersion(project);
+        await installRequiredPackages(project, [
             {
             {
                 pkg: '@vendure/ui-devkit',
                 pkg: '@vendure/ui-devkit',
                 isDevDependency: true,
                 isDevDependency: true,

+ 37 - 1
packages/cli/src/shared/shared-prompts.ts

@@ -1,4 +1,4 @@
-import { cancel, isCancel, select, text } from '@clack/prompts';
+import { cancel, isCancel, multiselect, select, text } from '@clack/prompts';
 import { pascalCase } from 'change-case';
 import { pascalCase } from 'change-case';
 import { ClassDeclaration, Project } from 'ts-morph';
 import { ClassDeclaration, Project } from 'ts-morph';
 
 
@@ -41,3 +41,39 @@ export async function selectPluginClass(project: Project, cancelledMessage: stri
     }
     }
     return targetPlugin as ClassDeclaration;
     return targetPlugin as ClassDeclaration;
 }
 }
+
+export async function selectMultiplePluginClasses(project: Project, cancelledMessage: string) {
+    const pluginClasses = getPluginClasses(project);
+    const selectAll = await select({
+        message: 'To which plugin would you like to add the feature?',
+        options: [
+            {
+                value: 'all',
+                label: 'All plugins',
+            },
+            {
+                value: 'specific',
+                label: 'Specific plugins (you will be prompted to select the plugins)',
+            },
+        ],
+    });
+    if (isCancel(selectAll)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    if (selectAll === 'all') {
+        return pluginClasses;
+    }
+    const targetPlugins = await multiselect({
+        message: 'Select one or more plugins (use ↑, ↓, space to select)',
+        options: pluginClasses.map(c => ({
+            value: c,
+            label: c.getName() as string,
+        })),
+    });
+    if (isCancel(targetPlugins)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    return targetPlugins as ClassDeclaration[];
+}

+ 9 - 7
packages/cli/src/utilities/ast-utils.ts

@@ -2,6 +2,7 @@ import { log } from '@clack/prompts';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import path from 'node:path';
 import path from 'node:path';
 import {
 import {
+    Directory,
     Node,
     Node,
     ObjectLiteralExpression,
     ObjectLiteralExpression,
     Project,
     Project,
@@ -134,13 +135,14 @@ function getModuleSpecifierString(moduleSpecifier: string | SourceFile, sourceFi
     return getRelativeImportPath({ from: moduleSpecifier, to: sourceFile });
     return getRelativeImportPath({ from: moduleSpecifier, to: sourceFile });
 }
 }
 
 
-export function getRelativeImportPath(locations: { from: SourceFile; to: SourceFile }): string {
-    return convertPathToRelativeImport(
-        path.relative(
-            locations.to.getSourceFile().getDirectory().getPath(),
-            locations.from.getSourceFile().getFilePath(),
-        ),
-    );
+export function getRelativeImportPath(locations: {
+    from: SourceFile | Directory;
+    to: SourceFile | Directory;
+}): string {
+    const fromPath =
+        locations.from instanceof SourceFile ? locations.from.getFilePath() : locations.from.getPath();
+    const toPath = locations.to instanceof SourceFile ? locations.to.getFilePath() : locations.to.getPath();
+    return convertPathToRelativeImport(path.relative(toPath, fromPath));
 }
 }
 
 
 export function createFile(project: Project, templatePath: string) {
 export function createFile(project: Project, templatePath: string) {

+ 33 - 8
packages/cli/src/utilities/package-utils.ts

@@ -2,6 +2,7 @@ import { note } from '@clack/prompts';
 import spawn from 'cross-spawn';
 import spawn from 'cross-spawn';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import path from 'path';
 import path from 'path';
+import { Project } from 'ts-morph';
 
 
 export interface PackageToInstall {
 export interface PackageToInstall {
     pkg: string;
     pkg: string;
@@ -9,13 +10,18 @@ export interface PackageToInstall {
     isDevDependency?: boolean;
     isDevDependency?: boolean;
 }
 }
 
 
-export function determineVendureVersion(): string | undefined {
-    const packageJson = getPackageJsonContent();
+export function determineVendureVersion(project: Project): string | undefined {
+    const packageJson = getPackageJsonContent(project);
     return packageJson.dependencies['@vendure/core'];
     return packageJson.dependencies['@vendure/core'];
 }
 }
 
 
-export async function installRequiredPackages(requiredPackages: PackageToInstall[]) {
-    const packageJson = getPackageJsonContent();
+/**
+ * @description
+ * Installs the packages with the appropriate package manager if the package
+ * is not already found in the package.json file.
+ */
+export async function installRequiredPackages(project: Project, requiredPackages: PackageToInstall[]) {
+    const packageJson = getPackageJsonContent(project);
     const packagesToInstall = requiredPackages.filter(({ pkg, version, isDevDependency }) => {
     const packagesToInstall = requiredPackages.filter(({ pkg, version, isDevDependency }) => {
         const hasDependency = isDevDependency
         const hasDependency = isDevDependency
             ? packageJson.devDependencies[pkg]
             ? packageJson.devDependencies[pkg]
@@ -24,10 +30,10 @@ export async function installRequiredPackages(requiredPackages: PackageToInstall
     });
     });
 
 
     const depsToInstall = packagesToInstall
     const depsToInstall = packagesToInstall
-        .filter(p => !p.isDevDependency)
+        .filter(p => !p.isDevDependency && packageJson.dependencies?.[p.pkg] === undefined)
         .map(p => `${p.pkg}${p.version ? `@${p.version}` : ''}`);
         .map(p => `${p.pkg}${p.version ? `@${p.version}` : ''}`);
     const devDepsToInstall = packagesToInstall
     const devDepsToInstall = packagesToInstall
-        .filter(p => p.isDevDependency)
+        .filter(p => p.isDevDependency && packageJson.devDependencies?.[p.pkg] === undefined)
         .map(p => `${p.pkg}${p.version ? `@${p.version}` : ''}`);
         .map(p => `${p.pkg}${p.version ? `@${p.version}` : ''}`);
     if (depsToInstall.length) {
     if (depsToInstall.length) {
         await installPackages(depsToInstall, false);
         await installPackages(depsToInstall, false);
@@ -72,6 +78,21 @@ export async function installPackages(dependencies: string[], isDev: boolean) {
     });
     });
 }
 }
 
 
+export function addNpmScriptToPackageJson(project: Project, scriptName: string, script: string) {
+    const packageJson = getPackageJsonContent(project);
+    if (!packageJson) {
+        return;
+    }
+    packageJson.scripts = packageJson.scripts || {};
+    packageJson.scripts[scriptName] = script;
+    const rootDir = project.getDirectory('.');
+    if (!rootDir) {
+        throw new Error('Could not find the root directory of the project');
+    }
+    const packageJsonPath = path.join(rootDir.getPath(), 'package.json');
+    fs.writeJsonSync(packageJsonPath, packageJson, { spaces: 2 });
+}
+
 function determinePackageManagerBasedOnLockFile(): 'yarn' | 'npm' | 'pnpm' {
 function determinePackageManagerBasedOnLockFile(): 'yarn' | 'npm' | 'pnpm' {
     const yarnLockPath = path.join(process.cwd(), 'yarn.lock');
     const yarnLockPath = path.join(process.cwd(), 'yarn.lock');
     const npmLockPath = path.join(process.cwd(), 'package-lock.json');
     const npmLockPath = path.join(process.cwd(), 'package-lock.json');
@@ -88,8 +109,12 @@ function determinePackageManagerBasedOnLockFile(): 'yarn' | 'npm' | 'pnpm' {
     return 'npm';
     return 'npm';
 }
 }
 
 
-function getPackageJsonContent() {
-    const packageJsonPath = path.join(process.cwd(), 'package.json');
+function getPackageJsonContent(project: Project) {
+    const rootDir = project.getDirectory('.');
+    if (!rootDir) {
+        throw new Error('Could not find the root directory of the project');
+    }
+    const packageJsonPath = path.join(rootDir.getPath(), 'package.json');
     if (!fs.existsSync(packageJsonPath)) {
     if (!fs.existsSync(packageJsonPath)) {
         note(
         note(
             `Could not find a package.json in the current directory. Please run this command from the root of a Vendure project.`,
             `Could not find a package.json in the current directory. Please run this command from the root of a Vendure project.`,