diff --git a/CHANGELOG.md b/CHANGELOG.md index da9d85a63776ef536cc6549f76c3551db2cda4bb..e685b34a4e788bb34634a5dd33f4f40217ef9aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,16 @@ --> -## 3.4.0 (???) +## 3.4.2 (2024-01-23) + +### π Bugfix +- Fix description of the unpublish command in the CLI help + +### π Documentation +- Wiki: add tutorial about adding a new command to the CLI + + +## 3.4.1 (2024-01-23) ### β¨ Feature - Limit of 2 exercises by user diff --git a/NodeApp/package-lock.json b/NodeApp/package-lock.json index 5fa193f14e3e973eddca8cf7af8fcac0e7b1c0ea..e65258129e6b3233b33eea6eb42fb84f12d39f5e 100644 --- a/NodeApp/package-lock.json +++ b/NodeApp/package-lock.json @@ -1,12 +1,12 @@ { "name": "dojo_cli", - "version": "3.4.1", + "version": "3.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dojo_cli", - "version": "3.4.1", + "version": "3.4.2", "license": "AGPLv3", "dependencies": { "appdata-path": "^1.0.0", diff --git a/NodeApp/package.json b/NodeApp/package.json index fe5466e2f44a52dcf09f8bd37b9ee203d6b3cb1f..3ae9cf17b1c7c2932e3ed62e9ead6f9a7e6f0339 100644 --- a/NodeApp/package.json +++ b/NodeApp/package.json @@ -1,7 +1,7 @@ { "name" : "dojo_cli", "description" : "CLI of the Dojo project", - "version" : "3.4.1", + "version" : "3.4.2", "license" : "AGPLv3", "author" : "MichaΓ«l Minelli <dojo@minelli.me>", "main" : "dist/app.js", diff --git a/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts b/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts index acc361c3ffd9e01d2adf65a55bc187c7180720c5..3c7c1d86e5c365d9b12e23d28487f8f5a3edcf67 100644 --- a/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts +++ b/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts @@ -13,7 +13,7 @@ abstract class AssignmentPublishUnpublishCommandBase extends CommanderCommand { protected defineCommand() { this.command - .description('publish an assignment') + .description(`${ this.publish ? 'publish' : 'unpublish' } 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)); diff --git a/Wiki/Development/1-How-to-setup-your-development-environment.md b/Wiki/Development/1-How-to-setup-your-development-environment.md new file mode 100644 index 0000000000000000000000000000000000000000..47a876164e87e7f6a17827ed854a0c1484f5e40a --- /dev/null +++ b/Wiki/Development/1-How-to-setup-your-development-environment.md @@ -0,0 +1,94 @@ +# 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 --recurse-submodule 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 diff --git a/Wiki/Development/2-How-to-add-a-new-command.md b/Wiki/Development/2-How-to-add-a-new-command.md new file mode 100644 index 0000000000000000000000000000000000000000..8548351bc0d7d45d5f0f8dcb4d8ad48fa7d6dabb --- /dev/null +++ b/Wiki/Development/2-How-to-add-a-new-command.md @@ -0,0 +1,402 @@ +# 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 diff --git a/Wiki/home.md b/Wiki/home.md index 90b298e88fc361293cfa24c148149689e3cd9fa6..df06c272b3e13cb87c280810470168ba50f5cba9 100644 --- a/Wiki/home.md +++ b/Wiki/home.md @@ -23,3 +23,10 @@ More details here : [Dojo detailed presentation](0-Dojo-presentation) ### Teaching staff * [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