diff --git a/.gitignore b/.gitignore index bbcf40c9751719662c0912bd3f5b52dcc6e3e6ac..b30f2e1d0b10ca45c92e87f88f9766903d8ff732 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ Wiki/.idea NodeApp/src/config/Version.ts +dojo_bash_completion.sh +dojo.fish + ############################ MacOS # General .DS_Store diff --git a/NodeApp/src/commander/CommanderApp.ts b/NodeApp/src/commander/CommanderApp.ts index 9da832294078c3c474435505bc835414f2828ed4..b944b9e4f902307c3d8d3d469fa8ba7cb9edecd6 100644 --- a/NodeApp/src/commander/CommanderApp.ts +++ b/NodeApp/src/commander/CommanderApp.ts @@ -1,5 +1,4 @@ import { Command, Option } from 'commander'; -import AuthCommand from './auth/AuthCommand'; import ClientsSharedConfig from '../sharedByClients/config/ClientsSharedConfig'; import AssignmentCommand from './assignment/AssignmentCommand'; import ExerciseCommand from './exercise/ExerciseCommand'; @@ -9,6 +8,8 @@ import { stateConfigFile } from '../config/ConfigFiles'; import semver from 'semver/preload'; import { version } from '../config/Version'; import Config from '../config/Config'; +import CompletionCommand from './completion/CompletionCommand'; +import AuthCommand from './auth/AuthCommand'; import SessionCommand from './auth/SessionCommand'; @@ -95,6 +96,7 @@ https://gitedu.hesge.ch/dojo_project/projects/ui/dojocli/-/releases/Latest`, { SessionCommand.registerOnCommand(this.program); AssignmentCommand.registerOnCommand(this.program); ExerciseCommand.registerOnCommand(this.program); + CompletionCommand.registerOnCommand(this.program); } } diff --git a/NodeApp/src/commander/assignment/subcommands/AssignmentCreateCommand.ts b/NodeApp/src/commander/assignment/subcommands/AssignmentCreateCommand.ts index 3429b7518bb8694b892f2037f1ed138b163b28e9..7844d44021863c0cf3a8c67e1404635778d08ae9 100644 --- a/NodeApp/src/commander/assignment/subcommands/AssignmentCreateCommand.ts +++ b/NodeApp/src/commander/assignment/subcommands/AssignmentCreateCommand.ts @@ -1,5 +1,4 @@ import CommanderCommand from '../../CommanderCommand'; -import chalk from 'chalk'; import ora from 'ora'; import AccessesHelper from '../../../helpers/AccessesHelper'; import Assignment from '../../../sharedByClients/models/Assignment'; @@ -7,6 +6,7 @@ import GitlabUser from '../../../shared/types/Gitlab/GitlabUser'; import GitlabManager from '../../../managers/GitlabManager'; import DojoBackendManager from '../../../managers/DojoBackendManager'; import Toolbox from '../../../shared/helpers/Toolbox'; +import TextStyle from '../../../types/TextStyle'; class AssignmentCreateCommand extends CommanderCommand { @@ -30,7 +30,7 @@ class AssignmentCreateCommand extends CommanderCommand { // Check access and retrieve data { - console.log(chalk.cyan('Please wait while we verify and retrieve data...')); + console.log(TextStyle.BLOCK('Please wait while we verify and retrieve data...')); if ( !await AccessesHelper.checkTeachingStaff() ) { return; @@ -63,7 +63,7 @@ class AssignmentCreateCommand extends CommanderCommand { // Create the assignment { - console.log(chalk.cyan('Please wait while we are creating the assignment (approximately 10 seconds)...')); + console.log(TextStyle.BLOCK('Please wait while we are creating the assignment (approximately 10 seconds)...')); try { assignment = await DojoBackendManager.createAssignment(options.name, members, templateIdOrNamespace); @@ -75,10 +75,10 @@ class AssignmentCreateCommand extends CommanderCommand { }).start().info(); }; - oraInfo(`${ chalk.magenta('Name:') } ${ assignment.name }`); - oraInfo(`${ chalk.magenta('Web URL:') } ${ assignment.gitlabCreationInfo.web_url }`); - oraInfo(`${ chalk.magenta('HTTP Repo:') } ${ assignment.gitlabCreationInfo.http_url_to_repo }`); - oraInfo(`${ chalk.magenta('SSH Repo:') } ${ assignment.gitlabCreationInfo.ssh_url_to_repo }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('Name:') } ${ assignment.name }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('Web URL:') } ${ assignment.gitlabCreationInfo.web_url }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('HTTP Repo:') } ${ assignment.gitlabCreationInfo.http_url_to_repo }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('SSH Repo:') } ${ assignment.gitlabCreationInfo.ssh_url_to_repo }`); } catch ( error ) { return; } @@ -87,7 +87,7 @@ class AssignmentCreateCommand extends CommanderCommand { // Clone the repository { if ( options.clone ) { - console.log(chalk.cyan('Please wait while we are cloning the repository...')); + console.log(TextStyle.BLOCK('Please wait while we are cloning the repository...')); await GitlabManager.cloneRepository(options.clone, assignment.gitlabCreationInfo.ssh_url_to_repo, undefined, true, 0); } diff --git a/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts b/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts index 3c7c1d86e5c365d9b12e23d28487f8f5a3edcf67..c5745c4ed928e5b98ad84013581cbff510379da9 100644 --- a/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts +++ b/NodeApp/src/commander/assignment/subcommands/AssignmentPublishUnpublishCommandBase.ts @@ -1,11 +1,11 @@ import CommanderCommand from '../../CommanderCommand'; import inquirer from 'inquirer'; -import chalk from 'chalk'; import SessionManager from '../../../managers/SessionManager'; import ora from 'ora'; import DojoBackendManager from '../../../managers/DojoBackendManager'; import Assignment from '../../../sharedByClients/models/Assignment'; import SharedAssignmentHelper from '../../../shared/helpers/Dojo/SharedAssignmentHelper'; +import TextStyle from '../../../types/TextStyle'; abstract class AssignmentPublishUnpublishCommandBase extends CommanderCommand { @@ -35,7 +35,7 @@ abstract class AssignmentPublishUnpublishCommandBase extends CommanderCommand { let assignment!: Assignment | undefined; { - console.log(chalk.cyan('Please wait while we verify and retrieve data...')); + console.log(TextStyle.BLOCK('Please wait while we verify and retrieve data...')); if ( !await SessionManager.testSession(true, null) ) { return; @@ -82,7 +82,7 @@ abstract class AssignmentPublishUnpublishCommandBase extends CommanderCommand { } { - console.log(chalk.cyan(`Please wait while we ${ this.publish ? 'publish' : 'unpublish' } the assignment...`)); + console.log(TextStyle.BLOCK(`Please wait while we ${ this.publish ? 'publish' : 'unpublish' } the assignment...`)); try { await DojoBackendManager.changeAssignmentPublishedStatus(assignment, this.publish); diff --git a/NodeApp/src/commander/assignment/subcommands/correction/subcommands/AssignmentCorrectionLinkUpdateCommand.ts b/NodeApp/src/commander/assignment/subcommands/correction/subcommands/AssignmentCorrectionLinkUpdateCommand.ts index cd7b4f8897678e3e43fe2bd6973a76f0ac61db94..6e5c6d3fd911e427dfa89eddb05d2f4278117202 100644 --- a/NodeApp/src/commander/assignment/subcommands/correction/subcommands/AssignmentCorrectionLinkUpdateCommand.ts +++ b/NodeApp/src/commander/assignment/subcommands/correction/subcommands/AssignmentCorrectionLinkUpdateCommand.ts @@ -1,9 +1,9 @@ import CommanderCommand from '../../../../CommanderCommand'; -import chalk from 'chalk'; import ora from 'ora'; import DojoBackendManager from '../../../../../managers/DojoBackendManager'; import SessionManager from '../../../../../managers/SessionManager'; import Assignment from '../../../../../sharedByClients/models/Assignment'; +import TextStyle from '../../../../../types/TextStyle'; abstract class AssignmentCorrectionLinkUpdateCommand extends CommanderCommand { @@ -22,7 +22,7 @@ abstract class AssignmentCorrectionLinkUpdateCommand extends CommanderCommand { // Check access { - console.log(chalk.cyan('Please wait while we check access...')); + console.log(TextStyle.BLOCK('Please wait while we check access...')); const assignmentGetSpinner: ora.Ora = ora('Checking if assignment exists').start(); assignment = await DojoBackendManager.getAssignment(options.assignment); @@ -51,7 +51,7 @@ abstract class AssignmentCorrectionLinkUpdateCommand extends CommanderCommand { // Link the exercise { - console.log(chalk.cyan('Please wait while we link the exercise...')); + console.log(TextStyle.BLOCK('Please wait while we link the exercise...')); await DojoBackendManager.linkUpdateCorrection(exerciseIdOrUrl, assignment, this.isUpdate); } diff --git a/NodeApp/src/commander/auth/subcommands/AuthLoginCommand.ts b/NodeApp/src/commander/auth/subcommands/AuthLoginCommand.ts index 3bc4586c766e63e8af321047cff7215b2bf692e2..615fc20ecbbb38a3f3fce7918d5af9e7a77a61ff 100644 --- a/NodeApp/src/commander/auth/subcommands/AuthLoginCommand.ts +++ b/NodeApp/src/commander/auth/subcommands/AuthLoginCommand.ts @@ -1,6 +1,6 @@ -import chalk from 'chalk'; import CommanderCommand from '../../CommanderCommand'; import SessionManager from '../../../managers/SessionManager'; +import TextStyle from '../../../types/TextStyle'; class AuthLoginCommand extends CommanderCommand { @@ -15,7 +15,7 @@ class AuthLoginCommand extends CommanderCommand { protected async commandAction(options: { cli: boolean }): Promise<void> { try { - console.log(chalk.cyan('Please wait while we login you into Dojo...')); + console.log(TextStyle.BLOCK('Please wait while we login you into Dojo...')); await SessionManager.login(options.cli); } catch ( error ) { /* empty */ } } diff --git a/NodeApp/src/commander/completion/CompletionCommand.ts b/NodeApp/src/commander/completion/CompletionCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..c24a7d6f4876f8a20812f6a07f8b5d8c82e9eacb --- /dev/null +++ b/NodeApp/src/commander/completion/CompletionCommand.ts @@ -0,0 +1,25 @@ +import CommanderCommand from '../CommanderCommand'; +import CompletionBashCommand from './subcommands/CompletionBashCommand'; +import CompletionFishCommand from './subcommands/CompletionFishCommand'; +import CompletionZshCommand from './subcommands/CompletionZshCommand'; + + +class CompletionCommand extends CommanderCommand { + protected commandName: string = 'completion'; + + protected defineCommand() { + this.command + .description('generate completions for bash, fish, or zsh'); + } + + protected defineSubCommands() { + CompletionBashCommand.registerOnCommand(this.command); + CompletionFishCommand.registerOnCommand(this.command); + CompletionZshCommand.registerOnCommand(this.command); + } + + protected async commandAction(): Promise<void> { } +} + + +export default new CompletionCommand(); \ No newline at end of file diff --git a/NodeApp/src/commander/completion/subcommands/CompletionBashCommand.ts b/NodeApp/src/commander/completion/subcommands/CompletionBashCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..a10dc0eb4ee8a805154893bbf7f762edf71279e5 --- /dev/null +++ b/NodeApp/src/commander/completion/subcommands/CompletionBashCommand.ts @@ -0,0 +1,76 @@ +import CommanderCommand from '../../CommanderCommand'; +import { generateBashCompletion, getRoot, tryRenameFile } from '../../../helpers/AutoCompletionHelper'; +import ora from 'ora'; +import TextStyle from '../../../types/TextStyle'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; + + +class CompletionBashCommand extends CommanderCommand { + protected commandName: string = 'bash'; + + private installPath = path.join(os.homedir(), '.bash_completion'); + + protected defineCommand() { + this.command + .description('generate bash completion') + .option('-f, --file <filename>') + .option('-y, --force', 'don\'t ask for file overwrite confirmation') + .action(this.commandAction.bind(this)); + } + + private writeFile(filename: string, showInstructions: boolean) { + const spinner: ora.Ora = ora(`Writing Bash completion in ${ TextStyle.CODE(filename) } ...`).start(); + + try { + fs.mkdirsSync(path.dirname(filename)); + + fs.writeFileSync(filename, generateBashCompletion(getRoot(this.command))); + + spinner.succeed(`Bash completion successfully written in ${ TextStyle.CODE(filename) }`); + if ( showInstructions ) { + console.log(` +The easiest way to install the completion is to append the content of the generated file to the end of the ${ TextStyle.CODE('~/.bash_completion') } file or to overwrite it, if it only contains the 'dojo' completion. + +This can be performed by either +${ TextStyle.CODE(` +cat ${ filename } > ~/.bash_completion # overwrites .bash_completion +cat ${ filename } >> ~/.bash_completion # appends to .bash_completion +`) } +For more details: ${ TextStyle.URL('https://github.com/scop/bash-completion/blob/master/README.md') } +`); + } + } catch ( error ) { + spinner.fail(`Bash completion error: ${ error }`); + } + } + + /* The completion command must do the following: + - if a file is provided: + - if force is not enabled: + - check if the file exists: + - if it exists, prompt the user that it will be erased + - if ok is given write the file and prompt that a backup has been created + - else create the file containing the completion + - else + - if force is not enabled: + - check if the default file exists: + - if it exists, prompt the user that it will be erased: + - if ok is given write the file and prompt that a backup has been created + - else + - create the file containing the completion + */ + protected async commandAction(options: { file: string, force: boolean }): Promise<void> { + const filePath = path.resolve(options.file ?? this.installPath); // change that if file is empty + const showInstructions = !!options.file; + if ( !(await tryRenameFile(filePath, options.force)) ) { // means renaming was interrupted + return; + } + this.writeFile(filePath, showInstructions); + } + +} + + +export default new CompletionBashCommand(); \ No newline at end of file diff --git a/NodeApp/src/commander/completion/subcommands/CompletionFishCommand.ts b/NodeApp/src/commander/completion/subcommands/CompletionFishCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..51431a58022023c1566f5092996c0c1cbf2ef219 --- /dev/null +++ b/NodeApp/src/commander/completion/subcommands/CompletionFishCommand.ts @@ -0,0 +1,70 @@ +import { generateFishCompletion, getRoot, tryRenameFile } from '../../../helpers/AutoCompletionHelper'; +import CommanderCommand from '../../CommanderCommand'; +import ora from 'ora'; +import TextStyle from '../../../types/TextStyle'; +import path from 'path'; +import os from 'os'; +import fs from 'fs-extra'; + + +class CompletionFishCommand extends CommanderCommand { + protected commandName: string = 'fish'; + + private installPath = path.join(os.homedir(), '.config/fish/completions/dojo.fish'); + + protected defineCommand() { + this.command + .description('generate fish completion') + .option('-f, --file <filename>', `filename where the bash completion will be stored`) + .option('-y, --force', 'don\'t ask for file overwrite confirmation') + .action(this.commandAction.bind(this)); + } + + private writeFile(filename: string, showInstructions: boolean) { + const spinner: ora.Ora = ora(`Writing fish completion in ${ filename }...`).start(); + try { + fs.mkdirsSync(path.dirname(filename)); + + fs.writeFileSync(filename, generateFishCompletion(getRoot(this.command))); + + spinner.succeed(`Fish completion successfully written in ${ filename }.`); + if ( showInstructions ) { + + console.log(` +The easiest way to install the completion is to copy the ${ TextStyle.CODE(filename) } into the ${ TextStyle.CODE('~/.config/fish/completions') } directory. + +${ TextStyle.CODE(` cp -i ${ filename } ~/.config/fish/completions # interactive cp to avoid accidents `) }`); + } + } catch ( error ) { + spinner.fail(`Fish completion error: ${ error }.`); + } + } + + + /* The completion command must do the following: + - if a file is provided: + - if force is not enabled: + - check if the file exists: + - if it exists, prompt the user that it will be erased + - if ok is given write the file and prompt that a backup has been created + - else create the file containing the completion + - else + - if force is not enabled: + - check if the default file exists: + - if it exists, prompt the user that it will be erased: + - if ok is given write the file and prompt that a backup has been created + - else + - create the file containing the completion + */ + protected async commandAction(options: { file: string, force: boolean }): Promise<void> { + const filePath = path.resolve(options.file ?? this.installPath); // change that if file is empty + const showInstructions = !!options.file; + if ( !(await tryRenameFile(filePath, options.force)) ) { // means renaming was interrupted + return; + } + this.writeFile(filePath, showInstructions); + } +} + + +export default new CompletionFishCommand(); \ No newline at end of file diff --git a/NodeApp/src/commander/completion/subcommands/CompletionZshCommand.ts b/NodeApp/src/commander/completion/subcommands/CompletionZshCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e277aa6cb44d77eabf44521beb7786c452f7cc0 --- /dev/null +++ b/NodeApp/src/commander/completion/subcommands/CompletionZshCommand.ts @@ -0,0 +1,135 @@ +import CommanderCommand from '../../CommanderCommand'; +import { generateBashCompletion, getRoot, tryRenameFile } from '../../../helpers/AutoCompletionHelper'; +import ora from 'ora'; +import TextStyle from '../../../types/TextStyle'; +import path from 'path'; +import { homedir } from 'os'; +import fs from 'fs-extra'; + + +class CompletionZshCommand extends CommanderCommand { + protected commandName: string = 'zsh'; + + private zprofile: string = path.join(homedir(), '.zprofile'); + private bash_completion = path.join(homedir(), '.bash_completion'); + private load_bash_completion = ` +# Added by DojoCLI +autoload -U +X compinit && compinit +autoload -U +X bashcompinit && bashcompinit +source ${ this.bash_completion } +`; + + + protected defineCommand() { + this.command + .description('generate zsh completion, which is derived from the bash completion') + .option('-f, --file <filename>', 'bash completion filename') + .option('-y, --force', 'don\'t ask for file overwrite confirmation') + .action(this.commandAction.bind(this)); + } + + private writeFile(filename: string, showInstructions: boolean) { + const spinner: ora.Ora = ora(`Writing Bash completion in ${ TextStyle.CODE(filename) } ...`).start(); + + try { + fs.mkdirsSync(path.dirname(filename)); + + fs.writeFileSync(filename, generateBashCompletion(getRoot(this.command))); + + spinner.succeed(`Bash completion successfully written in ${ TextStyle.CODE(filename) }`); + if ( showInstructions ) { + console.log(` +The easiest way to install the completion is to append the content of the generated file to the end of the ${ TextStyle.CODE(filename) } file or to overwrite it, if it only contains the 'dojo' completion. + +This can be performed by either ${ TextStyle.CODE(` +cat ${ filename } > ~/.bash_completion # overwrites .bash_completion +cat ${ filename } >> ~/.bash_completion # appends to .bash_completion`) } +For more details: ${ TextStyle.URL('https://github.com/scop/bash-completion/blob/master/README.md') } + +Next add the following lines to your ${ TextStyle.CODE(`~/.zprofile`) } file: ${ TextStyle.CODE(` +autoload -U +X compinit && compinit +autoload -U +X bashcompinit && bashcompinit +source ${ filename } +`) } `); + } + } catch ( error ) { + spinner.fail(`Bash completion writing error: ${ error }`); + } + } + + protected addToZprofile(path: string, bash_path: string) { + const spinner: ora.Ora = ora(`Modifying ${ path } ...`).start(); + if ( fs.existsSync(path) ) { + const data = fs.readFileSync(path); + let updated = false; + if ( !data.includes('autoload -U +X compinit && compinit') ) { + try { + fs.appendFileSync(path, '\nautoload -U +X compinit && compinit'); + updated = true; + } catch { + spinner.fail(`Error appending in ${ this.zprofile }`); + } + } + if ( !data.includes('autoload -U +X bashcompinit && bashcompinit') ) { + try { + fs.appendFileSync(path, '\nautoload -U +X bashcompinit && bashcompinit'); + updated = true; + } catch { + spinner.fail(`Error appending in ${ this.zprofile }`); + } + } + if ( !data.includes(`source ${ bash_path }`) ) { + try { + fs.appendFileSync(path, `\nsource ${ bash_path }`); + updated = true; + } catch { + spinner.fail(`Error appending in ${ this.zprofile }`); + } + } + if ( updated ) { + spinner.succeed(`Zsh profile updated.`); + } else { + spinner.succeed(`Zsh profile already up to date.`); + } + } else { + try { + fs.writeFileSync(path, this.load_bash_completion); + spinner.succeed(`Zsh profile written.`); + } catch ( error ) { + spinner.fail(`Error writing in ${ this.zprofile }`); + } + } + } + + /* The completion command must do the following: + - if a file is provided: + - if force is not enabled: + - check if the bash completion file exists: + - if it exists, prompt the user that it will be overwritten + - if ok is given write the file and prompt that a backup has been created + - else create the file containing the completion + - else + - if force is not enabled: + - check if the default file exists: + - if it exists, prompt the user that it will be erased: + - if ok is given write the file and prompt that a backup has been created + - else + - create the file containing the completion + - create a .zprofile or append the appropriate commands into the .zprofile file + */ + protected async commandAction(options: { file: string, force: boolean }): Promise<void> { + const filePath = path.resolve(options.file ?? this.bash_completion); // change that if file is empty + const showInstructions = !!options.file; + if ( !(await tryRenameFile(filePath, options.force)) ) { // means renaming was interrupted + return; + } + this.writeFile(filePath, showInstructions); + // Do not modify if custom file was provided + if ( !options.file ) { + this.addToZprofile(this.zprofile, filePath); + } + } +} + + +export default new CompletionZshCommand(); \ No newline at end of file diff --git a/NodeApp/src/commander/exercise/subcommands/ExerciseCorrectionCommand.ts b/NodeApp/src/commander/exercise/subcommands/ExerciseCorrectionCommand.ts index 0704ebfa5598e601f2575f80aa7fa8e24c221f02..03641466226d60d598814addff6ad1e1963a505e 100644 --- a/NodeApp/src/commander/exercise/subcommands/ExerciseCorrectionCommand.ts +++ b/NodeApp/src/commander/exercise/subcommands/ExerciseCorrectionCommand.ts @@ -6,6 +6,7 @@ import Assignment from '../../../sharedByClients/models/Assignment'; import inquirer from 'inquirer'; import open from 'open'; import chalk from 'chalk'; +import TextStyle from '../../../types/TextStyle'; type CorrectionResume = { name: string, value: string } @@ -66,7 +67,7 @@ class ExerciseCorrectionCommand extends CommanderCommand { default: false })).correctionUrl; - console.log(chalk.green(correctionUrl)); + console.log(TextStyle.URL(correctionUrl)); open(correctionUrl).then(); } diff --git a/NodeApp/src/commander/exercise/subcommands/ExerciseCreateCommand.ts b/NodeApp/src/commander/exercise/subcommands/ExerciseCreateCommand.ts index 00215301ae2cac6352adb41a152f64fcceeea91f..8f7673d0049654d3b9b394cbc9cf986ef07442c0 100644 --- a/NodeApp/src/commander/exercise/subcommands/ExerciseCreateCommand.ts +++ b/NodeApp/src/commander/exercise/subcommands/ExerciseCreateCommand.ts @@ -1,5 +1,4 @@ import CommanderCommand from '../../CommanderCommand'; -import chalk from 'chalk'; import GitlabManager from '../../../managers/GitlabManager'; import GitlabUser from '../../../shared/types/Gitlab/GitlabUser'; import ora from 'ora'; @@ -7,6 +6,7 @@ import DojoBackendManager from '../../../managers/DojoBackendManager'; import AccessesHelper from '../../../helpers/AccessesHelper'; import Assignment from '../../../sharedByClients/models/Assignment'; import Exercise from '../../../sharedByClients/models/Exercise'; +import TextStyle from '../../../types/TextStyle'; class ExerciseCreateCommand extends CommanderCommand { @@ -29,7 +29,7 @@ class ExerciseCreateCommand extends CommanderCommand { // Check access and retrieve data { - console.log(chalk.cyan('Please wait while we verify and retrieve data...')); + console.log(TextStyle.BLOCK('Please wait while we verify and retrieve data...')); if ( !await AccessesHelper.checkStudent() ) { return; @@ -65,7 +65,7 @@ class ExerciseCreateCommand extends CommanderCommand { //Create the exercise { - console.log(chalk.cyan('Please wait while we are creating the exercise (approximately 10 seconds)...')); + console.log(TextStyle.BLOCK('Please wait while we are creating the exercise (approximately 10 seconds)...')); try { exercise = await DojoBackendManager.createExercise((assignment as Assignment).name, members); @@ -77,11 +77,11 @@ class ExerciseCreateCommand extends CommanderCommand { }).start().info(); }; - oraInfo(`${ chalk.magenta('Id:') } ${ exercise.id }`); - oraInfo(`${ chalk.magenta('Name:') } ${ exercise.name }`); - oraInfo(`${ chalk.magenta('Web URL:') } ${ exercise.gitlabCreationInfo.web_url }`); - oraInfo(`${ chalk.magenta('HTTP Repo:') } ${ exercise.gitlabCreationInfo.http_url_to_repo }`); - oraInfo(`${ chalk.magenta('SSH Repo:') } ${ exercise.gitlabCreationInfo.ssh_url_to_repo }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('Id:') } ${ exercise.id }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('Name:') } ${ exercise.name }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('Web URL:') } ${ exercise.gitlabCreationInfo.web_url }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('HTTP Repo:') } ${ exercise.gitlabCreationInfo.http_url_to_repo }`); + oraInfo(`${ TextStyle.LIST_ITEM_NAME('SSH Repo:') } ${ exercise.gitlabCreationInfo.ssh_url_to_repo }`); } catch ( error ) { return; } @@ -90,7 +90,7 @@ class ExerciseCreateCommand extends CommanderCommand { // Clone the repository { if ( options.clone ) { - console.log(chalk.cyan('Please wait while we are cloning the repository...')); + console.log(TextStyle.BLOCK('Please wait while we are cloning the repository...')); await GitlabManager.cloneRepository(options.clone, exercise.gitlabCreationInfo.ssh_url_to_repo, `DojoExercise - ${ exercise.assignmentName }`, true, 0); } diff --git a/NodeApp/src/helpers/AutoCompletionHelper.ts b/NodeApp/src/helpers/AutoCompletionHelper.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0c480a82d97af2be721b88ecc7285f9c242abf8 --- /dev/null +++ b/NodeApp/src/helpers/AutoCompletionHelper.ts @@ -0,0 +1,334 @@ +import { Command } from 'commander'; +import { existsSync, renameSync } from 'fs'; +import ora from 'ora'; +import TextStyle from '../types/TextStyle'; +import inquirer from 'inquirer'; + +function renameFile(filename: string, showWarning: boolean) { + const old_filename = `${filename}.old` + const spinner: ora.Ora = ora(`Renaming ${TextStyle.CODE(filename)} in ${TextStyle.CODE(old_filename)} ...`).start(); + try { + renameSync(filename, old_filename); + spinner.succeed(`Renaming success: ${TextStyle.CODE(filename)} in ${TextStyle.CODE(old_filename)}`); + + if (showWarning) { + console.log(`${TextStyle.WARNING('Warning:')} Your ${TextStyle.CODE(filename)} was renamed ${TextStyle.CODE(old_filename)}. If this was not intended please revert this change.`); + } + } catch (error) { + spinner.fail(`Renaming failed: ${error}.`); + } +} + +async function askConfirmation(msg: string): Promise<boolean> { + return (await inquirer.prompt({ + name: 'confirm', + message: msg, + type: 'confirm', + default: false + })).confirm +} + +// Returns false, when the renaming is interrupted +export async function tryRenameFile(path: string, force: boolean): Promise<boolean> { + const fileExists = existsSync(path) + if (fileExists && force) { + renameFile(path, false) + } else if (fileExists) { + const confirm = (await askConfirmation(`${TextStyle.CODE(path)} in ${TextStyle.CODE(path + '.old')}. Are you sure?`)) + if (confirm) { + renameFile(path, true) + } else { + console.log(`${TextStyle.BLOCK('Completion generation interrupted.')}`) + return false + } + } + return true +} + +const fishFunction = ` +function __fish_dojo_using_commands + set cmd (commandline -opc) + set num_cmd (count $cmd) + if [ $num_cmd -eq $argv[1] ] + for i in (seq 1 (math $num_cmd)) + if [ $argv[(math $i+1)] != $cmd[$i] ] + return 1 + end + end + return 0 + end + return 1 +end + +complete -f -c dojo +`; + +function isHidden(cmd: Command): boolean { + return (cmd as Command & { _hidden: boolean })._hidden + +} + +function isLeaf(cmd: Command): boolean { + return cmd.commands.length == 0; +} + +function flatten(cmd: Command): Array<Command> { + if (isLeaf(cmd)) { + return [cmd]; + } else { + return cmd.commands + .filter(c => !isHidden(c)) + .map(child => flatten(child)) + .reduce((acc, cmd) => acc.concat(cmd), [cmd]); + } +} + +// Computes the maximum number of commands until a leaf is reached +function computeDepth(cmd: Command | undefined): number { + if (cmd === undefined) { + return 0; + } else { + return 1 + cmd.commands.filter(c => !isHidden(c)).map(subCmd => computeDepth(subCmd)).reduce((acc, depth) => depth > acc ? depth : acc, 0); + } +} + +// Computes the maximum number of commands until the root is reached +function computeHeight(cmd: Command | null): number { + let height = 0; + let tmp = cmd; + while (tmp !== null) { + tmp = tmp.parent; + height += 1; + } + return height; +} + +// Computes the maximum number of commands until the root is reached +export function getRoot(cmd: Command): Command { + if (cmd.parent == null) { + return cmd; + } else { + return getRoot(cmd.parent); + } +} + +function getOptions(cmd: Command): string { + // we remove <args>, [command], and , from option lines + return cmd.options.filter(opt => !opt.hidden).map(opt => opt.flags.replace(/<.*?>/, '').replace(/\[.*?\]/, '').replace(',', '').trimEnd()).join(' '); +} + +function commandsAndOptionsToString(cmd: Command): string { + return cmd.commands.filter(c => !isHidden(c)).map(c => c.name()).join(' ').concat(' ' + getOptions(cmd)).trim().concat(' --help -h').trim(); +} + +function addLine(identLevel: number, pattern: string): string { + return `${' '.repeat(identLevel)}${pattern}\n`; +} + +function generateBashSubCommands(cmd: Command, current: number, maxDepth: number, ident: number): string { + if (current == maxDepth) { + return addLine(ident, `case "\${COMP_WORDS[$COMP_CWORD - ${maxDepth - current + 1}]}" in`) + addLine(ident + 1, `${cmd.name()})`) + addLine(ident + 2, `words="${commandsAndOptionsToString(cmd)}"`) + addLine(ident + 1, ';;') + addLine(ident + 1, '*)') + addLine(ident + 1, ';;') + addLine(ident, 'esac'); + + } else { + let data = addLine(ident, `case "\${COMP_WORDS[$COMP_CWORD - ${maxDepth - current + 1}]}" in`) + addLine(ident + 1, `${cmd.name()})`); + cmd.commands.filter(c => !isHidden(c)).forEach(subCmd => { + data += generateBashSubCommands(subCmd, current + 1, maxDepth, ident + 2); + }); + data += addLine(ident + 1, ';;') + addLine(ident + 1, '*)') + addLine(ident + 1, ';;') + addLine(ident, 'esac'); + return data; + } +} + +export function generateBashCompletion(root: Command): string { + const depth = computeDepth(root); + let data = addLine(0, '#/usr/bin/env bash\nfunction _dojo_completions()') + addLine(0, '{') + addLine(1, 'latest="${COMP_WORDS[$COMP_CWORD]}"'); + for (let i = 1; i <= depth; i++) { + data += addLine(1, `${i == 1 ? 'if' : 'elif'} [ $COMP_CWORD -eq ${depth - i + 1} ]`) + addLine(1, 'then'); + data += generateBashSubCommands(root, i, depth, 2); + } + data += addLine(1, 'fi') + addLine(1, 'COMPREPLY=($(compgen -W "$words" -- $latest))') + addLine(1, 'return 0') + addLine(0, '}') + addLine(0, 'complete -F _dojo_completions dojo'); + + return data; +} + +const prefix = 'complete -f -c dojo -n \'__fish_dojo_using_commands'; + +function generateCommandChain(cmd: Command | null): string { + let data = ''; + while (cmd !== null) { + data = cmd.name().concat(` ${data}`); + cmd = cmd.parent; + } + return data.trimEnd(); +} + +function hasOptions(cmd: Command): boolean { + return cmd.options.length > 0; +} + +function optionsToString(cmd: Command): string { + return cmd.options.filter(opt => !opt.hidden).map(opt => { + return `${prefix} ${computeHeight(cmd)} ${generateCommandChain(cmd)}' -a "${opt.short ?? ''} ${opt.long ?? ''}" -d "${opt.description}"`; + }).join('\n').concat('\n'); +} + +export function generateFishCompletion(root: Command): string { + const commands = flatten(root); + + const data = fishFunction.concat(// add completions for options + commands.filter(c => !isHidden(c)).filter(cmd => hasOptions(cmd)).map(cmd => optionsToString(cmd)).filter(str => str != '').join('')).concat(// add completions for commands and subcommands + commands.filter(c => !isHidden(c)).filter(cmd => !isLeaf(cmd)).map(cmd => cmd.commands.filter(c => !isHidden(c)).map(subCmd => { + return `${prefix} ${computeHeight(cmd)} ${generateCommandChain(cmd)}' -a ${subCmd.name()} -d "${subCmd.description()}"`; + }).join('\n').concat('\n')).join('')); + + return data; +} + + +// The following code should create a bash completion automatically from the Commander +// CLI library. The file should look something like that (it looks at the time +// this comment is written). + +// #/usr/bin/env bash +// function _dojo_completions() +// { +// latest="${COMP_WORDS[$COMP_CWORD]}" +// if [ $COMP_CWORD -eq 3 ] +// then +// case "${COMP_WORDS[$COMP_CWORD - 3]}" in +// dojo) +// case "${COMP_WORDS[$COMP_CWORD - 2]}" in +// session) +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// login) +// words="-c --cli --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// logout) +// words="-f --force --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// test) +// words="--help -h" +// ;; +// *) +// ;; +// esac +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 2]}" in +// assignment) +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// create) +// words="-n --name -i --members_id -u --members_username -t --template -c --clone --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// check) +// words="-p --path -v --verbose -w --super-verbose --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// run) +// words="-p --path -v --verbose -w --super-verbose --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// publish) +// words="-f --force --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// unpublish) +// words="-f --force --help -h" +// ;; +// *) +// ;; +// esac +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 2]}" in +// exercise) +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// create) +// words="-a --assignment -i --members_id -u --members_username -c --clone --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// run) +// words="-p --path -v --verbose -w --super-verbose --help -h" +// ;; +// *) +// ;; +// esac +// ;; +// *) +// ;; +// esac +// ;; +// *) +// ;; +// esac +// elif [ $COMP_CWORD -eq 2 ] +// then +// case "${COMP_WORDS[$COMP_CWORD - 2]}" in +// dojo) +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// session) +// words="login logout test --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// assignment) +// words="create check run publish unpublish --help -h" +// ;; +// *) +// ;; +// esac +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// exercise) +// words="create run --help -h" +// ;; +// *) +// ;; +// esac +// ;; +// *) +// ;; +// esac +// elif [ $COMP_CWORD -eq 1 ] +// then +// case "${COMP_WORDS[$COMP_CWORD - 1]}" in +// dojo) +// words="session assignment exercise -V --version -H --host --help -h" +// ;; +// *) +// ;; +// esac +// fi +// COMPREPLY=($(compgen -W "$words" -- $latest)) +// return 0 +// } +// complete -F _dojo_completions dojo diff --git a/NodeApp/src/helpers/Dojo/ExerciseRunHelper.ts b/NodeApp/src/helpers/Dojo/ExerciseRunHelper.ts index 2db4ba26242ff5fbea45c80b82bbfaff1b27dfa7..07fa2f5b4de4d383c4cfd77765efe02ac8349886 100644 --- a/NodeApp/src/helpers/Dojo/ExerciseRunHelper.ts +++ b/NodeApp/src/helpers/Dojo/ExerciseRunHelper.ts @@ -14,6 +14,7 @@ import os from 'os'; import util from 'util'; import { exec } from 'child_process'; import SharedConfig from '../../shared/config/SharedConfig'; +import TextStyle from '../../types/TextStyle'; const execAsync = util.promisify(exec); @@ -50,7 +51,7 @@ class ExerciseRunHelper { // Step 1: Check requirements (if it's an exercise folder and if Docker daemon is running) { - console.log(chalk.cyan('Please wait while we are checking and creating dependencies...')); + console.log(TextStyle.BLOCK('Please wait while we are checking and creating dependencies...')); // Create result temp folder fs.mkdirSync(this.folderResultsVolume, { recursive: true }); @@ -122,7 +123,7 @@ class ExerciseRunHelper { // Step 2: Run docker-compose file { - console.log(chalk.cyan('Please wait while we are running the exercise...')); + console.log(TextStyle.BLOCK('Please wait while we are running the exercise...')); let composeFileOverride: string[] = []; const composeOverridePath: string = path.join(localExercisePath, 'docker-compose-override.yml'); @@ -211,7 +212,7 @@ class ExerciseRunHelper { // Step 3: Get results { - console.log(chalk.cyan('Please wait while we are checking the results...')); + console.log(TextStyle.BLOCK('Please wait while we are checking the results...')); exerciseResultsValidation = new ExerciseResultsSanitizerAndValidator(this.folderResultsDojo, this.folderResultsExercise, exerciseDockerCompose.exitCode); diff --git a/NodeApp/src/managers/SessionManager.ts b/NodeApp/src/managers/SessionManager.ts index b5175f8598955b32b9a1d2a4b0404e716e422926..d06fef9e44ec22ec7e89d045f066995b67606fad 100644 --- a/NodeApp/src/managers/SessionManager.ts +++ b/NodeApp/src/managers/SessionManager.ts @@ -20,6 +20,7 @@ import GitlabManager from './GitlabManager'; import GitlabToken from '../shared/types/Gitlab/GitlabToken'; import open from 'open'; import { sessionConfigFile } from '../config/ConfigFiles'; +import TextStyle from '../types/TextStyle'; class LoginServer { @@ -41,7 +42,7 @@ class LoginServer { res.writeHead(HttpStatusCode.Ok, { 'Content-Type': 'text/html' }); res.write(`<html lang="en"><body><h1 style="color: green">DojoCLI login successful</h1><h3>You can close this window.</h3></body></html>`); res.end(); - + this.events.emit('code', urlParts[1]); return; } @@ -124,9 +125,9 @@ class SessionManager { private async getGitlabCodeFromHeadlessEnvironment(): Promise<string> { const indent: string = ' '; console.log(`${ indent }Please open the following URL in your web browser and accept to give the requested permissions to Dojo:`); - console.log(chalk.blue(`${ indent }${ Config.login.gitlab.url.code }`)); + console.log(TextStyle.URL(`${ indent }${ Config.login.gitlab.url.code }`)); console.log(`${ indent }Then, copy the code at the end of the redirected url and paste it bellow.`); - console.log(`${ indent }Example of url (here the code is 123456): ${ chalk.blue(`${ SharedConfig.login.gitlab.url.redirect }?code=`) }${ chalk.green('123456') }`); + console.log(`${ indent }Example of url (here the code is 123456): ${ TextStyle.URL(`${ SharedConfig.login.gitlab.url.redirect }?code=`) }${ chalk.green('123456') }`); return (await inquirer.prompt({ type : 'password', name : 'code', diff --git a/NodeApp/src/types/TextStyle.ts b/NodeApp/src/types/TextStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7fd0031614d80f943a61dcf3a6ab99a5f1bba6f --- /dev/null +++ b/NodeApp/src/types/TextStyle.ts @@ -0,0 +1,13 @@ +import chalk from 'chalk'; + + +class TextStyle { + public readonly BLOCK = chalk.cyan; + public readonly CODE = chalk.bgHex('F7F7F7').grey; + public readonly LIST_ITEM_NAME = chalk.magenta; + public readonly URL = chalk.blue.underline; + public readonly WARNING = chalk.red; +} + + +export default new TextStyle(); \ No newline at end of file