Skip to content

Commit

Permalink
feat(cli): Implement "add" command for ui extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Feb 22, 2024
1 parent 6981c73 commit 795b013
Show file tree
Hide file tree
Showing 26 changed files with 5,280 additions and 4,544 deletions.
6 changes: 4 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"scripts": {
"build": "rimraf dist && tsc -p ./tsconfig.cli.json",
"watch": "tsc -p ./tsconfig.cli.json --watch",
"ci": "yarn build"
"ci": "yarn build",
"test": "vitest --config ./vitest.config.ts --run"
},
"publishConfig": {
"access": "public"
Expand All @@ -38,7 +39,8 @@
"change-case": "^4.1.2",
"commander": "^11.0.0",
"fs-extra": "^11.1.1",
"picocolors": "^1.0.0"
"picocolors": "^1.0.0",
"ts-morph": "^21.0.1"
},
"devDependencies": {
"typescript": "4.9.5"
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@

import { Command } from 'commander';

import { registerAddCommand } from './commands/add/add';
import { registerNewCommand } from './commands/new/new';
import { Logger } from './utilities/logger';

const program = new Command();

// eslint-disable-next-line @typescript-eslint/no-var-requires
const version = require('../package.json').version;

program.version(version).description('The Vendure CLI');
program
.version(version)
.description('The Vendure CLI')
.option(
'--log-level <logLevel>',
"Log level, either 'silent', 'info', or 'verbose'. Default: 'info'",
'info',
);

const options = program.opts();
if (options.logLevel) {
Logger.setLogLevel(options.logLevel);
}
registerNewCommand(program);
registerAddCommand(program);

void program.parseAsync(process.argv);
29 changes: 29 additions & 0 deletions packages/cli/src/commands/add/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { cancel, isCancel, select } from '@clack/prompts';
import { Command } from 'commander';

import { addUiExtensions } from './ui-extensions/add-ui-extensions';

const cancelledMessage = 'Add feature cancelled.';

export function registerAddCommand(program: Command) {
program
.command('add')
.description('Add a feature to your Vendure project')
.action(async () => {
const featureType = await select({
message: 'Which feature would you like to add?',
options: [
{ value: 'uiExtensions', label: 'Set up Admin UI extensions' },
{ value: 'other', label: 'Other' },
],
});
if (isCancel(featureType)) {
cancel(cancelledMessage);
process.exit(0);
}
if (featureType === 'uiExtensions') {
await addUiExtensions();
}
process.exit(0);
});
}
109 changes: 109 additions & 0 deletions packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { note, outro, spinner, log } from '@clack/prompts';
import path from 'path';
import { ClassDeclaration } from 'ts-morph';
import { Logger } from '../../../utilities/logger';
import { determineVendureVersion, installRequiredPackages } from '../../../utilities/package-utils';

import { Scaffolder } from '../../../utilities/scaffolder';
import { getTsMorphProject, getVendureConfig, selectPluginClass } from '../../../utilities/utils';

import { addUiExtensionStaticProp } from './codemods/add-ui-extension-static-prop/add-ui-extension-static-prop';
import { updateAdminUiPluginInit } from './codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init';
import { renderProviders } from './scaffold/providers';
import { renderRoutes } from './scaffold/routes';

export async function addUiExtensions() {
const projectSpinner = spinner();
projectSpinner.start('Analyzing project...');

await new Promise(resolve => setTimeout(resolve, 100));
const project = getTsMorphProject();
projectSpinner.stop('Project analyzed');

const pluginClass = await selectPluginClass(project, 'Add UI extensions cancelled');
if (pluginAlreadyHasUiExtensionProp(pluginClass)) {
outro('This plugin already has a UI extension configured');
return;
}
addUiExtensionStaticProp(pluginClass);

log.success('Updated the plugin class');
const installSpinner = spinner();
installSpinner.start(`Installing dependencies...`);
try {
const version = determineVendureVersion();
await installRequiredPackages([
{
pkg: '@vendure/ui-devkit',
isDevDependency: true,
version,
},
]);
} catch (e: any) {
log.error(
`Failed to install dependencies: ${
e.message as string
}. Run with --log-level=verbose to see more details.`,
);
Logger.verbose(e.stack);
}
installSpinner.stop('Dependencies installed');

const scaffolder = new Scaffolder();
scaffolder.addFile(renderProviders, 'providers.ts');
scaffolder.addFile(renderRoutes, 'routes.ts');
log.success('Created UI extension scaffold');

const pluginDir = pluginClass.getSourceFile().getDirectory().getPath();
scaffolder.createScaffold({
dir: path.join(pluginDir, 'ui'),
context: {},
});
const vendureConfig = getVendureConfig(project);
if (!vendureConfig) {
log.warning(
`Could not find the VendureConfig declaration in your project. You will need to manually set up the compileUiExtensions function.`,
);
} else {
const pluginClassName = pluginClass.getName() as string;
const pluginPath = convertPathToRelativeImport(
path.relative(
vendureConfig.getSourceFile().getDirectory().getPath(),
pluginClass.getSourceFile().getFilePath(),
),
);
const updated = updateAdminUiPluginInit(vendureConfig, { pluginClassName, pluginPath });
if (updated) {
log.success('Updated VendureConfig file');
} else {
log.warning(`Could not update \`AdminUiPlugin.init()\` options.`);
note(
`You will need to manually set up the compileUiExtensions function,\nadding ` +
`the \`${pluginClassName}.ui\` object to the \`extensions\` array.`,
'Info',
);
}
}

project.saveSync();
outro('✅ Done!');
}

function pluginAlreadyHasUiExtensionProp(pluginClass: ClassDeclaration) {
const uiProperty = pluginClass.getProperty('ui');
if (!uiProperty) {
return false;
}
if (uiProperty.isStatic()) {
return true;
}
}

function convertPathToRelativeImport(filePath: string) {
// Normalize the path separators
const normalizedPath = filePath.replace(/\\/g, '/');

// Remove the file extension
const parsedPath = path.parse(normalizedPath);
return `./${parsedPath.dir}/${parsedPath.name}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import fs from 'fs-extra';
import path from 'path';
import { Project, QuoteKind } from 'ts-morph';
import { describe, expect, it } from 'vitest';
import { defaultManipulationSettings } from '../../../../../constants';

import { getPluginClasses } from '../../../../../utilities/utils';

import { addUiExtensionStaticProp } from './add-ui-extension-static-prop';

describe('addUiExtensionStaticProp', () => {
it('add ui prop and imports', () => {
const project = new Project({
manipulationSettings: defaultManipulationSettings,
});
project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-ui-prop.fixture.ts'));
const pluginClasses = getPluginClasses(project);
expect(pluginClasses.length).toBe(1);
addUiExtensionStaticProp(pluginClasses[0]);

const result = pluginClasses[0].getSourceFile().getText();
const expected = fs.readFileSync(path.join(__dirname, 'fixtures', 'no-ui-prop.expected'), 'utf-8');
expect(result).toBe(expected);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ClassDeclaration } from 'ts-morph';

import { addImportsToFile, kebabize } from '../../../../../utilities/utils';

/**
* @description
* Adds the static `ui` property to the plugin class, and adds the required imports.
*/
export function addUiExtensionStaticProp(pluginClass: ClassDeclaration) {
const adminUiExtensionType = 'AdminUiExtension';
const extensionId = kebabize(pluginClass.getName() as string).replace(/-plugin$/, '');
pluginClass
.addProperty({
name: 'ui',
isStatic: true,
type: adminUiExtensionType,
initializer: `{
id: '${extensionId}-ui',
extensionPath: path.join(__dirname, 'ui'),
routes: [{ route: '${extensionId}', filePath: 'routes.ts' }],
providers: ['providers.ts'],
}`,
})
.formatText();

// Add the AdminUiExtension import if it doesn't already exist
addImportsToFile(pluginClass.getSourceFile(), {
moduleSpecifier: '@vendure/ui-devkit/compiler',
namedImports: [adminUiExtensionType],
order: 0,
});

// Add the path import if it doesn't already exist
addImportsToFile(pluginClass.getSourceFile(), {
moduleSpecifier: 'path',
namespaceImport: 'path',
order: 0,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as path from 'path';
import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
compatibility: '^2.0.0',
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}

static ui: AdminUiExtension = {
id: 'test-one-ui',
extensionPath: path.join(__dirname, 'ui'),
routes: [{ route: 'test-one', filePath: 'routes.ts' }],
providers: ['providers.ts'],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';

type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
compatibility: '^2.0.0',
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
import { DefaultJobQueuePlugin, dummyPaymentHandler, VendureConfig } from '@vendure/core';
import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
import path from 'path';
import { TestPlugin } from './plugins/test-plugin/test.plugin';

export const config: VendureConfig = {
apiOptions: {
port: 3000,
adminApiPath: 'admin-api',
},
authOptions: {
tokenMethod: ['bearer', 'cookie'],
},
dbConnectionOptions: {
synchronize: true,
type: 'mariadb',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '',
database: 'vendure-dev',
},
paymentOptions: {
paymentMethodHandlers: [dummyPaymentHandler],
},
// When adding or altering custom field definitions, the database will
// need to be updated. See the "Migrations" section in README.md.
customFields: {},
plugins: [
DefaultJobQueuePlugin.init({ useDatabaseForBuffer: true }),
AdminUiPlugin.init({
route: 'admin',
port: 3002,
adminUiConfig: {
apiPort: 3000,
},
app: compileUiExtensions({
outputPath: path.join(__dirname, '../admin-ui'),
extensions: [
{
id: 'existing-ui-plugin',
extensionPath: path.join(__dirname, 'existing-ui-plugin'),
providers: ['providers.ts'],
},
TestPlugin.ui,
],
devMode: true,
}),
}),
],
};
Loading

0 comments on commit 795b013

Please sign in to comment.