Skip to content
Snippets Groups Projects
Commit 3375cd53 authored by michael.minelli's avatar michael.minelli Committed by orestis.malaspin
Browse files

Adds tutorial on command creation in documentation

parent 1d90c481
No related branches found
No related tags found
1 merge request!6Add documentations with how to add a command tutorial
# How to setup your development environment
## Introduction
This tutorial describes how to setup your development environment for building the Dojo CLI by detailing the prerequisites and dependencies needed.
## Technologies
The cli is built using [NodeJS](https://nodejs.org/en/) and [NPM](https://www.npmjs.com/).
The programming language used is [Typescript](https://www.typescriptlang.org/) v5.
## Prerequisites
In order to build the cli you will need the following tools:
- [NodeJS](https://nodejs.org/en/) (version 18 or higher)
- [NPM](https://www.npmjs.com/) (version 10 or higher)
Install NodeJS and NPM by following the instructions on the [official website](https://nodejs.org/en/download/package-manager).
Or via Node Version Manager (NVM) by following the instructions on the [official website](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
## Dependencies
The CLI is packaged using [pkg](https://www.npmjs.com/package/pkg).
This means that all the dependencies are bundled in the final binary.
Here are the main dependencies used by the cli (you don't need to install them manually or globally on your system):
- [Axios](https://www.npmjs.com/package/axios): a promise-based HTTP client for the browser and Node.js. It is
used to make HTTP(S) requests to the Dojo backend and Gitlab.
- [Boxen](https://www.npmjs.com/package/boxen): used to display messages in a box in the terminal.
- [Chalk](https://www.npmjs.com/package/chalk): used to add colors to the messages in the terminal.
- [Commander.js](https://www.npmjs.com/package/commander): a library to write command-line interfaces.
- [Dotenv](https://www.npmjs.com/package/dotenv): used to load environment variables from a .env file.
- [Dotenv-vault](https://www.npmjs.com/package/dotenv-vault): a CLI to sync .env files across machines,
environments, and team members.
- [Inquirer](https://www.npmjs.com/package/inquirer): used to ask perform to the interactive command line user interfaces.
- [JsonWebToken](https://www.npmjs.com/package/jsonwebtoken): used to generate and validate [JSON Web Tokens](https://jwt.io/).
- [ora](https://www.npmjs.com/package/ora): used to display elegantly in the terminal.
- [zod](https://www.npmjs.com/package/zod): a TypeScript-first schema validation with static type inference. Used
in the projet to validate json files created by the user.
## Installation
First of all, you need to clone the repository:
```bash
$ git clone ssh://git@ssh.hesge.ch:10572/dojo_project/projects/ui/dojocli.git
```
Then, you need to move to the project's directory:
```bash
$ cd NodeApp
```
To install the dependencies listed above you can use the following command in the base directory of the project:
```bash
$ npm install
```
## Environment variables
Environment variables are used to store sensitive information such as API keys, passwords, etc.
They are also used to store configuration information that can be changed without modifying the code.
You can decrypt env var stored in the `.env.vault` file with the following commands in the project's main folder:
```bash
> npx dotenv-vault local keys
environment DOTENV_KEY
─────────── ─────────────────────────────────────────────────────────────────────────
development dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development
Set DOTENV_KEY on your server
> npx dotenv-vault local decrypt dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development > .env.development
```
**The `.env.keys` file have to be requested to the project maintainer: [Michaël Minelli](mailto:dojo@minelli.me).**
## Run the cli
To run the cli (in dev mode) you can use the following command in the base directory of the project:
```bash
$ npm run start:dev -- COMMAND
```
Where `COMMAND` is the command you want to run.
For example, if you want to test the exercise creation command you can use the following command:
```bash
$ npm run start:dev -- exercise create -a "Technique de compilation - TP" --members_username michael.minelli
```
\ No newline at end of file
# How to add a new command to the Dojo cli
## 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:
```bash
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 wiki](https://gitedu.hesge.ch/dojo/backend/-/wikis/Development/1-How-to-add-a-new-route).
For the rest of this tutorial we will assume that we are in the `NodeApp` folder :
```bash
cd NodeApp
```
## Prerequisites
All the prerequisites are described in
[How to setup your development environment](1-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:
```typescript
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:
```typescript
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:
```typescript
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](https://github.com/tj/commander.js).
### 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:
```typescript
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:
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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`.
```typescript
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](https://gitedu.hesge.ch/dojo/backend/-/wikis/Development/1-How-to-add-a-new-route).
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.
```typescript
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.
```typescript
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.
```typescript
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
```typescript
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
```typescript
export enum ApiRoute {
...
ASSIGNMENT_PUBLISH = '/assignment/{{nameOrUrl}}/publish',
ASSIGNMENT_UNPUBLISH = '/assignment/{{nameOrUrl}}/unpublish',
...
}
```
### DojoBackendManager.ts
```typescript
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;
}
}
```
\ No newline at end of file
...@@ -23,3 +23,10 @@ More details here : [Dojo detailed presentation](0-Dojo-presentation) ...@@ -23,3 +23,10 @@ More details here : [Dojo detailed presentation](0-Dojo-presentation)
### Teaching staff ### Teaching staff
* [How to create and publish an assignment](Tutorials/1-Assignment-creation) * [How to create and publish an assignment](Tutorials/1-Assignment-creation)
## Development / Contribution
* [How to contribute]() - Available soon
* [How to setup your development environment](Development/1-How-to-setup-your-development-environment)
* [How to add a new command](Development/2-How-to-add-a-new-command)
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment