Skip to content

Plugin Development

Andrey Subbotin edited this page Feb 28, 2022 · 10 revisions

Prerequisites

JDK 10 installed on you computer.

Gradle 4.8.1

Clone CUBA CLI sources and install it to you local m2 repository using ./gradlew install command.

You can also use CUBA CLI binaries from the repository https://repo.cuba-platform.com/content/groups/work. Add to your build.gradle:

repositories { 
  maven {
    url "https://repo.cuba-platform.com/content/groups/work" 
  } 
}

Plugin basics

Plugin loading

The main extension point for CUBA CLI is com.haulmont.cuba.cli.CliPlugin interface. To make CLI able to load your plugin you should implement this interface. CLI will load it with the ServiceLoader and register in Guava event bus. Also module-info.java should declare that it provides CliPlugin with your implementation.

CLI lifecycle

CLI may be started in two modes.

  • SHELL is an interactive mode, when CLI takes user command, evaluate it and waiting for next command.
  • SINGLE_COMMAND is mode, that runs only one command, that user specified in command line parameters. Plugin can get current mode from InitPluginEvent.

Life cycle is served by Guava EventBus. After plugin loading it will be registered in event bus. To subscribe an event simply add void method with single parameter of the event type and mark the method with an annotation com.google.common.eventbus.Subscribe. Available events are:

  • com.haulmont.cuba.cli.event.InitPluginEvent
  • com.haulmont.cuba.cli.event.BeforeCommandExecutionEvent
  • com.haulmont.cuba.cli.event.AfterCommandExecutionEvent
  • com.haulmont.cuba.cli.event.ModelRegisteredEvent
  • com.haulmont.cuba.cli.event.DestroyPluginEvent
  • com.haulmont.cuba.cli.event.ErrorEvent After CLI is launched it fires InitPluginEvent, and all subscribed plugins may register their commands. Before CLI is closed it fires DestroyEvent.

Commands

To create your own command you should create a class for it that implements com.haulmont.cuba.cli.commands.CliCommand interface. By the way, more convenient would be extending com.haulmont.cuba.cli.commands.AbstractCommand class or com.haulmont.cuba.cli.commands.GeneratorCommand, if your command will generate some content. Package of the command should be marked as open in module-info.java.

You can add parameters to command that will be input after command name. To add them simply create corresponding field and mark them with [com.beust.jcommander.Parameter] annotation. You can get more information about command parameters in JCommander documentation. To add command description, mark it with com.beust.jcommander.Parameters annotation and specify com.beust.jcommander.Parameters.commandDescription value.

To register your command, subscribe your plugin to InitPluginEvent. The event contains special CommandsRegistry with that you can register your commands and their sub-commands as following:

    @Subscribe
    fun onInit(event: InitPluginEvent) {
        event.commandsRegistry {
            command("command-name", SomeCommand()) {
                command("sub-command-name", SomeSubCommand())
            }
        }
    }

Artifact generation

CUBA CLI has special mechanisms to generate code or another content with special templates. To use these mechanisms, it is preferable your command to implement com.haulmont.cuba.cli.commands.GeneratorCommand.

GeneratorCommand has special lifecycle.

  • Prompting GeneratorCommand.prompting is the first phase, at which user is asked with questions about ahead generated artifact.
  • After that, the command creates artifact model based on the prompting phase user answers and register it in the cliContext by name retrieved from getModelName.
  • At generation phase, the command gets all available models as Map<String, Any> and generates artifact.

There is a sort of DSL to prompt user about artifact parameters. It is quite simple, has some question types, default values and validation. You can view an example in any CUBA CLI command GeneratorCommand.

The generation is based on templates and models. Every model is often a simple POJO class that describes future generated artifact parameters. Models stored in cliContext, and every of them has own name. If command is launched in existing CUBA project, cliContext will be store ProjectModel by name project. GeneratorCommand registers newly created model in cliContext after prompting phase. You also can register additional models in your plugin on BeforeCommandExecutionEvent. Actually, as model is a class without any restrictions, you can register anything as model, for example, some class with helper functions.

You can read about templates structure here, but there is one difference, that templates you use in the commands doesn't need to have template.xml descriptor. Variables are allowed in template directories names. Records like ${varpath} will be substituted with corresponding variable from Velocity Context. Records like $[packageNameVariable] is used to automatically convert package names to directories names. Packages of all your models should be opened in order to Apache Velocity may access them through reflexion.

To generate code or another content from template, use com.haulmont.cuba.cli.generation.TemplateProcessor. If you implement GeneratorCommand class, you will have to implement generate method, that takes bindings parameter. This bindings parameter is a map when key is a model name and value is a corresponding model object. In most cases generation looks like

TemplateProcessor(templatePath, bindings) {
    transformWhole()
}

Besides transformWhole method, that processes every file in template directory as it is a Apache Velocity template, you can use methods copy(from, to), transform(from, to) and copyWhole.

Dependency injection

CUBA CLI uses Kodein-DI as a dependency injection container. All default dependencies are available throw the com.haulmont.cuba.cli.kodein instance. But if you need to provide your own dependencies in your plugin, you can extend default kodein, and use it inside your plugin.

import com.haulmont.cuba.cli.kodein

val localKodein = Kodein {
 extend(kodein)
 bind<Worker>() with singleton { Worker() }
}

...

private val worker: Worker by localKodein.instance()

Sample plugin tutorial

Lets create simple CUBA CLI plugin that will be open current project in IntelliJ IDEA. We will add command idea that will firstly create .ipr file by invoking gradlew idea and than communicate through TCP with CUBA idea plugin to open project. You can get all code here.

First of all, lets create new project.

  1. Open IntelliJ IDEA, choose Create New Project.
  2. Choose Java 10 SDK, at Additional Libraries and Frameworks section check Java and Kotlin (Java) options. Press Next, input Group id and Artifact name.
  3. Open build.gradle and paste following code:
buildscript {
    ext.kotlin_version = '1.2.41'

    repositories {
        mavenCentral()

        maven {
            credentials {
                username System.getenv('HAULMONT_REPOSITORY_USER') ?: 'cuba'
                password System.getenv('HAULMONT_REPOSITORY_PASSWORD') ?: 'cuba123'
            }
            url System.getenv('HAULMONT_REPOSITORY_URL') ?: 'https://repo.cuba-platform.com/content/groups/work'
        }
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

group 'com.haulmont' // Don't forget to specify your group id
version '1.0-SNAPSHOT'

def moduleName = "cli.plugin.tutorial" // You can use your module name

apply plugin: 'kotlin'
apply plugin: 'maven'
apply plugin: 'application'

sourceCompatibility = 10
targetCompatibility = 10

repositories {
    mavenCentral()
    mavenLocal()

    maven {
        credentials {
            username System.getenv('HAULMONT_REPOSITORY_USER') ?: 'cuba'
            password System.getenv('HAULMONT_REPOSITORY_PASSWORD') ?: 'cuba123'
        }
        url System.getenv('HAULMONT_REPOSITORY_URL') ?: 'https://repo.cuba-platform.com/content/groups/work'
    }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "com.haulmont.cuba.cli:cuba-cli:1.0-SNAPSHOT"
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

[compileKotlin, compileTestKotlin].each {
    it.kotlinOptions.jvmTarget = '1.8'
}

compileJava {
    inputs.property("moduleName", moduleName)
    doFirst {
        options.compilerArgs = [
                '--module-path', classpath.asPath,
                '--patch-module', "$moduleName=${compileKotlin.destinationDir}"
        ]
        classpath = files()
    }
}

/**
 * The task allows us to quickly put plugin to special plugins directory.
 */
task installPlugin(dependsOn: jar, type: Copy) {
    inputs.files jar.outputs.files

    jar.outputs.files.each {
        from it
    }

    into System.getProperty("user.home") + "/.haulmont/cli/plugins/"
}

Don't forget to specify your group id.

Create src/main/java/module-info.java file with following content:

import com.haulmont.cuba.cli.CliPlugin;

module cli.plugin.tutorial {
    requires java.base;
    requires kotlin.stdlib;
    requires kotlin.reflect;

    requires jcommander;

    requires com.haulmont.cuba.cli;
    requires com.google.common;
    requires kodein.di.core.jvm;
    requires kodein.di.generic.jvm;
    requires practicalxml;
    
    opens com.haulmont.cli.tutorial;
    exports com.haulmont.cli.tutorial;

    // This line very important, as it indicates that module provides its own implementation of CliPlugin and ServiceLoader could load it.
    provides CliPlugin with com.haulmont.cli.tutorial.DemoPlugin;
}

In src/main/kotlin/ create package com.haulmont.cli.tutorial. In it create new kotlin class DemoPlugin and implement com.haulmont.cuba.cli.CliPlugin. This class will be our plugin entry point.

Lets add method that will register our plugin command.

@Subscribe
fun onInit(event: InitPluginEvent) {
    event.commandsRegistry {
        command("idea", IdeaOpenCommand())
    }
}

Our plugin doesn't have class IdeaOpenCommand so lets create it in the same package. As it will not generate any content from templates it is preferable to extend it from AbstractCommand. We can also add command description, so we could see it by invoking help command.

@Parameters(commandDescription = "Opens project in IntelliJ IDEA")
class IdeaOpenCommand : AbstractCommand() {
...
}

First of all, there is no sense, if command invoking not in CUBA project directory. So add project existence precheck.

    override fun preExecute() = checkProjectExistence()

Insert the rest code, that generates .ipr file and opens project in IDEA.

    override fun run() {
        // From context we can get project model, and than get it name
        val model = context.getModel<ProjectModel>(ProjectModel.MODEL_NAME)
        val iprFileName = model.name + ".ipr"

        // This class helps to find project files, such as build.gradle, modules source, etc.
        val projectStructure = ProjectStructure()

        val hasIpr = projectStructure.path.resolve(iprFileName).let { Files.exists(it) }
        if (!hasIpr) {
            val currentDir = projectStructure.path.toAbsolutePath()
            val createIprGradle = "${currentDir.resolve("gradlew")} idea"
            // Exec gradle task
            val process = Runtime.getRuntime().exec(createIprGradle, emptyArray(), currentDir.toFile())
            process.waitFor()
        }
        
        // CUBA IDEA plugin listens to this port
        val url = URL("""http://localhost:48561/?project=${projectStructure.path.resolve(iprFileName).toAbsolutePath()}""")
        sendRequest(url)
    }

    private fun sendRequest(url: URL) {
        try {
            val connection = url.openConnection()
            connection.connect()
            InputStreamReader(connection.getInputStream(), "UTF-8").use { reader ->
                val response = CharStreams.toString(reader)
                val firstLine = response.trim { it <= ' ' }.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0]
                if (!firstLine.startsWith("OK")) {
                    // fail method stops command execution and prints error
                    fail("Unable to connect to the IDE. Check if the IDE is running and CUBA Plugin is installed.")
                }
            }
        } catch (e: IOException) {
            fail("Unable to connect to the IDE. Check if the IDE is running and CUBA Plugin is installed.")
        }
    }

In terminal execute ./gradlew installPlugin. Now, cli will load your plugin on startup. Lets test it.

  1. Ensure, that CUBA CLI is installed in your system.
  2. In terminal execute cuba-cli.
  3. Run create-app. Fill the parameters.
  4. Run idea. If you don't have IDEA launched on your PC, you will see error message. Launch it and repeat.

Plugin debug

Debug is very important part of development. There is short instruction how to debug your plugin.

  1. Open CUBA CLI installation directory.
  2. Make copy of cuba-cli/cuba-cli.bat and name it cuba-cli-debug/cuba-cli-debug.bat
  3. Open created file and replace JLINK_VM_OPTIONS= with JLINK_VM_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005.
  4. In the IntelliJ IDEA create new Remote run configuration on port 5005.
  5. Run installPlugin gradle task.
  6. In the terminal launch cuba-cli-debug. It will hang and wait for debugger.
  7. Start created Remote debug configuration in the IntelliJ IDEA.
  8. Now you can debug your plugin.