diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b10bf0..2826626 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,34 @@ jobs: path: build/packages/ if-no-files-found: error + publish_pages: + needs: build + permissions: + pages: write + id-token: write + #if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Download packages + uses: actions/download-artifact@v3 + with: + name: packages + path: build/packages/ + - name: Generate commands + run: | + tar -xzvf build/packages/uipathcli-linux-amd64.tar.gz + ./uipath commands show > documentation/commands.json + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + with: + path: 'documentation' + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v2 + release: needs: build if: github.ref == 'refs/heads/main' diff --git a/commandline/command_builder.go b/commandline/command_builder.go index fcb85af..1c31722 100644 --- a/commandline/command_builder.go +++ b/commandline/command_builder.go @@ -682,6 +682,23 @@ func (b CommandBuilder) createConfigSetCommand() *cli.Command { } func (b CommandBuilder) loadDefinitions(args []string, version string) ([]parser.Definition, error) { + if len(args) > 1 && args[1] == "commands" { + all, err := b.DefinitionProvider.Index(version) + if err != nil { + return nil, err + } + definitions := []parser.Definition{} + for _, d := range all { + definition, err := b.DefinitionProvider.Load(d.Name, version) + if err != nil { + return nil, err + } + if definition != nil { + definitions = append(definitions, *definition) + } + } + return definitions, nil + } if len(args) <= 1 || strings.HasPrefix(args[1], "--") { return b.DefinitionProvider.Index(version) } @@ -699,6 +716,43 @@ func (b CommandBuilder) loadAutocompleteDefinitions(args []string, version strin return b.loadDefinitions(args, version) } +func (b CommandBuilder) createShowCommand(definitions []parser.Definition, commands []*cli.Command) *cli.Command { + return &cli.Command{ + Name: "commands", + Description: "Command to inspect available uipath CLI operations", + Flags: []cli.Flag{ + b.HelpFlag(), + }, + Subcommands: []*cli.Command{ + { + Name: "show", + Description: "Print available uipath CLI commands", + Flags: []cli.Flag{ + b.HelpFlag(), + }, + Action: func(context *cli.Context) error { + flagBuilder := newFlagBuilder() + flagBuilder.AddFlags(b.CreateDefaultFlags(false)) + flagBuilder.AddFlag(b.HelpFlag()) + flags := flagBuilder.ToList() + + handler := newShowCommandHandler() + output, err := handler.Execute(definitions, flags) + if err != nil { + return err + } + fmt.Fprintln(b.StdOut, output) + return nil + }, + HideHelp: true, + Hidden: true, + }, + }, + HideHelp: true, + Hidden: true, + } +} + func (b CommandBuilder) createServiceCommands(definitions []parser.Definition) []*cli.Command { commands := []*cli.Command{} for _, e := range definitions { @@ -740,7 +794,8 @@ func (b CommandBuilder) Create(args []string) ([]*cli.Command, error) { servicesCommands := b.createServiceCommands(definitions) autocompleteCommand := b.createAutoCompleteCommand(version) configCommand := b.createConfigCommand() - commands := append(servicesCommands, autocompleteCommand, configCommand) + showCommand := b.createShowCommand(definitions, servicesCommands) + commands := append(servicesCommands, autocompleteCommand, configCommand, showCommand) return commands, nil } diff --git a/commandline/definition_file_store.go b/commandline/definition_file_store.go index e12425a..9235145 100644 --- a/commandline/definition_file_store.go +++ b/commandline/definition_file_store.go @@ -56,7 +56,6 @@ func (s *DefinitionFileStore) Read(name string, version string) (*DefinitionData return nil, err } definition := NewDefinitionData(name, version, data) - s.definitions = append(s.definitions, *definition) return definition, err } } diff --git a/commandline/parameter_formatter.go b/commandline/parameter_formatter.go index 75866c3..a6b3b10 100644 --- a/commandline/parameter_formatter.go +++ b/commandline/parameter_formatter.go @@ -15,6 +15,10 @@ func (f parameterFormatter) Description() string { return f.description(f.parameter) } +func (f parameterFormatter) UsageExample() string { + return f.usageExample(f.parameter) +} + func (f parameterFormatter) description(parameter parser.Parameter) string { builder := strings.Builder{} diff --git a/commandline/show_command_handler.go b/commandline/show_command_handler.go new file mode 100644 index 0000000..625bab6 --- /dev/null +++ b/commandline/show_command_handler.go @@ -0,0 +1,177 @@ +package commandline + +import ( + "encoding/json" + "sort" + + "github.com/UiPath/uipathcli/parser" + "github.com/urfave/cli/v2" +) + +// showCommandHandler prints all available uipathcli commands +type showCommandHandler struct { +} + +type parameterJson struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` + AllowedValues []interface{} `json:"allowedValues"` + DefaultValue interface{} `json:"defaultValue"` + Example string `json:"example"` +} + +type commandJson struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters []parameterJson `json:"parameters"` + Subcommands []commandJson `json:"subcommands"` +} + +func (h showCommandHandler) Execute(definitions []parser.Definition, globalFlags []cli.Flag) (string, error) { + result := commandJson{ + Name: "uipath", + Description: "Command line interface to simplify, script and automate API calls for UiPath services", + Parameters: h.convertFlagsToCommandParameters(globalFlags), + Subcommands: h.convertDefinitionsToCommands(definitions), + } + bytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (h showCommandHandler) convertDefinitionsToCommands(definitions []parser.Definition) []commandJson { + commands := []commandJson{} + for _, d := range definitions { + command := h.convertDefinitionToCommands(d) + commands = append(commands, command) + } + return commands +} + +func (h showCommandHandler) convertDefinitionToCommands(definition parser.Definition) commandJson { + categories := map[string]commandJson{} + + for _, op := range definition.Operations { + if op.Category == nil { + command := h.convertOperationToCommand(op) + categories[command.Name] = command + } else { + h.createOrUpdateCategory(op, categories) + } + } + + commands := []commandJson{} + for _, command := range categories { + commands = append(commands, command) + } + + h.sort(commands) + for _, command := range commands { + h.sort(command.Subcommands) + } + return commandJson{ + Name: definition.Name, + Subcommands: commands, + } +} + +func (h showCommandHandler) createOrUpdateCategory(operation parser.Operation, categories map[string]commandJson) { + command, found := categories[operation.Category.Name] + if !found { + command = h.createCategoryCommand(operation) + } + command.Subcommands = append(command.Subcommands, h.convertOperationToCommand(operation)) + categories[operation.Category.Name] = command +} + +func (h showCommandHandler) createCategoryCommand(operation parser.Operation) commandJson { + return commandJson{ + Name: operation.Category.Name, + Description: operation.Category.Description, + } +} + +func (h showCommandHandler) convertOperationToCommand(operation parser.Operation) commandJson { + return commandJson{ + Name: operation.Name, + Description: operation.Description, + Parameters: h.convertParametersToCommandParameters(operation.Parameters), + } +} + +func (h showCommandHandler) convertFlagsToCommandParameters(flags []cli.Flag) []parameterJson { + result := []parameterJson{} + for _, f := range flags { + result = append(result, h.convertFlagToCommandParameter(f)) + } + return result +} + +func (h showCommandHandler) convertParametersToCommandParameters(parameters []parser.Parameter) []parameterJson { + result := []parameterJson{} + for _, p := range parameters { + result = append(result, h.convertParameterToCommandParameter(p)) + } + return result +} + +func (h showCommandHandler) convertFlagToCommandParameter(flag cli.Flag) parameterJson { + intFlag, ok := flag.(*cli.IntFlag) + if ok { + return parameterJson{ + Name: intFlag.Name, + Description: intFlag.Usage, + Type: "integer", + Required: false, + AllowedValues: []interface{}{}, + DefaultValue: intFlag.Value, + } + } + boolFlag, ok := flag.(*cli.BoolFlag) + if ok { + return parameterJson{ + Name: boolFlag.Name, + Description: boolFlag.Usage, + Type: "boolean", + Required: false, + AllowedValues: []interface{}{}, + DefaultValue: boolFlag.Value, + } + } + stringFlag := flag.(*cli.StringFlag) + return parameterJson{ + Name: stringFlag.Name, + Description: stringFlag.Usage, + Type: "string", + Required: false, + AllowedValues: []interface{}{}, + DefaultValue: stringFlag.Value, + } +} + +func (h showCommandHandler) convertParameterToCommandParameter(parameter parser.Parameter) parameterJson { + formatter := newParameterFormatter(parameter) + return parameterJson{ + Name: parameter.Name, + Description: parameter.Description, + Type: parameter.Type, + Required: parameter.Required, + AllowedValues: parameter.AllowedValues, + DefaultValue: parameter.DefaultValue, + Example: formatter.UsageExample(), + } +} + +func (h showCommandHandler) sort(commands []commandJson) { + sort.Slice(commands, func(i, j int) bool { + return commands[i].Name < commands[j].Name + }) +} + +func newShowCommandHandler() *showCommandHandler { + return &showCommandHandler{} +} diff --git a/documentation/css/main.css b/documentation/css/main.css new file mode 100644 index 0000000..7d4e7f4 --- /dev/null +++ b/documentation/css/main.css @@ -0,0 +1,174 @@ +* { + color: #273139; + font-family: noto-sans, "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", "Noto Sans TC", "Noto Sans", -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,sans-serif; + font-size: 16px; +} + +body { + margin: 0; + padding: 0; + position: relative; + min-height: 100vh; +} + +h1 { + font-size: 26px; +} + +h2 { + font-size: 20px; + border-bottom: 1px solid rgb(164, 177, 184); + width: 90%; +} + +a { + color: #0067df; + text-decoration: none; + font-weight: 600; +} + +code { + font-family: monospace; + font-size: 14px; + padding: 2px; + background-color: #f7f7f7; + border-radius: 4px; + border: 1px solid #e1e1e8; +} + +.content { + padding-bottom: 50px; +} + +.main { + margin: 20px; +} + +.header { + height: 48px; + border-bottom: 1px solid rgb(207, 216, 221); + display: flex; + flex-direction: row; +} + +.header-icon { + padding-top: 6px; + padding-left: 8px; + line-height: 48px; + vertical-align: middle; +} + +.header-text { + padding-left: 15px; + font-weight: 600; + line-height: 48px; + vertical-align: middle; +} + +.breadcrumb +{ + color: #526069; + font-size: 14px; + font-weight: 600; + display: block; +} + +.breadcrumb:after { + content: ''; + display: block; + clear: both; +} + +.breadcrumb ol { + padding-left: 0; + list-style: none; +} + +.breadcrumb li { + float: left; +} + +.breadcrumb li:after +{ + content: '/'; + padding-left: 8px; + padding-right: 8px; + display: inline; +} + +.breadcrumb li:last-child +{ + font-weight: 400; +} + +.footer { + position: absolute; + bottom: 0; + margin-top: 20px; + height: 50px; + width: 100%; + background: rgb(29, 29, 30); + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.footer-icon { + padding-left: 8px; + margin-top: 10px; +} + +.footer-text { + padding-right: 15px; + margin-top: 14px; + color: rgb(89, 90, 92); +} + +.services li { + padding-bottom: 5px; +} + +.commands li { + padding-bottom: 5px; +} + +.usage { + padding: 5px; + background-color: #f7f7f7; + border-radius: 4px; + border: 1px solid #e1e1e8; + font-family: monospace; +} + +.parameters { + padding-left: 0; + list-style: none; +} + +.parameter { + margin: 20px; +} + +.parameter-name { + padding: 2px; + background-color: #f7f7f7; + border-radius: 4px; + border: 1px solid #e1e1e8; + font-family: monospace; +} + +.parameter-description { + margin-top: 10px; + margin-bottom: 10px; + width: 100%; +} + +.parameter-allowed-values ul { + list-style-type: square; +} + +.parameter-example-code { + font-family: monospace; + font-size: 14px; + margin: 5px 0 0 20px; +} \ No newline at end of file diff --git a/documentation/favicon.ico b/documentation/favicon.ico new file mode 100644 index 0000000..13c1d6d Binary files /dev/null and b/documentation/favicon.ico differ diff --git a/documentation/index.html b/documentation/index.html new file mode 100644 index 0000000..a36f234 --- /dev/null +++ b/documentation/index.html @@ -0,0 +1,38 @@ + + + + + + uipathcli - Documentation + + + + +
+
+ + + UiPath Logo + + + + uipathcli - Documentation +
+
+
+ + + + \ No newline at end of file diff --git a/documentation/js/main.mjs b/documentation/js/main.mjs new file mode 100644 index 0000000..84b5f38 --- /dev/null +++ b/documentation/js/main.mjs @@ -0,0 +1,53 @@ +import { Template } from "./template.mjs"; + +function findCommand(command, name) { + return command.subcommands.find(c => c.name === name); +} + +function render(command, url) { + let serviceName = null; + let serviceCommand = null; + let category = null; + let categoryCommand = null; + let operation = null; + let operationCommand = null; + + const args = url.split('/'); + if (args.length >= 2) { + serviceName = args[1]; + serviceCommand = findCommand(command, serviceName); + } + if (args.length >= 3 && serviceCommand != null) { + category = args[2]; + categoryCommand = findCommand(serviceCommand, category); + } + if (args.length >= 4 && categoryCommand != null) { + operation = args[3]; + operationCommand = findCommand(categoryCommand, operation); + } + + const template = new Template(); + if (operationCommand != null) { + return template.operation(command.name, serviceName, category, operationCommand); + } + if (categoryCommand != null) { + return template.category(command.name, serviceName, categoryCommand); + } + if (serviceCommand != null) { + return template.service(command.name, serviceCommand); + } + return template.main(command); +} + +export async function main() { + const response = await fetch("commands.json"); + const command = await response.json(); + const element = document.querySelector('.main'); + + window.onhashchange = function() { + const template = render(command, window.location.hash); + element.innerHTML = template; + }; + const template = render(command, window.location.hash); + element.innerHTML = template; +} \ No newline at end of file diff --git a/documentation/js/template.mjs b/documentation/js/template.mjs new file mode 100644 index 0000000..156ac7f --- /dev/null +++ b/documentation/js/template.mjs @@ -0,0 +1,165 @@ +export class Template { + main(command) { + return ` +

${command.name}

+

Description

+
${command.description}
+

Available Services

+ +

Configuration

+
+ The CLI supports multiple ways to authorize with the UiPath services: + +
+

Client Credentials

+
+

+ In order to use client credentials, you need to set up an External Application (Confidential) and generate an application secret. +

+

Run the interactive CLI configuration:

+ uipath config --auth credentials +

+ The CLI will ask you to enter the main config settings like +

+

+

After that the CLI should be ready and you can validate that it is working by invoking one of the services (requires OR.Users.Read scope):

+ uipath orchestrator users get +
+

OAuth Login

+
+

+ In order to use oauth login, you need to set up an External Application (Non-Confidential) with a redirect url which points to your local CLI: +

+

Run the interactive CLI configuration:

+ uipath config --auth login +

+ The CLI will ask you to enter the main config settings like +

+

+

After that the CLI should be ready and you can validate that it is working by invoking one of the services:

+ uipath orchestrator users get +
+

Global Parameters

+ + `; + } + + service(executableName, command) { + return ` + +

${executableName} ${command.name}

+

Description

+
${command.description}
+

Available Commands

+ + `; + } + + category(executableName, serviceName, command) { + return ` + +

${executableName} ${serviceName} ${command.name}

+

Description

+
${command.description}
+

Available Commands

+ + `; + } + + operation(executableName, serviceName, categoryName, command) { + return ` + +

${executableName} ${serviceName} ${categoryName} ${command.name}

+

Description

+
${command.description}
+

Usage

+
+ ${executableName} ${serviceName} ${categoryName} ${command.name}
+ ${command.parameters.map(parameter => ` +   --${parameter.name} ${parameter.type}
+ `).join('')} +
+

Parameters

+ + `; + } +} \ No newline at end of file diff --git a/test/show_command_test.go b/test/show_command_test.go new file mode 100644 index 0000000..a3f2103 --- /dev/null +++ b/test/show_command_test.go @@ -0,0 +1,104 @@ +package test + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestCommandReturnedSuccessfully(t *testing.T) { + definition := ` +paths: + /ping: + get: + summary: Simple ping + operationId: ping + tags: + - health +` + context := NewContextBuilder(). + WithDefinition("myservice", definition). + Build() + + result := RunCli([]string{"commands", "show"}, context) + + command := GetCommand(t, result) + name := command["name"] + if name != "uipath" { + t.Errorf("Unexpected executable name in output, got: %v", name) + } + + serviceCommand := GetSubcommands(command)[0] + serviceName := serviceCommand["name"] + if serviceName != "myservice" { + t.Errorf("Unexpected service name in output, got: %v", serviceName) + } + + categoryCommand := GetSubcommands(serviceCommand)[0] + categoryName := categoryCommand["name"] + if categoryName != "health" { + t.Errorf("Unexpected category name in output, got: %v", categoryName) + } + + operationCommand := GetSubcommands(categoryCommand)[0] + operationName := operationCommand["name"] + if operationName != "ping" { + t.Errorf("Unexpected operation name in output, got: %v", operationName) + } +} + +func TestCommandGlobalFlags(t *testing.T) { + definition := ` +paths: + /ping: + get: + summary: Simple ping + operationId: ping + tags: + - health +` + context := NewContextBuilder(). + WithDefinition("myservice", definition). + Build() + + result := RunCli([]string{"commands", "show"}, context) + + command := GetCommand(t, result) + parameters := GetParameters(command) + + names := []string{} + for _, parameter := range parameters { + names = append(names, parameter["name"].(string)) + } + + expectedNames := []string{"debug", "profile", "uri", "organization", "tenant", "insecure", "output", "query", "wait", "wait-timeout", "file", "version", "help"} + if !reflect.DeepEqual(names, expectedNames) { + t.Errorf("Unexpected global parameters in output, expected: %v but got: %v", expectedNames, names) + } +} + +func GetCommand(t *testing.T, result Result) map[string]interface{} { + command := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &command) + if err != nil { + t.Errorf("Failed to deserialize show commands result %v", err) + } + return command +} + +func GetSubcommands(command map[string]interface{}) []map[string]interface{} { + return GetArray(command, "subcommands") +} + +func GetParameters(command map[string]interface{}) []map[string]interface{} { + return GetArray(command, "parameters") +} + +func GetArray(section map[string]interface{}, name string) []map[string]interface{} { + array := section[name].([]interface{}) + result := []map[string]interface{}{} + for _, item := range array { + result = append(result, item.(map[string]interface{})) + } + return result +}