Browse Source

feat(cli): Introduce new `schema` command

Michael Bromley 3 months ago
parent
commit
134e0fe83d

+ 35 - 0
packages/cli/src/commands/command-declarations.ts

@@ -193,4 +193,39 @@ export const cliCommands: CliCommandDefinition[] = [
             process.exit(0);
         },
     },
+    {
+        name: 'schema',
+        description: 'Generate a schema file from your GraphQL APIs',
+        options: [
+            {
+                short: '-a',
+                long: '--api <admin|shop>',
+                description: 'Which GraphQL API to generate a schema for',
+                required: true,
+            },
+            {
+                short: '-d',
+                long: '--dir <dir>',
+                description: 'Output directory. Defaults to current directory.',
+                required: false,
+            },
+            {
+                short: '-n',
+                long: '--file-name <name>',
+                description: 'File name. Defaults to "schema.graphql|json" or "schema-shop.graphql|json"',
+                required: false,
+            },
+            {
+                short: '-f',
+                long: '--format <sdl|json>',
+                description: 'Output format, either SDL or JSON',
+                required: false,
+            },
+        ],
+        action: async options => {
+            const { schemaCommand } = await import('./schema/schema');
+            await schemaCommand(options as any);
+            process.exit(0);
+        },
+    },
 ];

+ 1 - 1
packages/cli/src/commands/migrate/generate-migration/generate-migration.ts

@@ -4,10 +4,10 @@ import { generateMigration, VendureConfig } from '@vendure/core';
 import path from 'path';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { loadVendureConfigFile } from '../../../shared/load-vendure-config-file';
 import { analyzeProject } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { withInteractiveTimeout } from '../../../utilities/utils';
-import { loadVendureConfigFile } from '../load-vendure-config-file';
 
 const cancelledMessage = 'Generate migration cancelled';
 

+ 2 - 3
packages/cli/src/commands/migrate/migrate.ts

@@ -19,9 +19,8 @@ export interface MigrateOptions {
 }
 
 /**
- * This command is currently not exposed due to unresolved issues which I think are related to
- * peculiarities in loading ESM modules vs CommonJS modules. More time is needed to dig into
- * this before we expose this command in the cli.ts file.
+ * @description
+ * Generate, run or revert a migration
  */
 export async function migrateCommand(options?: MigrateOptions) {
     // Check if any non-interactive options are provided

+ 1 - 2
packages/cli/src/commands/migrate/migration-operations.ts

@@ -2,12 +2,11 @@ import { log } from '@clack/prompts';
 import { generateMigration, revertLastMigration, runMigrations, VendureConfig } from '@vendure/core';
 import path from 'path';
 
+import { loadVendureConfigFile } from '../../shared/load-vendure-config-file';
 import { validateVendureProjectDirectory } from '../../shared/project-validation';
 import { analyzeProject } from '../../shared/shared-prompts';
 import { VendureConfigRef } from '../../shared/vendure-config-ref';
 
-import { loadVendureConfigFile } from './load-vendure-config-file';
-
 export interface MigrationOptions {
     name?: string;
     outputDir?: string;

+ 1 - 1
packages/cli/src/commands/migrate/revert-migration/revert-migration.ts

@@ -2,9 +2,9 @@ import { log, spinner } from '@clack/prompts';
 import { revertLastMigration } from '@vendure/core';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { loadVendureConfigFile } from '../../../shared/load-vendure-config-file';
 import { analyzeProject } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
-import { loadVendureConfigFile } from '../load-vendure-config-file';
 
 const cancelledMessage = 'Revert migrations cancelled';
 

+ 1 - 1
packages/cli/src/commands/migrate/run-migration/run-migration.ts

@@ -2,9 +2,9 @@ import { log, spinner } from '@clack/prompts';
 import { runMigrations } from '@vendure/core';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { loadVendureConfigFile } from '../../../shared/load-vendure-config-file';
 import { analyzeProject } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
-import { loadVendureConfigFile } from '../load-vendure-config-file';
 
 const cancelledMessage = 'Run migrations cancelled';
 

+ 59 - 0
packages/cli/src/commands/schema/generate-schema/generate-schema.ts

@@ -0,0 +1,59 @@
+import { log } from '@clack/prompts';
+import { GraphQLTypesLoader } from '@nestjs/graphql';
+import {
+    getConfig,
+    getFinalVendureSchema,
+    resetConfig,
+    runPluginConfigurations,
+    setConfig,
+    VENDURE_ADMIN_API_TYPE_PATHS,
+} from '@vendure/core';
+import { writeFileSync } from 'fs-extra';
+import { getIntrospectionQuery, graphqlSync, printSchema } from 'graphql';
+import path from 'path';
+
+import { loadVendureConfigFile } from '../../../shared/load-vendure-config-file';
+import { analyzeProject } from '../../../shared/shared-prompts';
+import { VendureConfigRef } from '../../../shared/vendure-config-ref';
+import { type SchemaOptions } from '../schema';
+
+const cancelledMessage = 'Generate schema cancelled';
+
+export async function generateSchema(options: SchemaOptions) {
+    resetConfig();
+    try {
+        const { project, tsConfigPath } = await analyzeProject({ cancelledMessage });
+        const vendureConfig = new VendureConfigRef(project);
+        log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
+        const config = await loadVendureConfigFile(vendureConfig, tsConfigPath);
+        await setConfig(config);
+
+        const apiType = options.api === 'admin' ? 'admin' : 'shop';
+
+        const runtimeConfig = await runPluginConfigurations(getConfig() as any);
+        const typesLoader = new GraphQLTypesLoader();
+        const schema = await getFinalVendureSchema({
+            config: runtimeConfig,
+            typePaths: VENDURE_ADMIN_API_TYPE_PATHS,
+            typesLoader,
+            apiType,
+        });
+        const format = options.format === 'json' ? 'json' : 'sdl';
+        const ext = format === 'sdl' ? 'graphql' : 'json';
+        const fileName = options.fileName ?? `schema${apiType === 'shop' ? '-shop' : ''}.${ext}`;
+        const outFile = path.join(options.outputDir ?? process.cwd(), fileName);
+        if (format === 'sdl') {
+            writeFileSync(outFile, printSchema(schema));
+        } else {
+            const jsonSchema = graphqlSync({
+                schema,
+                source: getIntrospectionQuery(),
+            }).data;
+            writeFileSync(outFile, JSON.stringify(jsonSchema));
+        }
+        log.info(`Generated schema: ${outFile}`);
+    } catch (e) {
+        log.error(e instanceof Error ? e.message : String(e));
+        process.exit(0);
+    }
+}

+ 120 - 0
packages/cli/src/commands/schema/schema.ts

@@ -0,0 +1,120 @@
+import { cancel, intro, isCancel, log, outro, select, text } from '@clack/prompts';
+import pc from 'picocolors';
+
+import { withInteractiveTimeout } from '../../utilities/utils';
+
+const cancelledMessage = 'Schema generation cancelled.';
+
+export interface SchemaOptions {
+    api: 'admin' | 'shop';
+    format?: 'sdl' | 'json';
+    fileName?: string;
+    outputDir?: string;
+}
+
+/**
+ * This command is used to generate a schema file for use with other GraphQL tools
+ * such as IDE plugins.
+ */
+export async function schemaCommand(options?: SchemaOptions) {
+    // Check if any non-interactive options are provided
+    if (options?.api) {
+        // Non-interactive mode
+        await handleNonInteractiveMode(options);
+        return;
+    }
+
+    // Interactive mode (original behavior)
+    await handleInteractiveMode();
+}
+
+async function handleNonInteractiveMode(options: SchemaOptions) {
+    try {
+        process.env.VENDURE_RUNNING_IN_CLI = 'true';
+        const { generateSchema } = await import('./generate-schema/generate-schema');
+        await generateSchema(options);
+        process.env.VENDURE_RUNNING_IN_CLI = undefined;
+    } catch (e: any) {
+        log.error(e.message as string);
+        if (e.stack) {
+            log.error(e.stack);
+        }
+        process.exit(1);
+    }
+}
+
+async function handleInteractiveMode() {
+    // eslint-disable-next-line no-console
+    console.log(`\n`);
+    intro(pc.blue('🛠️️ Generate a schema file of your GraphQL Admin API'));
+
+    const apiType: 'admin' | 'shop' | symbol = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'Which API should we target?',
+            options: [
+                { value: 'admin', label: 'Admin API' },
+                { value: 'shop', label: 'Shop API' },
+            ],
+        });
+    });
+
+    if (isCancel(apiType)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+
+    const format: 'sdl' | 'json' | symbol = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'What format should we use for the schema?',
+            options: [
+                { value: 'sdl', label: 'SDL format (default)' },
+                { value: 'json', label: 'JSON introspection query result' },
+            ],
+        });
+    });
+
+    if (isCancel(format)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    const outputDir = await withInteractiveTimeout(async () => {
+        return await text({
+            message: 'Output directory:',
+            initialValue: process.cwd(),
+        });
+    });
+    if (isCancel(outputDir)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+
+    const fileName = await withInteractiveTimeout(async () => {
+        const defaultBase = `schema${apiType === 'shop' ? '-shop' : ''}`;
+        return await text({
+            message: 'File name:',
+            initialValue: format === 'sdl' ? `${defaultBase}.graphql` : `${defaultBase}.json`,
+        });
+    });
+
+    if (isCancel(fileName)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    try {
+        process.env.VENDURE_RUNNING_IN_CLI = 'true';
+        const { generateSchema } = await import('./generate-schema/generate-schema');
+        await generateSchema({
+            api: apiType,
+            format,
+            fileName,
+            outputDir,
+        });
+        outro('✅ Done!');
+        process.env.VENDURE_RUNNING_IN_CLI = undefined;
+    } catch (e: any) {
+        log.error(e.message as string);
+        if (e.stack) {
+            log.error(e.stack);
+        }
+    }
+}

+ 0 - 0
packages/cli/src/commands/migrate/load-vendure-config-file.spec.ts → packages/cli/src/shared/load-vendure-config-file.spec.ts


+ 5 - 5
packages/cli/src/commands/migrate/load-vendure-config-file.ts → packages/cli/src/shared/load-vendure-config-file.ts

@@ -3,9 +3,10 @@ import { readFileSync } from 'node:fs';
 import path from 'node:path';
 import { register } from 'ts-node';
 
-import { VendureConfigRef } from '../../shared/vendure-config-ref';
-import { selectTsConfigFile } from '../../utilities/ast-utils';
-import { isRunningInTsNode } from '../../utilities/utils';
+import { selectTsConfigFile } from '../utilities/ast-utils';
+import { isRunningInTsNode } from '../utilities/utils';
+
+import { VendureConfigRef } from './vendure-config-ref';
 
 export async function loadVendureConfigFile(
     vendureConfig: VendureConfigRef,
@@ -59,6 +60,5 @@ export async function loadVendureConfigFile(
         throw new Error('Could not find the exported variable name in the VendureConfig file');
     }
     const configModule = await import(vendureConfig.sourceFile.getFilePath());
-    const config = configModule[exportedVarName];
-    return config;
+    return configModule[exportedVarName];
 }