How to add a new command

Tutorial on how to add a new command to the DojoCLI.

Introduction

This tutorial describes how to add a new command to the Dojo cli. For that we take an existing command and describe his implementation. This command allow a member of the teaching staff of an assignment to publish it.

The command is named publish and is a subcommand of the assignment command. Here is the command structure:

dojo assignment publish [options] <assignment_name_or_url>

This tutorial is linked to another one which explains how to add a new route to the Dojo backend. The documentation for this is available on the Dojo backend documentation.

For the rest of this tutorial we will assume that we are in the NodeApp folder :

cd NodeApp

Prerequisites

All the prerequisites are described in How to setup your development environment tutorial.

Commands files structure

The commands files are located in the src/commander folder. All command files are named with the following pattern: commandPathCommand.ts where commandPath has to be replaced by the path of the command (command, subcommand, subsubcommand, etc.).

For each command there are two choices:

  1. If it's a command that will contains subcommands, you will need to create a folder with the name of the command.
    • In this folder you will need to create a file with the name of the command.
    • A subfolder named subcommands will be needed to store the subcommands files or folders.
  2. If it's a command that will not contains subcommands, you will need to create a file with the name of the command.

Apply to our use case

In our case we will create a command that will be a subcommand of the assignment command, so its file path will be : src/commander/assignment/subcommands/AssignmentPublishCommand.ts.

Command class inheritance

All commands must inherit from the CommanderCommand abstract class. This class is located in the src/commander/CommanderCommand.ts file.

When you inherit from this class you will need to implement the following methods and properties :

  • commandName: string: This property will be used to define the name of the (sub)command.
  • defineCommand(): void: This method will be used to define the command itself.
  • commandAction(...args: Array<unknown>): Promise<void>: This method will be used to define the action of the command. You will need to link the command to the action by writing this code in the defineCommand method:
this.command.action(this.commandAction.bind(this));

Optionally, you can implement the defineSubCommands(): void method if you want to declare some subcommands. Here is an exemple of implementation:

protected defineSubCommands()
{
    SessionLoginCommand.registerOnCommand(this.command);
    SessionLogoutCommand.registerOnCommand(this.command);
    SessionTestCommand.registerOnCommand(this.command);
}

Apply to our use case

Now, the src/commander/assignment/subcommands/AssignmentPublishCommand.ts file will look like this:

import CommanderCommand from '../../CommanderCommand';


class AssignmentPublishCommand extends CommanderCommand {
    protected commandName: string = 'publish';

    protected defineCommand() {
    ...
    }

    protected async commandAction(assignmentNameOrUrl: string, options: { force: boolean }): Promise<void> {
    ...
    }
}


export default new AssignmentPublishCommand();

Define the command

In the defineCommand() method you will need to define the command itself. To do this you will need to use the Commander.js library. The documentation is available here.

Apply to our use case

We want to define the publish command that take the name of the assignment (or his url) as an argument and have a --force option that will allow the user to publish the assignment without having to confirm the action.

Here is the code to add in the defineCommand() method:

protected defineCommand()
{
    this.command
        .description('publish an assignment')
        .argument('<name or url>', 'name or url (http/s or ssh) of the assignment')
        .option('-f, --force', 'don\'t ask for confirmation')
        .action(this.commandAction.bind(this));
}

Define the action

To define the action we must adapt the commandAction() method by adding arguments and options defined in the command definition.

And then we will need to implement the action of the command.

Apply to our use case

In our case we will need to adapt the commandAction() method like this:

protected async commandAction(assignmentNameOrUrl: string, options: { force: boolean }): Promise<void> {
    ...
}

For the implementation we will split our code into several steps:

  1. Options parsing and confirmation asking
  2. Checking / Retrieving data
    1. Test the session validity
    2. Retrieve the assignment and check if it exists
    3. Check if the user is in the teaching staff of the assignment
    4. Check if the assignment is publishable by getting the last pipeline status
  3. Publishing the assignment

1. Options parsing and confirmation asking

First we need to parse the options and check if the --force option is present. If it's not present we ask the user to confirm the action.

if ( !options.force ) {
    options.force = (await inquirer.prompt({
                                               type   : 'confirm',
                                               name   : 'force',
                                               message: 'Are you sure you want to publish this assignment?'
                                           })).force;
}

if ( !options.force ) {
    return;
}

2. Checking / Retrieving data

2.1. Test the session validity

We call the SessionManager singleton that contain a function to test the session validity. It checks only the Dojo backend session validity and not the Gitlab one.

if ( !await SessionManager.testSession(true, null) ) {
    return;
}
2.2. Retrieve the assignment and check if it exists

We call the DojoBackendManager singleton that contains a function to retrieve an assignment by his name or his url. This function make a request to the Dojo backend to retrieve the assignment.

assignment = await DojoBackendManager.getAssignment(assignmentNameOrUrl);
if ( !assignment ) {
    return;
}
2.3. Check if the user is in the teaching staff of the assignment

We check if the user is in the teaching staff of the assignment by verifying if his id is in the staff array.

if ( !assignment.staff.some(staff => staff.id === SessionManager.profile?.id) ) {
    return;
}
2.4. Check if the assignment is publishable by getting the last pipeline status

We call the SharedAssignmentHelper singleton that contains a function to check if the assignment is publishable (an assignment is publishable only if the last pipeline status is success). This function make a request to the Gitlab API to retrieve the last pipeline of the assignment and return an object with the informations of it including a isPublishable state that is true only if the status of the pipeline is success.

const isPublishable = await SharedAssignmentHelper.isPublishable(assignment.gitlabId);
if ( !isPublishable.isPublishable ) {
    return;
}

3. Publishing the assignment

Finally, we will call the DojoBackendManager singleton that contain a function to publish an assignment as will be described hereafter.

First of all, we need to implement the route on the backend. For that, please refer to the linked tutorial on the Dojo backend API wiki.

Then, we need to create a function that will call the newly created route. This function needs to know the route that we have previously created. We will store this route in the src/sharedByClients/types/Dojo/ApiRoutes.ts file.

export enum ApiRoute {
    ...
    ASSIGNMENT_PUBLISH = '/assignment/{{nameOrUrl}}/publish',
    ASSIGNMENT_UNPUBLISH = '/assignment/{{nameOrUrl}}/unpublish',
    ...
}

Then, we can create the function that will be located in the backend manager singleton stored in the src/managers/DojoBackendManager.ts file.

Note: We use the axios library to make the request to the backend. We do not need to fill authorization headers because the axios interceptors defined in the src/managers/HttpManager.ts file will do it for us.

public async changeAssignmentPublishedStatus(assignment: Assignment, publish: boolean, verbose: boolean = true) {
    try {
        await axios.patch<DojoBackendResponse<null>>(this.getApiUrl(publish ? ApiRoute.ASSIGNMENT_PUBLISH : ApiRoute.ASSIGNMENT_UNPUBLISH).replace('{{nameOrUrl}}', encodeURIComponent(assignment.name)), {});
    } catch ( error ) {
    ...
        throw error;
    }
}

Finally, we go back to the command file and we call the function to publish the assignment.

try {
    await DojoBackendManager.changeAssignmentPublishedStatus(assignment, true);
} catch ( error ) {
    return;
}

Use case: final code

In the examples above we have seen all the different steps to implement our publish subcommand. For a better readability all the UI code (done with ora library) have been removed from the snippets.

Here is the final code of the src/commander/assignment/subcommands/AssignmentPublishCommand.ts file with integration of ora calls for the interface.

AssignmentPublishCommand.ts

import CommanderCommand     from '../../CommanderCommand';
import inquirer          from 'inquirer';
import Assignment         from '../../../sharedByClients/models/Assignment';
import chalk            from 'chalk';
import SessionManager      from '../../../managers/SessionManager';
import ora              from 'ora';
import DojoBackendManager    from '../../../managers/DojoBackendManager';
import SharedAssignmentHelper from '../../../shared/helpers/Dojo/SharedAssignmentHelper';


class AssignmentPublishCommand extends CommanderCommand {
    protected commandName: string = 'publish';

    protected defineCommand() {
        this.command
            .description('publish an assignment')
            .argument('<name or url>', 'name or url (http/s or ssh) of the assignment')
            .option('-f, --force', 'don\'t ask for confirmation')
            .action(this.commandAction.bind(this));
    }

    protected async commandAction(assignmentNameOrUrl: string, options: { force: boolean }): Promise<void> {
        if ( !options.force ) {
            options.force = (await inquirer.prompt({
                                                       type  : 'confirm',
                                                       name  : 'force',
                                                       message: 'Are you sure you want to publish this assignment?'
                                                   })).force;
        }

        if ( !options.force ) {
            return;
        }

        let assignment!: Assignment | undefined;

        {
            console.log(chalk.cyan('Please wait while we verify and retrieve data...'));

            if ( !await SessionManager.testSession(true, null) ) {
                return;
            }

            ora('Checking assignment:').start().info();
            ora({
                    text  : assignmentNameOrUrl,
                    indent: 4
                }).start().info();
            const assignmentGetSpinner: ora.Ora = ora({
                                                          text  : 'Checking if assignment exists',
                                                          indent: 8
                                                      }).start();
            assignment = await DojoBackendManager.getAssignment(assignmentNameOrUrl);
            if ( !assignment ) {
                assignmentGetSpinner.fail(`The assignment doesn't exists`);
                return;
            }
            assignmentGetSpinner.succeed(`The assignment exists`);


            const assignmentCheckAccessSpinner: ora.Ora = ora({
                                                                  text  : 'Checking accesses',
                                                                  indent: 8
                                                              }).start();
            if ( !assignment.staff.some(staff => staff.id === SessionManager.profile?.id) ) {
                assignmentCheckAccessSpinner.fail(`You are not in the staff of this assignment`);
                return;
            }
            assignmentCheckAccessSpinner.succeed(`You are in the staff of this assignment`);


            const assignmentIsPublishable: ora.Ora = ora({
                                                             text  : 'Checking if the assignment is publishable',
                                                             indent: 8
                                                         }).start();
            const isPublishable = await SharedAssignmentHelper.isPublishable(assignment.gitlabId);
            if ( !isPublishable.isPublishable ) {
                assignmentIsPublishable.fail(`The assignment is not publishable: ${ isPublishable.status?.message }`);
                return;
            }
            assignmentIsPublishable.succeed(`The assignment is publishable`);
        }

        {
            console.log(chalk.cyan(`Please wait while we publish the assignment...`));

            try {
                await DojoBackendManager.changeAssignmentPublishedStatus(assignment, true);
            } catch ( error ) {
                return;
            }
        }
    }
}


export default new AssignmentPublishCommand();

ApiRoutes.ts

export enum ApiRoute {
    ...
    ASSIGNMENT_PUBLISH = '/assignment/{{nameOrUrl}}/publish',
    ASSIGNMENT_UNPUBLISH = '/assignment/{{nameOrUrl}}/unpublish',
    ...
}

DojoBackendManager.ts

public async changeAssignmentPublishedStatus(assignment: Assignment, publish: boolean, verbose: boolean = true) {
    const spinner: ora.Ora = ora('Changing published status...');

    if ( verbose ) {
        spinner.start();
    }

    try {
        await axios.patch<DojoBackendResponse<null>>(this.getApiUrl(publish ? ApiRoute.ASSIGNMENT_PUBLISH : ApiRoute.ASSIGNMENT_UNPUBLISH).replace('{{nameOrUrl}}', encodeURIComponent(assignment.name)), {});

        if ( verbose ) {
            spinner.succeed(`Assignment ${ assignment.name } successfully ${ publish ? 'published' : 'unpublished' }`);
        }

        return;
    } catch ( error ) {
        if ( verbose ) {
            if ( error instanceof AxiosError && error.response ) {
                spinner.fail(`Assignment visibility change error: ${ error.response.statusText }`);
            } else {
                spinner.fail(`Assignment visibility change error: unknown error`);
            }
        }

        throw error;
    }
}