diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0aab6fe..9574ed9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI on: [push] env: - UIPATHCLI_BASE_VERSION: "v1.1" + UIPATHCLI_BASE_VERSION: "v2.0" GO_VERSION: "1.22.2" jobs: diff --git a/auth/browser_launcher.go b/auth/browser_launcher.go index 7698c7c..f60aece 100644 --- a/auth/browser_launcher.go +++ b/auth/browser_launcher.go @@ -1,6 +1,36 @@ package auth -// BrowserLauncher interface for opening browser windows. -type BrowserLauncher interface { - Open(url string) error +import ( + "fmt" + "time" + + "github.com/UiPath/uipathcli/utils" +) + +// BrowserLauncher tries to open the default browser on the local system. +type BrowserLauncher struct { + Exec utils.ExecProcess +} + +func (l BrowserLauncher) Open(url string) error { + cmd := l.openBrowser(url) + err := cmd.Start() + if err != nil { + return err + } + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + return fmt.Errorf("Timed out waiting for browser to start") + } +} + +func NewBrowserLauncher() *BrowserLauncher { + return &BrowserLauncher{utils.NewExecProcess()} } diff --git a/auth/browser_launcher_darwin.go b/auth/browser_launcher_darwin.go new file mode 100644 index 0000000..013ef5e --- /dev/null +++ b/auth/browser_launcher_darwin.go @@ -0,0 +1,11 @@ +//go:build darwin + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("open", url) +} diff --git a/auth/browser_launcher_linux.go b/auth/browser_launcher_linux.go new file mode 100644 index 0000000..03d7160 --- /dev/null +++ b/auth/browser_launcher_linux.go @@ -0,0 +1,11 @@ +//go:build linux + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("xdg-open", url) +} diff --git a/auth/browser_launcher_windows.go b/auth/browser_launcher_windows.go new file mode 100644 index 0000000..45ab3b2 --- /dev/null +++ b/auth/browser_launcher_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("rundll32", "url.dll,FileProtocolHandler", url) +} diff --git a/auth/exec_browser_launcher.go b/auth/exec_browser_launcher.go deleted file mode 100644 index e949029..0000000 --- a/auth/exec_browser_launcher.go +++ /dev/null @@ -1,47 +0,0 @@ -package auth - -import ( - "fmt" - "os/exec" - "runtime" - "time" -) - -// ExecBrowserLauncher is the default implementation for the browser launcher which -// tries to open the default browser on the local system. -type ExecBrowserLauncher struct{} - -func (l ExecBrowserLauncher) Open(url string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "darwin": - cmd = exec.Command("open", url) - default: - return fmt.Errorf("Platform not supported: %s", runtime.GOOS) - } - - err := cmd.Start() - if err != nil { - return err - } - done := make(chan error) - go func() { - done <- cmd.Wait() - }() - - select { - case err := <-done: - return err - case <-time.After(5 * time.Second): - return fmt.Errorf("Timed out waiting for browser to start") - } -} - -func NewExecBrowserLauncher() *ExecBrowserLauncher { - return &ExecBrowserLauncher{} -} diff --git a/auth/oauth_authenticator_test.go b/auth/oauth_authenticator_test.go index 7a9563f..36a3961 100644 --- a/auth/oauth_authenticator_test.go +++ b/auth/oauth_authenticator_test.go @@ -11,10 +11,12 @@ import ( "net/http" "net/http/httptest" "net/url" + "runtime" "strings" "testing" "github.com/UiPath/uipathcli/cache" + "github.com/UiPath/uipathcli/utils" ) func TestOAuthAuthenticatorNotEnabled(t *testing.T) { @@ -25,7 +27,7 @@ func TestOAuthAuthenticatorNotEnabled(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", map[string]string{}) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "" { t.Errorf("Expected no error when oauth flow is skipped, but got: %v", result.Error) @@ -46,7 +48,7 @@ func TestOAuthAuthenticatorPreservesExistingHeaders(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", headers) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "" { t.Errorf("Expected no error when oauth flow is skipped, but got: %v", result.Error) @@ -65,7 +67,7 @@ func TestOAuthAuthenticatorInvalidConfig(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", map[string]string{}) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "Invalid oauth authenticator configuration: Invalid value for clientId: '1'" { t.Errorf("Expected error with invalid config, but got: %v", result.Error) @@ -122,7 +124,7 @@ func TestOAuthFlowIsCached(t *testing.T) { performLogin(loginUrl, t) <-resultChannel - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(context) if result.Error != "" { @@ -208,8 +210,15 @@ func TestMissingCodeShowsErrorMessage(t *testing.T) { func callAuthenticator(context AuthenticatorContext) (url.URL, chan AuthenticatorResult) { loginChan := make(chan string) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), NoOpBrowserLauncher{ - loginUrlChannel: loginChan, + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), BrowserLauncher{ + Exec: utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + switch runtime.GOOS { + case "windows": + loginChan <- args[1] + default: + loginChan <- args[0] + } + }), }) resultChannel := make(chan AuthenticatorResult) @@ -323,12 +332,3 @@ func (i identityServerFake) writeValidationErrorResponse(response http.ResponseW response.WriteHeader(400) _, _ = response.Write([]byte(message)) } - -type NoOpBrowserLauncher struct { - loginUrlChannel chan string -} - -func (l NoOpBrowserLauncher) Open(url string) error { - l.loginUrlChannel <- url - return nil -} diff --git a/cache/file_cache.go b/cache/file_cache.go index 54cbc30..00a8822 100644 --- a/cache/file_cache.go +++ b/cache/file_cache.go @@ -9,11 +9,11 @@ import ( "strconv" "strings" "time" + + "github.com/UiPath/uipathcli/utils" ) const cacheFilePermissions = 0600 -const cacheDirectoryPermissions = 0700 -const cacheDirectory = "uipath" const separator = "|" // The FileCache stores data on disk in order to preserve them across @@ -63,15 +63,12 @@ func (c FileCache) readValue(key string) (int64, string, error) { } func (c FileCache) cacheFilePath(key string) (string, error) { - userCacheDirectory, err := os.UserCacheDir() + cacheDirectory, err := utils.Directories{}.Cache() if err != nil { return "", err } - cacheDirectory := filepath.Join(userCacheDirectory, cacheDirectory) - _ = os.MkdirAll(cacheDirectory, cacheDirectoryPermissions) - hash := sha256.Sum256([]byte(key)) - fileName := fmt.Sprintf("%x.cache", hash) + fileName := fmt.Sprintf("%x", hash) return filepath.Join(cacheDirectory, fileName), nil } diff --git a/definitions/studio.yaml b/definitions/studio.yaml new file mode 100644 index 0000000..6c742bc --- /dev/null +++ b/definitions/studio.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/{organization}/studio_/backend + description: The production url + variables: + organization: + description: The organization name (or id) + default: my-org +paths: + {} \ No newline at end of file diff --git a/executor/http_executor.go b/executor/http_executor.go index bba736a..536f765 100644 --- a/executor/http_executor.go +++ b/executor/http_executor.go @@ -169,7 +169,7 @@ func (e HttpExecutor) progressReader(text string, completedText string, reader i if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } diff --git a/log/debug_logger.go b/log/debug_logger.go index e72aea5..05963c5 100644 --- a/log/debug_logger.go +++ b/log/debug_logger.go @@ -47,6 +47,10 @@ func (l DebugLogger) LogResponse(response ResponseInfo) { fmt.Fprint(l.writer, "\n\n\n") } +func (l DebugLogger) Log(message string) { + fmt.Fprint(l.writer, message) +} + func (l DebugLogger) LogError(message string) { fmt.Fprint(l.writer, message) } diff --git a/log/default_logger.go b/log/default_logger.go index 45c08e1..c14a2d7 100644 --- a/log/default_logger.go +++ b/log/default_logger.go @@ -18,6 +18,9 @@ func (l *DefaultLogger) LogRequest(request RequestInfo) { func (l DefaultLogger) LogResponse(response ResponseInfo) { } +func (l DefaultLogger) Log(message string) { +} + func (l DefaultLogger) LogError(message string) { fmt.Fprint(l.writer, message) } diff --git a/log/logger.go b/log/logger.go index de132df..a65e75f 100644 --- a/log/logger.go +++ b/log/logger.go @@ -8,6 +8,7 @@ package log // The Logger interface which is used to provide additional information to the // user about what operations the CLI is performing. type Logger interface { + Log(message string) LogError(message string) LogRequest(request RequestInfo) LogResponse(response ResponseInfo) diff --git a/main.go b/main.go index 17153b4..7bcdfae 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/UiPath/uipathcli/plugin" plugin_digitizer "github.com/UiPath/uipathcli/plugin/digitizer" plugin_orchestrator "github.com/UiPath/uipathcli/plugin/orchestrator" + plugin_studio "github.com/UiPath/uipathcli/plugin/studio" "github.com/UiPath/uipathcli/utils" ) @@ -27,7 +28,7 @@ var embedded embed.FS func authenticators() []auth.Authenticator { return []auth.Authenticator{ auth.NewPatAuthenticator(), - auth.NewOAuthAuthenticator(cache.NewFileCache(), auth.NewExecBrowserLauncher()), + auth.NewOAuthAuthenticator(cache.NewFileCache(), *auth.NewBrowserLauncher()), auth.NewBearerAuthenticator(cache.NewFileCache()), } } @@ -63,9 +64,11 @@ func main() { commandline.NewDefinitionFileStore(os.Getenv("UIPATH_DEFINITIONS_PATH"), embedded), parser.NewOpenApiParser(), []plugin.CommandPlugin{ - plugin_digitizer.DigitizeCommand{}, - plugin_orchestrator.UploadCommand{}, - plugin_orchestrator.DownloadCommand{}, + plugin_digitizer.NewDigitizeCommand(), + plugin_orchestrator.NewUploadCommand(), + plugin_orchestrator.NewDownloadCommand(), + plugin_studio.NewPackagePackCommand(), + plugin_studio.NewPackageAnalyzeCommand(), }, ), *configProvider, diff --git a/plugin/digitizer/digitize_command.go b/plugin/digitizer/digitize_command.go index 6b847be..c0495dd 100644 --- a/plugin/digitizer/digitize_command.go +++ b/plugin/digitizer/digitize_command.go @@ -129,12 +129,9 @@ func (c DigitizeCommand) createDigitizeRequest(context plugin.ExecutionContext, var err error file := context.Input if file == nil { - file, err = c.getFileParameter(context.Parameters) - if err != nil { - return nil, err - } + file = c.getFileParameter(context.Parameters) } - contentType, _ := c.getParameter("content-type", context.Parameters) + contentType := c.getParameter("content-type", context.Parameters) if contentType == "" { contentType = "application/octet-stream" } @@ -164,7 +161,7 @@ func (c DigitizeCommand) progressReader(text string, completedText string, reade if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -262,33 +259,37 @@ func (c DigitizeCommand) sendRequest(request *http.Request, insecure bool) (*htt } func (c DigitizeCommand) getProjectId(parameters []plugin.ExecutionParameter) string { - projectId, _ := c.getParameter("project-id", parameters) + projectId := c.getParameter("project-id", parameters) if projectId == "" { projectId = "00000000-0000-0000-0000-000000000000" } return projectId } -func (c DigitizeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c DigitizeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c DigitizeCommand) getFileParameter(parameters []plugin.ExecutionParameter) (utils.Stream, error) { +func (c DigitizeCommand) getFileParameter(parameters []plugin.ExecutionParameter) utils.Stream { + var result utils.Stream for _, p := range parameters { if p.Name == "file" { if stream, ok := p.Value.(utils.Stream); ok { - return stream, nil + result = stream + break } } } - return nil, fmt.Errorf("Could not find 'file' parameter") + return result } func (c DigitizeCommand) logRequest(logger log.Logger, request *http.Request) { @@ -304,3 +305,7 @@ func (c DigitizeCommand) logResponse(logger log.Logger, response *http.Response, responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewDigitizeCommand() *DigitizeCommand { + return &DigitizeCommand{} +} diff --git a/plugin/digitizer/digitizer_plugin_test.go b/plugin/digitizer/digitizer_plugin_test.go index 37d55ff..b4f3512 100644 --- a/plugin/digitizer/digitizer_plugin_test.go +++ b/plugin/digitizer/digitizer_plugin_test.go @@ -20,7 +20,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234"}, context) @@ -47,7 +47,7 @@ paths: context := test.NewContextBuilder(). WithConfig(config). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234", "--file", "does-not-exist"}, context) @@ -67,7 +67,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234", "--file", "hello-world"}, context) @@ -87,7 +87,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--organization", "myorg", "--project-id", "1234", "--file", "hello-world"}, context) @@ -117,7 +117,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(400, "validation error"). Build() @@ -148,7 +148,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"04908673-2b65-4647-8ab3-dde8a3aa7885"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/04908673-2b65-4647-8ab3-dde8a3aa7885?api-version=1", 400, `validation error`). Build() @@ -190,7 +190,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"648ea1c2-7dbe-42a8-b112-6474d07e61c1"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/00000000-0000-0000-0000-000000000000/digitization/result/648ea1c2-7dbe-42a8-b112-6474d07e61c1?api-version=1", 200, `{"status":"Done"}`). Build() @@ -236,7 +236,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). Build() @@ -272,7 +272,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"pages":[],"status":"Done"}`). Build() @@ -313,7 +313,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithStdIn(stdIn). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). @@ -360,7 +360,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). Build() diff --git a/plugin/external_plugin.go b/plugin/external_plugin.go new file mode 100644 index 0000000..35d2a8c --- /dev/null +++ b/plugin/external_plugin.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "math" + "math/big" + "net/http" + "os" + "path/filepath" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/utils" +) + +const pluginDirectoryPermissions = 0700 + +type ExternalPlugin struct { + Logger log.Logger +} + +func (p ExternalPlugin) GetTool(name string, url string, executable string) (string, error) { + pluginDirectory, err := p.pluginDirectory(name, url) + if err != nil { + return "", fmt.Errorf("Could not download %s: %v", name, err) + } + path := filepath.Join(pluginDirectory, executable) + if _, err := os.Stat(path); err == nil { + return path, nil + } + + tmpPluginDirectory := pluginDirectory + "-" + p.randomFolderName() + _ = os.MkdirAll(tmpPluginDirectory, pluginDirectoryPermissions) + + progressBar := utils.NewProgressBar(p.Logger) + defer progressBar.Remove() + progressBar.UpdatePercentage("downloading...", 0) + zipArchivePath := filepath.Join(tmpPluginDirectory, name) + err = p.download(name, url, zipArchivePath, progressBar) + if err != nil { + return "", err + } + err = newZipArchive().Extract(zipArchivePath, tmpPluginDirectory, pluginDirectoryPermissions) + if err != nil { + return "", fmt.Errorf("Could not extract %s archive: %v", name, err) + } + os.Remove(zipArchivePath) + err = os.Rename(tmpPluginDirectory, pluginDirectory) + if err != nil { + return "", fmt.Errorf("Could not install %s: %v", name, err) + } + return path, nil +} + +func (p ExternalPlugin) download(name string, url string, destination string, progressBar *utils.ProgressBar) error { + out, err := os.Create(destination) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + defer out.Close() + + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + downloadReader := p.progressReader("downloading...", "installing... ", response.Body, response.ContentLength, progressBar) + _, err = io.Copy(out, downloadReader) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + return nil +} + +func (p ExternalPlugin) progressReader(text string, completedText string, reader io.Reader, length int64, progressBar *utils.ProgressBar) io.Reader { + progressReader := utils.NewProgressReader(reader, func(progress utils.Progress) { + displayText := text + if progress.Completed { + displayText = completedText + } + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) + }) + return progressReader +} + +func (p ExternalPlugin) pluginDirectory(name string, url string) (string, error) { + pluginDirectory, err := utils.Directories{}.Plugin() + if err != nil { + return "", err + } + hash := sha256.Sum256([]byte(url)) + subdirectory := fmt.Sprintf("%s-%x", name, hash) + return filepath.Join(pluginDirectory, subdirectory), nil +} + +func (p ExternalPlugin) randomFolderName() string { + value, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + return value.String() +} + +func NewExternalPlugin(logger log.Logger) *ExternalPlugin { + return &ExternalPlugin{logger} +} diff --git a/plugin/orchestrator/download_command.go b/plugin/orchestrator/download_command.go index c0dfb6c..7818114 100644 --- a/plugin/orchestrator/download_command.go +++ b/plugin/orchestrator/download_command.go @@ -77,7 +77,7 @@ func (c DownloadCommand) progressReader(text string, completedText string, reade if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -119,18 +119,9 @@ func (c DownloadCommand) createReadUrlRequest(context plugin.ExecutionContext) ( if context.Tenant == "" { return nil, errors.New("Tenant is not set") } - folderId, err := c.getIntParameter("folder-id", context.Parameters) - if err != nil { - return nil, err - } - bucketId, err := c.getIntParameter("key", context.Parameters) - if err != nil { - return nil, err - } - path, err := c.getStringParameter("path", context.Parameters) - if err != nil { - return nil, err - } + folderId := c.getIntParameter("folder-id", context.Parameters) + bucketId := c.getIntParameter("key", context.Parameters) + path := c.getStringParameter("path", context.Parameters) uri := c.formatUri(context.BaseUri, context.Organization, context.Tenant) + fmt.Sprintf("/odata/Buckets(%d)/UiPath.Server.Configuration.OData.GetReadUri?path=%s", bucketId, path) request, err := http.NewRequest("GET", uri, &bytes.Buffer{}) @@ -182,26 +173,30 @@ func (c DownloadCommand) sendRequest(request *http.Request, insecure bool) (*htt return client.Do(request) } -func (c DownloadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c DownloadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c DownloadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) (int, error) { +func (c DownloadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) int { + result := 0 for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(int); ok { - return data, nil + result = data + break } } } - return 0, fmt.Errorf("Could not find '%s' parameter", name) + return result } func (c DownloadCommand) logRequest(logger log.Logger, request *http.Request) { @@ -217,3 +212,7 @@ func (c DownloadCommand) logResponse(logger log.Logger, response *http.Response, responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewDownloadCommand() *DownloadCommand { + return &DownloadCommand{} +} diff --git a/plugin/orchestrator/orchestrator_plugin_test.go b/plugin/orchestrator/orchestrator_plugin_test.go index 6fcd320..d4d3a99 100644 --- a/plugin/orchestrator/orchestrator_plugin_test.go +++ b/plugin/orchestrator/orchestrator_plugin_test.go @@ -16,7 +16,7 @@ import ( func TestUploadWithoutFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--key", "2", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -29,7 +29,7 @@ func TestUploadWithoutFolderIdParameterShowsValidationError(t *testing.T) { func TestUploadWithoutKeyParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -42,7 +42,7 @@ func TestUploadWithoutKeyParameterShowsValidationError(t *testing.T) { func TestUploadWithInvalidFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "invalid", "--key", "2", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -55,7 +55,7 @@ func TestUploadWithInvalidFolderIdParameterShowsValidationError(t *testing.T) { func TestUploadWithoutPathParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--file", "does-not-exist"}, context) @@ -68,7 +68,7 @@ func TestUploadWithoutPathParameterShowsValidationError(t *testing.T) { func TestUploadWithoutFileParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -88,7 +88,7 @@ func TestUploadFileDoesNotExistShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithConfig(config). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"http://localhost"}`). Build() @@ -102,7 +102,7 @@ func TestUploadFileDoesNotExistShowsValidationError(t *testing.T) { func TestUploadWithoutOrganizationShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--path", "file.txt", "--file", "hello-world"}, context) @@ -115,7 +115,7 @@ func TestUploadWithoutOrganizationShowsValidationError(t *testing.T) { func TestUploadWithoutTenantShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--organization", "myorg", "--folder-id", "1", "--key", "2", "--path", "file.txt", "--file", "hello-world"}, context) @@ -138,7 +138,7 @@ func TestUploadWithFailedResponseReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). WithConfig(config). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(400, "validation error"). Build() @@ -197,7 +197,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). WithConfig(config). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -242,7 +242,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -277,7 +277,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+"/upload/file.txt"+`"}`). Build() @@ -294,7 +294,7 @@ servers: func TestDownloadWithoutFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--key", "2", "--path", "file.txt"}, context) @@ -307,7 +307,7 @@ func TestDownloadWithoutFolderIdParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutKeyParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--path", "file.txt"}, context) @@ -320,7 +320,7 @@ func TestDownloadWithoutKeyParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutPathParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--key", "2"}, context) @@ -333,7 +333,7 @@ func TestDownloadWithoutPathParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutOrganizationShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -346,7 +346,7 @@ func TestDownloadWithoutOrganizationShowsValidationError(t *testing.T) { func TestDownloadWithoutTenantShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--organization", "myorg", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -366,7 +366,7 @@ func TestDownloadWithFailedResponseReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). WithConfig(config). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(400, "validation error"). Build() @@ -411,7 +411,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). WithConfig(config). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -452,7 +452,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -485,7 +485,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`/download/file.txt"}`). Build() diff --git a/plugin/orchestrator/upload_command.go b/plugin/orchestrator/upload_command.go index 10f587b..ba5bb36 100644 --- a/plugin/orchestrator/upload_command.go +++ b/plugin/orchestrator/upload_command.go @@ -70,11 +70,7 @@ func (c UploadCommand) upload(context plugin.ExecutionContext, logger log.Logger func (c UploadCommand) createUploadRequest(context plugin.ExecutionContext, url string, uploadBar *utils.ProgressBar, requestError chan error) (*http.Request, error) { file := context.Input if file == nil { - var err error - file, err = c.getFileParameter(context.Parameters) - if err != nil { - return nil, err - } + file = c.getFileParameter(context.Parameters) } bodyReader, bodyWriter := io.Pipe() contentType, contentLength := c.writeBody(bodyWriter, file, requestError) @@ -118,7 +114,7 @@ func (c UploadCommand) progressReader(text string, completedText string, reader if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -160,18 +156,9 @@ func (c UploadCommand) createWriteUrlRequest(context plugin.ExecutionContext) (* if context.Tenant == "" { return nil, errors.New("Tenant is not set") } - folderId, err := c.getIntParameter("folder-id", context.Parameters) - if err != nil { - return nil, err - } - bucketId, err := c.getIntParameter("key", context.Parameters) - if err != nil { - return nil, err - } - path, err := c.getStringParameter("path", context.Parameters) - if err != nil { - return nil, err - } + folderId := c.getIntParameter("folder-id", context.Parameters) + bucketId := c.getIntParameter("key", context.Parameters) + path := c.getStringParameter("path", context.Parameters) uri := c.formatUri(context.BaseUri, context.Organization, context.Tenant) + fmt.Sprintf("/odata/Buckets(%d)/UiPath.Server.Configuration.OData.GetWriteUri?path=%s", bucketId, path) request, err := http.NewRequest("GET", uri, &bytes.Buffer{}) @@ -223,37 +210,43 @@ func (c UploadCommand) sendRequest(request *http.Request, insecure bool) (*http. return client.Do(request) } -func (c UploadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c UploadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c UploadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) (int, error) { +func (c UploadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) int { + result := 0 for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(int); ok { - return data, nil + result = data + break } } } - return 0, fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c UploadCommand) getFileParameter(parameters []plugin.ExecutionParameter) (utils.Stream, error) { +func (c UploadCommand) getFileParameter(parameters []plugin.ExecutionParameter) utils.Stream { + var result utils.Stream for _, p := range parameters { if p.Name == "file" { if stream, ok := p.Value.(utils.Stream); ok { - return stream, nil + result = stream + break } } } - return nil, fmt.Errorf("Could not find 'file' parameter") + return result } func (c UploadCommand) logRequest(logger log.Logger, request *http.Request) { @@ -269,3 +262,7 @@ func (c UploadCommand) logResponse(logger log.Logger, response *http.Response, b responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewUploadCommand() *UploadCommand { + return &UploadCommand{} +} diff --git a/plugin/studio/analyze_result_json.go b/plugin/studio/analyze_result_json.go new file mode 100644 index 0000000..e034383 --- /dev/null +++ b/plugin/studio/analyze_result_json.go @@ -0,0 +1,25 @@ +package studio + +type analyzeResultJson []struct { + ErrorCode string `json:"ErrorCode"` + Description string `json:"Description"` + RuleName string `json:"RuleName"` + FilePath string `json:"FilePath"` + ActivityId *analyzeResultActivityId `json:"ActivityId"` + ActivityDisplayName string `json:"ActivityDisplayName"` + WorkflowDisplayName string `json:"WorkflowDisplayName"` + Item *analyzeResultItem `json:"Item"` + ErrorSeverity int `json:"ErrorSeverity"` + Recommendation string `json:"Recommendation"` + DocumentationLink string `json:"DocumentationLink"` +} + +type analyzeResultActivityId struct { + Id string `json:"Id"` + IdRef string `json:"IdRef"` +} + +type analyzeResultItem struct { + Name string `json:"Name"` + Type int `json:"Type"` +} diff --git a/plugin/studio/package_analyze_command.go b/plugin/studio/package_analyze_command.go new file mode 100644 index 0000000..5aef3da --- /dev/null +++ b/plugin/studio/package_analyze_command.go @@ -0,0 +1,288 @@ +package studio + +import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +// The PackageAnalyzeCommand runs static code analyis on the project to detect common errors. +type PackageAnalyzeCommand struct { + Exec utils.ExecProcess +} + +func (c PackageAnalyzeCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("package", "Package", "UiPath Studio package-related actions"). + WithOperation("analyze", "Analyze Project", "Runs static code analysis on the project to detect common errors"). + WithParameter("source", plugin.ParameterTypeString, "Path to a project.json file or a folder containing project.json file", true). + WithParameter("treat-warnings-as-errors", plugin.ParameterTypeBoolean, "Treat warnings as errors", false). + WithParameter("stop-on-rule-violation", plugin.ParameterTypeBoolean, "Fail when any rule is violated", false) +} + +func (c PackageAnalyzeCommand) Execute(context plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source, err := c.getSource(context) + if err != nil { + return err + } + treatWarningsAsErrors := c.getBoolParameter("treat-warnings-as-errors", context.Parameters) + stopOnRuleViolation := c.getBoolParameter("stop-on-rule-violation", context.Parameters) + exitCode, result, err := c.execute(source, treatWarningsAsErrors, stopOnRuleViolation, context.Debug, logger) + if err != nil { + return err + } + + json, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("analyze command failed: %v", err) + } + err = writer.WriteResponse(*output.NewResponseInfo(200, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(json))) + if err != nil { + return err + } + if exitCode != 0 { + return errors.New("") + } + return nil +} + +func (c PackageAnalyzeCommand) execute(source string, treatWarningsAsErrors bool, stopOnRuleViolation bool, debug bool, logger log.Logger) (int, *packageAnalyzeResult, error) { + if !debug { + bar := c.newAnalyzingProgressBar(logger) + defer close(bar) + } + + jsonResultFilePath, err := c.getTemporaryJsonResultFilePath() + if err != nil { + return 1, nil, err + } + defer os.Remove(jsonResultFilePath) + + args := []string{"package", "analyze", source, "--resultPath", jsonResultFilePath} + if treatWarningsAsErrors { + args = append(args, "--treatWarningsAsErrors") + } + if stopOnRuleViolation { + args = append(args, "--stopOnRuleViolation") + } + + projectReader := newStudioProjectReader(source) + + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(projectReader.GetTargetFramework(), args...) + if err != nil { + return 1, nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + defer stdout.Close() + stderr, err := cmd.StderrPipe() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + defer stderr.Close() + err = cmd.Start() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + + stderrOutputBuilder := new(strings.Builder) + stderrReader := io.TeeReader(stderr, stderrOutputBuilder) + + var wg sync.WaitGroup + wg.Add(3) + go c.readOutput(stdout, logger, &wg) + go c.readOutput(stderrReader, logger, &wg) + go c.wait(cmd, &wg) + wg.Wait() + + violations, err := c.readAnalyzeResult(jsonResultFilePath) + if err != nil { + return 1, nil, err + } + + exitCode := cmd.ExitCode() + var result *packageAnalyzeResult + if exitCode == 0 { + result = newSucceededPackageAnalyzeResult(violations) + } else { + result = newFailedPackageAnalyzeResult( + violations, + stderrOutputBuilder.String(), + ) + } + return exitCode, result, nil +} + +func (c PackageAnalyzeCommand) getTemporaryJsonResultFilePath() (string, error) { + tempDirectory, err := utils.Directories{}.Temp() + if err != nil { + return "", err + } + fileName := c.randomJsonResultFileName() + return filepath.Join(tempDirectory, fileName), nil +} + +func (c PackageAnalyzeCommand) randomJsonResultFileName() string { + value, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + return "analyzeresult-" + value.String() + ".json" +} + +func (c PackageAnalyzeCommand) readAnalyzeResult(path string) ([]packageAnalyzeViolation, error) { + file, err := os.Open(path) + if err != nil && errors.Is(err, os.ErrNotExist) { + return []packageAnalyzeViolation{}, nil + } + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error reading %s file: %v", filepath.Base(path), err) + } + defer file.Close() + byteValue, err := io.ReadAll(file) + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error reading %s file: %v", filepath.Base(path), err) + } + + var result analyzeResultJson + err = json.Unmarshal(byteValue, &result) + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error parsing %s file: %v", filepath.Base(path), err) + } + return c.convertToViolations(result), nil +} + +func (c PackageAnalyzeCommand) convertToViolations(json analyzeResultJson) []packageAnalyzeViolation { + violations := []packageAnalyzeViolation{} + for _, entry := range json { + var activityId *packageAnalyzeActivityId + if entry.ActivityId != nil { + activityId = &packageAnalyzeActivityId{ + Id: entry.ActivityId.Id, + IdRef: entry.ActivityId.IdRef, + } + } + var item *packageAnalyzeItem + if entry.Item != nil { + item = &packageAnalyzeItem{ + Name: entry.Item.Name, + Type: entry.Item.Type, + } + } + violation := packageAnalyzeViolation{ + ErrorCode: entry.ErrorCode, + Description: entry.Description, + RuleName: entry.RuleName, + FilePath: entry.FilePath, + ActivityDisplayName: entry.ActivityDisplayName, + WorkflowDisplayName: entry.WorkflowDisplayName, + ErrorSeverity: entry.ErrorSeverity, + Recommendation: entry.Recommendation, + DocumentationLink: entry.DocumentationLink, + ActivityId: activityId, + Item: item, + } + violations = append(violations, violation) + } + return violations +} + +func (c PackageAnalyzeCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { + defer wg.Done() + _ = cmd.Wait() +} + +func (c PackageAnalyzeCommand) newAnalyzingProgressBar(logger log.Logger) chan struct{} { + progressBar := utils.NewProgressBar(logger) + ticker := time.NewTicker(10 * time.Millisecond) + cancel := make(chan struct{}) + var percent float64 = 0 + go func() { + for { + select { + case <-ticker.C: + progressBar.UpdatePercentage("analyzing... ", percent) + percent = percent + 1 + if percent > 100 { + percent = 0 + } + case <-cancel: + ticker.Stop() + progressBar.Remove() + return + } + } + }() + return cancel +} + +func (c PackageAnalyzeCommand) getSource(context plugin.ExecutionContext) (string, error) { + source := c.getParameter("source", context.Parameters) + if source == "" { + return "", errors.New("source is not set") + } + source, _ = filepath.Abs(source) + fileInfo, err := os.Stat(source) + if err != nil { + return "", fmt.Errorf("%s not found", defaultProjectJson) + } + if fileInfo.IsDir() { + source = filepath.Join(source, defaultProjectJson) + } + return source, nil +} + +func (c PackageAnalyzeCommand) readOutput(output io.Reader, logger log.Logger, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(output) + scanner.Split(bufio.ScanRunes) + for scanner.Scan() { + logger.Log(scanner.Text()) + } +} + +func (c PackageAnalyzeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func (c PackageAnalyzeCommand) getBoolParameter(name string, parameters []plugin.ExecutionParameter) bool { + result := false + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(bool); ok { + result = data + break + } + } + } + return result +} + +func NewPackageAnalyzeCommand() *PackageAnalyzeCommand { + return &PackageAnalyzeCommand{utils.NewExecProcess()} +} diff --git a/plugin/studio/package_analyze_result.go b/plugin/studio/package_analyze_result.go new file mode 100644 index 0000000..5a58dc2 --- /dev/null +++ b/plugin/studio/package_analyze_result.go @@ -0,0 +1,38 @@ +package studio + +type packageAnalyzeResult struct { + Status string `json:"status"` + Violations []packageAnalyzeViolation `json:"violations"` + Error *string `json:"error"` +} + +type packageAnalyzeViolation struct { + ErrorCode string `json:"errorCode"` + Description string `json:"description"` + RuleName string `json:"ruleName"` + FilePath string `json:"filePath"` + ActivityId *packageAnalyzeActivityId `json:"activityId"` + ActivityDisplayName string `json:"activityDisplayName"` + WorkflowDisplayName string `json:"workflowDisplayName"` + Item *packageAnalyzeItem `json:"item"` + ErrorSeverity int `json:"errorSeverity"` + Recommendation string `json:"recommendation"` + DocumentationLink string `json:"documentationLink"` +} +type packageAnalyzeActivityId struct { + Id string `json:"id"` + IdRef string `json:"idRef"` +} + +type packageAnalyzeItem struct { + Name string `json:"name"` + Type int `json:"type"` +} + +func newSucceededPackageAnalyzeResult(violations []packageAnalyzeViolation) *packageAnalyzeResult { + return &packageAnalyzeResult{"Succeeded", violations, nil} +} + +func newFailedPackageAnalyzeResult(violations []packageAnalyzeViolation, err string) *packageAnalyzeResult { + return &packageAnalyzeResult{"Failed", violations, &err} +} diff --git a/plugin/studio/package_pack_command.go b/plugin/studio/package_pack_command.go new file mode 100644 index 0000000..1f43fcc --- /dev/null +++ b/plugin/studio/package_pack_command.go @@ -0,0 +1,270 @@ +package studio + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +const defaultProjectJson = "project.json" + +// The PackagePackCommand packs a project into a single NuGet package +type PackagePackCommand struct { + Exec utils.ExecProcess +} + +func (c PackagePackCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("package", "Package", "UiPath Studio package-related actions"). + WithOperation("pack", "Package Project", "Packs a project into a single package"). + WithParameter("source", plugin.ParameterTypeString, "Path to a project.json file or a folder containing project.json file", true). + WithParameter("destination", plugin.ParameterTypeString, "The output folder", true). + WithParameter("package-version", plugin.ParameterTypeString, "The package version", false). + WithParameter("auto-version", plugin.ParameterTypeBoolean, "Auto-generate package version", false). + WithParameter("output-type", plugin.ParameterTypeString, "Force the output to a specific type", false). + WithParameter("split-output", plugin.ParameterTypeBoolean, "Enables the output split to runtime and design libraries", false). + WithParameter("release-notes", plugin.ParameterTypeString, "Add release notes", false) +} + +func (c PackagePackCommand) Execute(context plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source, err := c.getSource(context) + if err != nil { + return err + } + destination, err := c.getDestination(context) + if err != nil { + return err + } + packageVersion := c.getParameter("package-version", context.Parameters) + autoVersion := c.getBoolParameter("auto-version", context.Parameters) + outputType := c.getParameter("output-type", context.Parameters) + splitOutput := c.getBoolParameter("split-output", context.Parameters) + releaseNotes := c.getParameter("release-notes", context.Parameters) + params := newPackagePackParams(source, destination, packageVersion, autoVersion, outputType, splitOutput, releaseNotes) + + result, err := c.execute(*params, context.Debug, logger) + if err != nil { + return err + } + + json, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("pack command failed: %v", err) + } + return writer.WriteResponse(*output.NewResponseInfo(200, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(json))) +} + +func (c PackagePackCommand) execute(params packagePackParams, debug bool, logger log.Logger) (*packagePackResult, error) { + if !debug { + bar := c.newPackagingProgressBar(logger) + defer close(bar) + } + + args := []string{"package", "pack", params.Source, "--output", params.Destination} + if params.PackageVersion != "" { + args = append(args, "--version", params.PackageVersion) + } + if params.AutoVersion { + args = append(args, "--autoVersion") + } + if params.OutputType != "" { + args = append(args, "--outputType", params.OutputType) + } + if params.SplitOutput { + args = append(args, "--splitOutput") + } + if params.ReleaseNotes != "" { + args = append(args, "--releaseNotes", params.ReleaseNotes) + } + + projectReader := newStudioProjectReader(params.Source) + + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(projectReader.GetTargetFramework(), args...) + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + defer stdout.Close() + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + defer stderr.Close() + err = cmd.Start() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + + stderrOutputBuilder := new(strings.Builder) + stderrReader := io.TeeReader(stderr, stderrOutputBuilder) + + var wg sync.WaitGroup + wg.Add(3) + go c.readOutput(stdout, logger, &wg) + go c.readOutput(stderrReader, logger, &wg) + go c.wait(cmd, &wg) + wg.Wait() + + project, err := projectReader.ReadMetadata() + if err != nil { + return nil, err + } + + exitCode := cmd.ExitCode() + var result *packagePackResult + if exitCode == 0 { + nupkgFile := c.findNupkg(params.Destination) + version := c.extractVersion(nupkgFile) + result = newSucceededPackagePackResult( + filepath.Join(params.Destination, nupkgFile), + project.Name, + project.Description, + project.ProjectId, + version) + } else { + result = newFailedPackagePackResult( + stderrOutputBuilder.String(), + &project.Name, + &project.Description, + &project.ProjectId) + } + return result, nil +} + +func (c PackagePackCommand) findNupkg(destination string) string { + newestFile := "" + newestTime := time.Time{} + + files, _ := os.ReadDir(destination) + for _, file := range files { + extension := filepath.Ext(file.Name()) + if strings.EqualFold(extension, ".nupkg") { + fileInfo, _ := file.Info() + time := fileInfo.ModTime() + if time.After(newestTime) { + newestTime = time + newestFile = file.Name() + } + } + } + return newestFile +} + +func (c PackagePackCommand) extractVersion(nupkgFile string) string { + parts := strings.Split(nupkgFile, ".") + len := len(parts) + if len < 4 { + return "" + } + return fmt.Sprintf("%s.%s.%s", parts[len-4], parts[len-3], parts[len-2]) +} + +func (c PackagePackCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { + defer wg.Done() + _ = cmd.Wait() +} + +func (c PackagePackCommand) newPackagingProgressBar(logger log.Logger) chan struct{} { + progressBar := utils.NewProgressBar(logger) + ticker := time.NewTicker(10 * time.Millisecond) + cancel := make(chan struct{}) + var percent float64 = 0 + go func() { + for { + select { + case <-ticker.C: + progressBar.UpdatePercentage("packaging... ", percent) + percent = percent + 1 + if percent > 100 { + percent = 0 + } + case <-cancel: + ticker.Stop() + progressBar.Remove() + return + } + } + }() + return cancel +} + +func (c PackagePackCommand) getSource(context plugin.ExecutionContext) (string, error) { + source := c.getParameter("source", context.Parameters) + if source == "" { + return "", errors.New("source is not set") + } + source, _ = filepath.Abs(source) + fileInfo, err := os.Stat(source) + if err != nil { + return "", fmt.Errorf("%s not found", defaultProjectJson) + } + if fileInfo.IsDir() { + source = filepath.Join(source, defaultProjectJson) + } + return source, nil +} + +func (c PackagePackCommand) getDestination(context plugin.ExecutionContext) (string, error) { + destination := c.getParameter("destination", context.Parameters) + if destination == "" { + return "", errors.New("destination is not set") + } + destination, _ = filepath.Abs(destination) + return destination, nil +} + +func (c PackagePackCommand) readOutput(output io.Reader, logger log.Logger, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(output) + scanner.Split(bufio.ScanRunes) + for scanner.Scan() { + logger.Log(scanner.Text()) + } +} + +func (c PackagePackCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func (c PackagePackCommand) getBoolParameter(name string, parameters []plugin.ExecutionParameter) bool { + result := false + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(bool); ok { + result = data + break + } + } + } + return result +} + +func NewPackagePackCommand() *PackagePackCommand { + return &PackagePackCommand{utils.NewExecProcess()} +} diff --git a/plugin/studio/package_pack_params.go b/plugin/studio/package_pack_params.go new file mode 100644 index 0000000..ebee3e7 --- /dev/null +++ b/plugin/studio/package_pack_params.go @@ -0,0 +1,22 @@ +package studio + +type packagePackParams struct { + Source string + Destination string + PackageVersion string + AutoVersion bool + OutputType string + SplitOutput bool + ReleaseNotes string +} + +func newPackagePackParams( + source string, + destination string, + packageVersion string, + autoVersion bool, + outputType string, + splitOutput bool, + releaseNotes string) *packagePackParams { + return &packagePackParams{source, destination, packageVersion, autoVersion, outputType, splitOutput, releaseNotes} +} diff --git a/plugin/studio/package_pack_result.go b/plugin/studio/package_pack_result.go new file mode 100644 index 0000000..2b89441 --- /dev/null +++ b/plugin/studio/package_pack_result.go @@ -0,0 +1,19 @@ +package studio + +type packagePackResult struct { + Status string `json:"status"` + Name *string `json:"name"` + Description *string `json:"description"` + ProjectId *string `json:"projectId"` + Version *string `json:"version"` + Output *string `json:"output"` + Error *string `json:"error"` +} + +func newSucceededPackagePackResult(output string, name string, description string, projectId string, version string) *packagePackResult { + return &packagePackResult{"Succeeded", &name, &description, &projectId, &version, &output, nil} +} + +func newFailedPackagePackResult(err string, name *string, description *string, projectId *string) *packagePackResult { + return &packagePackResult{"Failed", name, description, projectId, nil, nil, &err} +} diff --git a/plugin/studio/projects/.gitignore b/plugin/studio/projects/.gitignore new file mode 100644 index 0000000..57c19d8 --- /dev/null +++ b/plugin/studio/projects/.gitignore @@ -0,0 +1,6 @@ +crossplatform/* +!crossplatform/Main.xaml +!crossplatform/project.json +windows/* +!windows/Main.xaml +!windows/project.json \ No newline at end of file diff --git a/plugin/studio/projects/crossplatform/Main.xaml b/plugin/studio/projects/crossplatform/Main.xaml new file mode 100644 index 0000000..c7168e2 --- /dev/null +++ b/plugin/studio/projects/crossplatform/Main.xaml @@ -0,0 +1,87 @@ + + + + System.Activities + System.Activities.Statements + System.Activities.Expressions + System.Activities.Validation + System.Activities.XamlIntegration + Microsoft.VisualBasic + Microsoft.VisualBasic.Activities + System + System.Collections + System.Collections.Generic + System.Collections.ObjectModel + System.Data + System.Diagnostics + System.Drawing + System.IO + System.Linq + System.Net.Mail + System.Xml + System.Text + System.Xml.Linq + UiPath.Core + UiPath.Core.Activities + System.Windows.Markup + GlobalVariablesNamespace + GlobalConstantsNamespace + System.Linq.Expressions + System.Runtime.Serialization + + + + + Microsoft.CSharp + Microsoft.VisualBasic + mscorlib + System + System.Activities + System.ComponentModel.TypeConverter + System.Core + System.Data + System.Data.Common + System.Data.DataSetExtensions + System.Drawing + System.Drawing.Common + System.Drawing.Primitives + System.Linq + System.Net.Mail + System.ObjectModel + System.Private.CoreLib + System.Runtime.Serialization + System.ServiceModel + System.ServiceModel.Activities + System.Xaml + System.Xml + System.Xml.Linq + UiPath.System.Activities + UiPath.UiAutomation.Activities + UiPath.Studio.Constants + System.Configuration.ConfigurationManager + System.Security.Permissions + System.Console + System.ComponentModel + System.Memory + System.Private.Uri + System.Linq.Expressions + System.Runtime.Serialization.Formatters + System.Private.DataContractSerialization + System.Runtime.Serialization.Primitives + + + + + + True + + + + + + "Hello World" + + + + + \ No newline at end of file diff --git a/plugin/studio/projects/crossplatform/project.json b/plugin/studio/projects/crossplatform/project.json new file mode 100644 index 0000000..e6c5ed0 --- /dev/null +++ b/plugin/studio/projects/crossplatform/project.json @@ -0,0 +1,61 @@ +{ + "name": "MyProcess", + "projectId": "9011ee47-8dd4-4726-8850-299bd6ef057c", + "description": "Blank Process", + "main": "Main.xaml", + "dependencies": { + "UiPath.System.Activities": "[24.10.3]", + "UiPath.Testing.Activities": "[24.10.0]", + "UiPath.UIAutomation.Activities": "[24.10.0]", + "UiPath.WebAPI.Activities": "[1.21.0]" + }, + "webServices": [], + "entitiesStores": [], + "schemaVersion": "4.0", + "studioVersion": "24.10.1.0", + "projectVersion": "1.0.2", + "runtimeOptions": { + "autoDispose": false, + "netFrameworkLazyLoading": false, + "isPausable": true, + "isAttended": false, + "requiresUserInteraction": false, + "supportsPersistence": false, + "workflowSerialization": "DataContract", + "excludedLoggedData": [ + "Private:*", + "*password*" + ], + "executionType": "Workflow", + "readyForPiP": false, + "startsInPiP": false, + "mustRestoreAllDependencies": true, + "pipType": "ChildSession" + }, + "designOptions": { + "projectProfile": "Developement", + "outputType": "Process", + "libraryOptions": { + "includeOriginalXaml": false, + "privateWorkflows": [] + }, + "processOptions": { + "ignoredFiles": [] + }, + "fileInfoCollection": [], + "saveToCloud": false + }, + "expressionLanguage": "CSharp", + "entryPoints": [ + { + "filePath": "Main.xaml", + "uniqueId": "ac610120-f85b-4ed4-a014-05dca3380186", + "input": [], + "output": [] + } + ], + "isTemplate": false, + "templateProjectData": {}, + "publishData": {}, + "targetFramework": "Portable" +} \ No newline at end of file diff --git a/plugin/studio/projects/windows/Main.xaml b/plugin/studio/projects/windows/Main.xaml new file mode 100644 index 0000000..4c906dc --- /dev/null +++ b/plugin/studio/projects/windows/Main.xaml @@ -0,0 +1,94 @@ + + + + System.Activities + System.Activities.Statements + System.Activities.Expressions + System.Activities.Validation + System.Activities.XamlIntegration + Microsoft.VisualBasic + Microsoft.VisualBasic.Activities + System + System.Collections + System.Collections.Generic + System.Collections.ObjectModel + System.Data + System.Diagnostics + System.Drawing + System.IO + System.Linq + System.Net.Mail + System.Xml + System.Text + System.Xml.Linq + UiPath.Core + UiPath.Core.Activities + System.Windows.Markup + GlobalVariablesNamespace + GlobalConstantsNamespace + System.Linq.Expressions + System.Runtime.Serialization + + + + + Microsoft.CSharp + Microsoft.VisualBasic + mscorlib + PresentationCore + PresentationFramework + System + System.Activities + System.ComponentModel.TypeConverter + System.Core + System.Data + System.Data.Common + System.Data.DataSetExtensions + System.Drawing + System.Drawing.Common + System.Drawing.Primitives + System.Linq + System.Net.Mail + System.ObjectModel + System.Private.CoreLib + System.Runtime.Serialization + System.ServiceModel + System.ServiceModel.Activities + System.Xaml + System.Xml + System.Xml.Linq + UiPath.System.Activities + UiPath.UiAutomation.Activities + WindowsBase + UiPath.Studio.Constants + System.Memory.Data + UiPath.Excel.Activities.Design + NPOI + System.Console + System.Security.Permissions + System.Configuration.ConfigurationManager + System.ComponentModel + System.Memory + System.Private.Uri + System.Linq.Expressions + System.Private.ServiceModel + System.Runtime.Serialization.Formatters + System.Private.DataContractSerialization + System.Runtime.Serialization.Primitives + + + + + + True + + + + + + "Hello World" + + + + + \ No newline at end of file diff --git a/plugin/studio/projects/windows/project.json b/plugin/studio/projects/windows/project.json new file mode 100644 index 0000000..4798b88 --- /dev/null +++ b/plugin/studio/projects/windows/project.json @@ -0,0 +1,62 @@ +{ + "name": "MyWindowsProcess", + "projectId": "94c4c9c1-68c3-45d4-be14-d4427f17eddd", + "description": "Blank Process", + "main": "Main.xaml", + "dependencies": { + "UiPath.Excel.Activities": "[2.23.4]", + "UiPath.Mail.Activities": "[1.23.1]", + "UiPath.System.Activities": "[24.10.3]", + "UiPath.Testing.Activities": "[24.10.0]", + "UiPath.UIAutomation.Activities": "[24.10.0]" + }, + "webServices": [], + "entitiesStores": [], + "schemaVersion": "4.0", + "studioVersion": "24.10.1.0", + "projectVersion": "1.0.0", + "runtimeOptions": { + "autoDispose": false, + "netFrameworkLazyLoading": false, + "isPausable": true, + "isAttended": false, + "requiresUserInteraction": true, + "supportsPersistence": false, + "workflowSerialization": "DataContract", + "excludedLoggedData": [ + "Private:*", + "*password*" + ], + "executionType": "Workflow", + "readyForPiP": false, + "startsInPiP": false, + "mustRestoreAllDependencies": true, + "pipType": "ChildSession" + }, + "designOptions": { + "projectProfile": "Developement", + "outputType": "Process", + "libraryOptions": { + "includeOriginalXaml": false, + "privateWorkflows": [] + }, + "processOptions": { + "ignoredFiles": [] + }, + "fileInfoCollection": [], + "saveToCloud": false + }, + "expressionLanguage": "CSharp", + "entryPoints": [ + { + "filePath": "Main.xaml", + "uniqueId": "657507a4-4df3-47eb-810e-2548d3a59824", + "input": [], + "output": [] + } + ], + "isTemplate": false, + "templateProjectData": {}, + "publishData": {}, + "targetFramework": "Windows" +} \ No newline at end of file diff --git a/plugin/studio/studio_plugin_notwin_test.go b/plugin/studio/studio_plugin_notwin_test.go new file mode 100644 index 0000000..85b5a17 --- /dev/null +++ b/plugin/studio/studio_plugin_notwin_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package studio + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +func TestPackOnLinuxWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + if !strings.HasSuffix(commandName, "dotnet") { + t.Errorf("Expected command name to be dotnet, but got: %v", commandName) + } + if !strings.HasSuffix(commandArgs[0], "uipcli.dll") { + t.Errorf("Expected 1st argument to be the uipcli.dll, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[1]) + } + if commandArgs[2] != "pack" { + t.Errorf("Expected 3rd argument to be pack, but got: %v", commandArgs[2]) + } + if commandArgs[3] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[3]) + } + if commandArgs[4] != "--output" { + t.Errorf("Expected 5th argument to be output, but got: %v", commandArgs[4]) + } + if commandArgs[5] != destination { + t.Errorf("Expected 6th argument to be the output path, but got: %v", commandArgs[5]) + } +} + +func TestAnalyzeOnLinuxWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + if !strings.HasSuffix(commandName, "dotnet") { + t.Errorf("Expected command name to be dotnet, but got: %v", commandName) + } + if !strings.HasSuffix(commandArgs[0], "uipcli.dll") { + t.Errorf("Expected 1st argument to be the uipcli.dll, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[1]) + } + if commandArgs[2] != "analyze" { + t.Errorf("Expected 3rd argument to be analyze, but got: %v", commandArgs[2]) + } + if commandArgs[3] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[3]) + } +} diff --git a/plugin/studio/studio_plugin_test.go b/plugin/studio/studio_plugin_test.go new file mode 100644 index 0000000..e04d8a5 --- /dev/null +++ b/plugin/studio/studio_plugin_test.go @@ -0,0 +1,368 @@ +package studio + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +const studioDefinition = ` +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/{organization}/studio_/backend + description: The production url + variables: + organization: + description: The organization name (or id) + default: my-org +paths: + {} +` + +func TestPackWithoutSourceParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "pack", "--destination", "test.nupkg"}, context) + + if !strings.Contains(result.StdErr, "Argument --source is missing") { + t.Errorf("Expected stderr to show that source parameter is missing, but got: %v", result.StdErr) + } +} + +func TestPackWithoutDestinationParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "pack", "--source", source}, context) + + if !strings.Contains(result.StdErr, "Argument --destination is missing") { + t.Errorf("Expected stderr to show that destination parameter is missing, but got: %v", result.StdErr) + } +} + +func TestPackNonExistentProjectShowsProjectJsonNotFound(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "pack", "--source", "non-existent", "--destination", "test.nupkg"}, context) + + if !strings.Contains(result.StdErr, "project.json not found") { + t.Errorf("Expected stderr to show that project.json was not found, but got: %v", result.StdErr) + } +} + +func TestFailedPackagingReturnsFailureStatus(t *testing.T) { + exec := utils.NewExecCustomProcess(1, "Build output", "There was an error", func(name string, args []string) {}) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Failed" { + t.Errorf("Expected status to be Failed, but got: %v", result.StdOut) + } + if stdout["error"] != "There was an error" { + t.Errorf("Expected error to be set, but got: %v", result.StdOut) + } +} + +func TestPackCrossPlatformSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + if stdout["name"] != "MyProcess" { + t.Errorf("Expected name to be set, but got: %v", result.StdOut) + } + if stdout["description"] != "Blank Process" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + if stdout["projectId"] != "9011ee47-8dd4-4726-8850-299bd6ef057c" { + t.Errorf("Expected projectId to be set, but got: %v", result.StdOut) + } + if stdout["version"] != "1.0.2" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + outputFile := stdout["output"].(string) + if outputFile != filepath.Join(destination, "MyProcess.1.0.2.nupkg") { + t.Errorf("Expected output path to be set, but got: %v", result.StdOut) + } + if _, err := os.Stat(outputFile); err != nil { + t.Errorf("Expected output file %s to exists, but could not find it: %v", outputFile, err) + } +} + +func TestPackWithAutoVersionArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--auto-version", "true"}, context) + + if !slices.Contains(commandArgs, "--autoVersion") { + t.Errorf("Expected argument --autoVersion, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithOutputTypeArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--output-type", "Process"}, context) + + if !slices.Contains(commandArgs, "--outputType") { + t.Errorf("Expected argument --outputType, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithSplitOutputArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--split-output", "true"}, context) + + if !slices.Contains(commandArgs, "--splitOutput") { + t.Errorf("Expected argument --splitOutput, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithReleaseNotesArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--release-notes", "These are release notes."}, context) + + index := slices.Index(commandArgs, "--releaseNotes") + if commandArgs[index] != "--releaseNotes" { + t.Errorf("Expected argument --releaseNotes, but got: %v", strings.Join(commandArgs, " ")) + } + if commandArgs[index+1] != "These are release notes." { + t.Errorf("Expected release notes argument, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestAnalyzeWithoutSourceParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "analyze"}, context) + + if !strings.Contains(result.StdErr, "Argument --source is missing") { + t.Errorf("Expected stderr to show that source parameter is missing, but got: %v", result.StdErr) + } +} + +func TestAnalyzeCrossPlatformSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + violations := stdout["violations"].([]interface{}) + if len(violations) == 0 { + t.Errorf("Expected violations not to be empty, but got: %v", result.StdOut) + } + violation := findViolation(violations, "TA-DBP-002") + if violation == nil { + t.Errorf("Could not find violation TA-DBP-002, got: %v", result.StdOut) + } + if violation["activityDisplayName"] != "" { + t.Errorf("Expected violation to have a activityDisplayName, but got: %v", result.StdOut) + } + if violation["description"] != "Workflow Main.xaml does not have any assigned Test Cases." { + t.Errorf("Expected violation to have a description, but got: %v", result.StdOut) + } + if violation["documentationLink"] != "https://docs.uipath.com/activities/lang-en/docs/ta-dbp-002" { + t.Errorf("Expected violation to have a documentationLink, but got: %v", result.StdOut) + } + if violation["errorSeverity"] != 1.0 { + t.Errorf("Expected violation to have a errorSeverity, but got: %v", result.StdOut) + } + if violation["filePath"] != "" { + t.Errorf("Expected violation to have a filePath, but got: %v", result.StdOut) + } + if violation["recommendation"] != "Creating Test Cases for your workflows allows you to run them frequently to discover potential issues early on before they are introduced in your production environment. [Learn more.](https://docs.uipath.com/activities/lang-en/docs/ta-dbp-002)" { + t.Errorf("Expected violation to have a recommendation, but got: %v", result.StdOut) + } + if violation["ruleName"] != "Untested Workflows" { + t.Errorf("Expected violation to have a ruleName, but got: %v", result.StdOut) + } + if violation["workflowDisplayName"] != "Main" { + t.Errorf("Expected violation to have a workflowDisplayName, but got: %v", result.StdOut) + } +} + +func TestFailedAnalyzeReturnsFailureStatus(t *testing.T) { + exec := utils.NewExecCustomProcess(1, "Analyze output", "There was an error", func(name string, args []string) {}) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Failed" { + t.Errorf("Expected status to be Failed, but got: %v", result.StdOut) + } + if stdout["error"] != "There was an error" { + t.Errorf("Expected error to be set, but got: %v", result.StdOut) + } +} + +func TestAnalyzeWithTreatWarningsAsErrorsArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source, "--treat-warnings-as-errors", "true"}, context) + + if !slices.Contains(commandArgs, "--treatWarningsAsErrors") { + t.Errorf("Expected argument --treatWarningsAsErrors, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestAnalyzeWithStopOnRuleViolationArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source, "--stop-on-rule-violation", "true"}, context) + + if !slices.Contains(commandArgs, "--stopOnRuleViolation") { + t.Errorf("Expected argument --stopOnRuleViolation, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func findViolation(violations []interface{}, errorCode string) map[string]interface{} { + var violation map[string]interface{} + for _, v := range violations { + vMap := v.(map[string]interface{}) + if vMap["errorCode"] == errorCode { + violation = vMap + } + } + return violation +} + +func studioCrossPlatformProjectDirectory() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "projects", "crossplatform") +} + +func createDirectory(t *testing.T) string { + tmp, err := os.MkdirTemp("", "uipath-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(tmp) }) + return tmp +} diff --git a/plugin/studio/studio_plugin_windows_test.go b/plugin/studio/studio_plugin_windows_test.go new file mode 100644 index 0000000..f9dcdad --- /dev/null +++ b/plugin/studio/studio_plugin_windows_test.go @@ -0,0 +1,153 @@ +//go:build windows + +package studio + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +func TestPackWindowsSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioWindowsProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + if stdout["name"] != "MyWindowsProcess" { + t.Errorf("Expected name to be set, but got: %v", result.StdOut) + } + if stdout["description"] != "Blank Process" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + if stdout["projectId"] != "94c4c9c1-68c3-45d4-be14-d4427f17eddd" { + t.Errorf("Expected projectId to be set, but got: %v", result.StdOut) + } + if stdout["version"] != "1.0.0" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + outputFile := stdout["output"].(string) + if outputFile != filepath.Join(destination, "MyWindowsProcess.1.0.0.nupkg") { + t.Errorf("Expected output path to be set, but got: %v", result.StdOut) + } + if _, err := os.Stat(outputFile); err != nil { + t.Errorf("Expected output file %s to exists, but could not find it: %v", outputFile, err) + } +} + +func TestPackOnWindowsWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + if !strings.HasSuffix(commandName, "uipcli.exe") { + t.Errorf("Expected command name to be uipcli.exe, but got: %v", commandName) + } + if commandArgs[0] != "package" { + t.Errorf("Expected 1st argument to be package, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "pack" { + t.Errorf("Expected 2nd argument to be pack, but got: %v", commandArgs[1]) + } + if commandArgs[2] != filepath.Join(source, "project.json") { + t.Errorf("Expected 3rd argument to be the project.json, but got: %v", commandArgs[2]) + } + if commandArgs[3] != "--output" { + t.Errorf("Expected 4th argument to be output, but got: %v", commandArgs[3]) + } + if commandArgs[4] != destination { + t.Errorf("Expected 5th argument to be the output path, but got: %v", commandArgs[4]) + } +} + +func TestAnalyzeWindowsSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + source := studioWindowsProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + violations := stdout["violations"].([]interface{}) + if len(violations) == 0 { + t.Errorf("Expected violations not to be empty, but got: %v", result.StdOut) + } +} + +func TestAnalyzeOnWindowsWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + if !strings.HasSuffix(commandName, "uipcli.exe") { + t.Errorf("Expected command name to be uipcli.exe, but got: %v", commandName) + } + if commandArgs[0] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "analyze" { + t.Errorf("Expected 3rd argument to be analyze, but got: %v", commandArgs[1]) + } + if commandArgs[2] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[2]) + } +} + +func studioWindowsProjectDirectory() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "projects", "windows") +} diff --git a/plugin/studio/studio_project_json.go b/plugin/studio/studio_project_json.go new file mode 100644 index 0000000..25b2db3 --- /dev/null +++ b/plugin/studio/studio_project_json.go @@ -0,0 +1,8 @@ +package studio + +type studioProjectJson struct { + Name string `json:"name"` + Description string `json:"description"` + ProjectId string `json:"projectId"` + TargetFramework string `json:"targetFramework"` +} diff --git a/plugin/studio/studio_project_reader.go b/plugin/studio/studio_project_reader.go new file mode 100644 index 0000000..e96be3b --- /dev/null +++ b/plugin/studio/studio_project_reader.go @@ -0,0 +1,44 @@ +package studio + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +type studioProjectReader struct { + Path string +} + +func (p studioProjectReader) GetTargetFramework() TargetFramework { + project, _ := p.ReadMetadata() + if strings.EqualFold(project.TargetFramework, "windows") { + return TargetFrameworkWindows + } + return TargetFrameworkCrossPlatform +} + +func (p studioProjectReader) ReadMetadata() (studioProjectJson, error) { + file, err := os.Open(p.Path) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + defer file.Close() + byteValue, err := io.ReadAll(file) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + + var project studioProjectJson + err = json.Unmarshal(byteValue, &project) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error parsing %s file: %v", defaultProjectJson, err) + } + return project, nil +} + +func newStudioProjectReader(path string) *studioProjectReader { + return &studioProjectReader{path} +} diff --git a/plugin/studio/target_framework.go b/plugin/studio/target_framework.go new file mode 100644 index 0000000..969cc5d --- /dev/null +++ b/plugin/studio/target_framework.go @@ -0,0 +1,8 @@ +package studio + +type TargetFramework int + +const ( + TargetFrameworkCrossPlatform TargetFramework = iota + 1 + TargetFrameworkWindows +) diff --git a/plugin/studio/uipcli.go b/plugin/studio/uipcli.go new file mode 100644 index 0000000..04ddde9 --- /dev/null +++ b/plugin/studio/uipcli.go @@ -0,0 +1,66 @@ +package studio + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +const uipcliVersion = "24.12.9111.31003" +const uipcliUrl = "https://uipath.pkgs.visualstudio.com/Public.Feeds/_apis/packaging/feeds/1c781268-d43d-45ab-9dfc-0151a1c740b7/nuget/packages/UiPath.CLI/versions/" + uipcliVersion + "/content" + +const uipcliWindowsVersion = "24.12.9111.31003" +const uipcliWindowsUrl = "https://uipath.pkgs.visualstudio.com/Public.Feeds/_apis/packaging/feeds/1c781268-d43d-45ab-9dfc-0151a1c740b7/nuget/packages/UiPath.CLI.Windows/versions/" + uipcliWindowsVersion + "/content" + +type uipcli struct { + Exec utils.ExecProcess + Logger log.Logger +} + +func (c uipcli) Execute(targetFramework TargetFramework, args ...string) (utils.ExecCmd, error) { + if targetFramework == TargetFrameworkWindows { + return c.execute("uipcli-win", uipcliWindowsUrl, args) + } + return c.execute("uipcli", uipcliUrl, args) +} + +func (c uipcli) execute(name string, url string, args []string) (utils.ExecCmd, error) { + uipcliPath, err := c.getPath(name, url) + if err != nil { + return nil, err + } + + path := uipcliPath + if filepath.Ext(uipcliPath) == ".dll" { + path, err = exec.LookPath("dotnet") + if err != nil { + return nil, fmt.Errorf("Could not find dotnet runtime to run command: %v", err) + } + args = append([]string{uipcliPath}, args...) + } + + cmd := c.Exec.Command(path, args...) + return cmd, nil +} + +func (c uipcli) getPath(name string, url string) (string, error) { + externalPlugin := plugin.NewExternalPlugin(c.Logger) + executable := "tools/uipcli.dll" + if c.isWindows() { + executable = "tools/uipcli.exe" + } + return externalPlugin.GetTool(name, url, executable) +} + +func (c uipcli) isWindows() bool { + return runtime.GOOS == "windows" +} + +func newUipcli(exec utils.ExecProcess, logger log.Logger) *uipcli { + return &uipcli{exec, logger} +} diff --git a/plugin/zip_archive.go b/plugin/zip_archive.go new file mode 100644 index 0000000..aa6b64d --- /dev/null +++ b/plugin/zip_archive.go @@ -0,0 +1,75 @@ +package plugin + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const MaxArchiveSize = 1 * 1024 * 1024 * 1024 + +type zipArchive struct{} + +func (z zipArchive) Extract(zipArchive string, destinationFolder string, permissions os.FileMode) error { + archive, err := zip.OpenReader(zipArchive) + if err != nil { + return err + } + defer archive.Close() + + for _, file := range archive.File { + err := z.extractFile(file, destinationFolder, permissions) + if err != nil { + return err + } + } + return nil +} + +func (z zipArchive) extractFile(zipFile *zip.File, destinationFolder string, permissions os.FileMode) error { + path, err := z.sanitizeArchivePath(destinationFolder, zipFile.Name) + if err != nil { + return err + } + + if zipFile.FileInfo().IsDir() { + return os.MkdirAll(path, permissions) + } + err = os.MkdirAll(filepath.Dir(path), permissions) + if err != nil { + return err + } + + zipFileReader, err := zipFile.Open() + if err != nil { + return err + } + defer zipFileReader.Close() + + destinationFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) + if err != nil { + return err + } + defer destinationFile.Close() + + _, err = io.CopyN(destinationFile, zipFileReader, MaxArchiveSize) + if err != nil && err != io.EOF { + return err + } + return nil +} + +func (z zipArchive) sanitizeArchivePath(directory string, name string) (string, error) { + result := filepath.Join(directory, name) + if strings.HasPrefix(result, filepath.Clean(directory)) { + return result, nil + } + return "", fmt.Errorf("File path '%s' is not allowed", directory) +} + +func newZipArchive() *zipArchive { + return &zipArchive{} +} diff --git a/test/setup.go b/test/setup.go index 46809f0..2fdf2b8 100644 --- a/test/setup.go +++ b/test/setup.go @@ -187,7 +187,7 @@ func RunCli(args []string, context Context) Result { stderr := new(bytes.Buffer) authenticators := []auth.Authenticator{ auth.NewPatAuthenticator(), - auth.NewOAuthAuthenticator(cache.NewFileCache(), auth.NewExecBrowserLauncher()), + auth.NewOAuthAuthenticator(cache.NewFileCache(), *auth.NewBrowserLauncher()), auth.NewBearerAuthenticator(cache.NewFileCache()), } commandPlugins := []plugin.CommandPlugin{} diff --git a/utils/directories.go b/utils/directories.go new file mode 100644 index 0000000..b57caf6 --- /dev/null +++ b/utils/directories.go @@ -0,0 +1,43 @@ +package utils + +import ( + "os" + "path/filepath" +) + +const directoryPermissions = 0700 + +type Directories struct { +} + +func (d Directories) Temp() (string, error) { + return d.userDirectory("tmp") +} + +func (d Directories) Cache() (string, error) { + return d.userDirectory("cache") +} + +func (d Directories) Plugin() (string, error) { + return d.userDirectory("plugins") +} + +func (d Directories) userDirectory(name string) (string, error) { + userDirectory, err := d.baseUserDirectory() + if err != nil { + return "", err + } + directory := filepath.Join(userDirectory, name) + _ = os.MkdirAll(directory, directoryPermissions) + return directory, nil +} + +func (d Directories) baseUserDirectory() (string, error) { + userCacheDirectory, err := os.UserCacheDir() + if err != nil { + return "", err + } + userDirectory := filepath.Join(userCacheDirectory, "uipath", "uipathcli") + _ = os.MkdirAll(userDirectory, directoryPermissions) + return userDirectory, nil +} diff --git a/utils/exec_cmd.go b/utils/exec_cmd.go new file mode 100644 index 0000000..5fa7344 --- /dev/null +++ b/utils/exec_cmd.go @@ -0,0 +1,11 @@ +package utils + +import "io" + +type ExecCmd interface { + StdoutPipe() (io.ReadCloser, error) + StderrPipe() (io.ReadCloser, error) + Start() error + Wait() error + ExitCode() int +} diff --git a/utils/exec_custom_cmd.go b/utils/exec_custom_cmd.go new file mode 100644 index 0000000..35643b1 --- /dev/null +++ b/utils/exec_custom_cmd.go @@ -0,0 +1,35 @@ +package utils + +import ( + "io" +) + +type ExecCustomCmd struct { + Name string + Args []string + Exit int + StdOut io.ReadCloser + StdErr io.ReadCloser + OnStart func(name string, args []string) +} + +func (c ExecCustomCmd) StdoutPipe() (io.ReadCloser, error) { + return c.StdOut, nil +} + +func (c ExecCustomCmd) StderrPipe() (io.ReadCloser, error) { + return c.StdErr, nil +} + +func (c ExecCustomCmd) Start() error { + c.OnStart(c.Name, c.Args) + return nil +} + +func (c ExecCustomCmd) Wait() error { + return nil +} + +func (c ExecCustomCmd) ExitCode() int { + return c.Exit +} diff --git a/utils/exec_custom_process.go b/utils/exec_custom_process.go new file mode 100644 index 0000000..18a4c96 --- /dev/null +++ b/utils/exec_custom_process.go @@ -0,0 +1,33 @@ +package utils + +import ( + "io" + "strings" +) + +type ExecCustomProcess struct { + ExitCode int + Stdout string + Stderr string + OnStart func(name string, args []string) +} + +func (e ExecCustomProcess) Command(name string, args ...string) ExecCmd { + return ExecCustomCmd{ + Name: name, + Args: args, + StdOut: io.NopCloser(strings.NewReader(e.Stdout)), + StdErr: io.NopCloser(strings.NewReader(e.Stderr)), + Exit: e.ExitCode, + OnStart: e.OnStart, + } +} + +func NewExecCustomProcess(exitCode int, stdout string, stderr string, onStart func(name string, args []string)) *ExecCustomProcess { + return &ExecCustomProcess{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + OnStart: onStart, + } +} diff --git a/utils/exec_default_cmd.go b/utils/exec_default_cmd.go new file mode 100644 index 0000000..9aa6ffd --- /dev/null +++ b/utils/exec_default_cmd.go @@ -0,0 +1,30 @@ +package utils + +import ( + "io" + "os/exec" +) + +type ExecDefaultCmd struct { + Cmd *exec.Cmd +} + +func (c ExecDefaultCmd) StdoutPipe() (io.ReadCloser, error) { + return c.Cmd.StdoutPipe() +} + +func (c ExecDefaultCmd) StderrPipe() (io.ReadCloser, error) { + return c.Cmd.StderrPipe() +} + +func (c ExecDefaultCmd) Start() error { + return c.Cmd.Start() +} + +func (c ExecDefaultCmd) Wait() error { + return c.Cmd.Wait() +} + +func (c ExecDefaultCmd) ExitCode() int { + return c.Cmd.ProcessState.ExitCode() +} diff --git a/utils/exec_default_process.go b/utils/exec_default_process.go new file mode 100644 index 0000000..f8382e3 --- /dev/null +++ b/utils/exec_default_process.go @@ -0,0 +1,14 @@ +package utils + +import "os/exec" + +type ExecDefaultProcess struct { +} + +func (e ExecDefaultProcess) Command(name string, args ...string) ExecCmd { + return ExecDefaultCmd{exec.Command(name, args...)} +} + +func NewExecProcess() ExecProcess { + return &ExecDefaultProcess{} +} diff --git a/utils/exec_process.go b/utils/exec_process.go new file mode 100644 index 0000000..dc69d03 --- /dev/null +++ b/utils/exec_process.go @@ -0,0 +1,5 @@ +package utils + +type ExecProcess interface { + Command(name string, args ...string) ExecCmd +} diff --git a/utils/progress_bar.go b/utils/progress_bar.go index 9147021..eb81c82 100644 --- a/utils/progress_bar.go +++ b/utils/progress_bar.go @@ -16,9 +16,19 @@ type ProgressBar struct { renderedLength int } -func (b *ProgressBar) Update(text string, current int64, total int64, bytesPerSecond int64) { +func (b *ProgressBar) UpdatePercentage(text string, percent float64) { b.logger.LogError("\r") - length := b.render(text, current, total, bytesPerSecond) + length := b.renderTick(text, percent) + left := b.renderedLength - length + if left > 0 { + b.logger.LogError(strings.Repeat(" ", left)) + } + b.renderedLength = length +} + +func (b *ProgressBar) UpdateProgress(text string, current int64, total int64, bytesPerSecond int64) { + b.logger.LogError("\r") + length := b.renderProgress(text, current, total, bytesPerSecond) left := b.renderedLength - length if left > 0 { b.logger.LogError(strings.Repeat(" ", left)) @@ -33,10 +43,18 @@ func (b *ProgressBar) Remove() { } } -func (b ProgressBar) render(text string, currentBytes int64, totalBytes int64, bytesPerSecond int64) int { +func (b ProgressBar) renderTick(text string, percent float64) int { + bar := b.createBar(percent) + output := fmt.Sprintf("%s |%s|", + text, + bar) + b.logger.LogError(output) + return utf8.RuneCountInString(output) +} + +func (b ProgressBar) renderProgress(text string, currentBytes int64, totalBytes int64, bytesPerSecond int64) int { percent := math.Min(float64(currentBytes)/float64(totalBytes)*100.0, 100.0) - barCount := int(percent / 5.0) - bar := strings.Repeat("█", barCount) + strings.Repeat(" ", 20-barCount) + bar := b.createBar(percent) totalBytesFormatted, unit := b.formatBytes(totalBytes) currentBytesFormatted, unit := b.formatBytesInUnit(currentBytes, unit) bytesPerSecondFormatted, bytesPerSecondUnit := b.formatBytes(bytesPerSecond) @@ -53,6 +71,14 @@ func (b ProgressBar) render(text string, currentBytes int64, totalBytes int64, b return utf8.RuneCountInString(output) } +func (b ProgressBar) createBar(percent float64) string { + barCount := int(percent / 5.0) + if barCount > 20 { + barCount = 20 + } + return strings.Repeat("█", barCount) + strings.Repeat(" ", 20-barCount) +} + func (b ProgressBar) formatBytes(count int64) (string, string) { if count < 1000 { return b.formatBytesInUnit(count, "B") diff --git a/utils/progress_bar_test.go b/utils/progress_bar_test.go index 364361b..3f670d4 100644 --- a/utils/progress_bar_test.go +++ b/utils/progress_bar_test.go @@ -11,23 +11,23 @@ import ( "github.com/UiPath/uipathcli/log" ) -func TestProgressBarShowsZeroAtTheBeginning(t *testing.T) { +func TestProgressBarUpdateProgressShowsZeroAtTheBeginning(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) - progressBar.Update("downloading...", 0, 1000, 0) + progressBar.UpdateProgress("downloading...", 0, 1000, 0) if output.String() != "\rdownloading... 0% | | (0.0/1.0 kB, 0 B/s)" { t.Errorf("Should display progress of 10 percent, but got: %v", output.String()) } } -func TestProgressBarUpdatesMultipleTimes(t *testing.T) { +func TestProgressBarUpdateProgressMultipleTimes(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) - progressBar.Update("downloading...", 0, 1000, 1) - progressBar.Update("downloading...", 200, 1000, 1024) + progressBar.UpdateProgress("downloading...", 0, 1000, 1) + progressBar.UpdateProgress("downloading...", 200, 1000, 1024) if output.String() != "\rdownloading... 0% | | (0.0/1.0 kB, 1 B/s)"+ "\rdownloading... 20% |████ | (0.2/1.0 kB, 1.0 kB/s)" { @@ -39,7 +39,7 @@ func TestProgressBarShowsReadsFromProgressReader(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) progressReader := NewProgressReader(data(1000), func(progress Progress) { - progressBar.Update("download", progress.BytesRead, 1000, progress.BytesPerSecond) + progressBar.UpdateProgress("download", progress.BytesRead, 1000, progress.BytesPerSecond) }) _, _ = progressReader.Read(make([]byte, 100)) @@ -54,7 +54,7 @@ func TestProgressBarShowsMultipleReadsFromProgressReader(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) progressReader := NewProgressReader(data(1000), func(progress Progress) { - progressBar.Update("download", progress.BytesRead, 1000, progress.BytesPerSecond) + progressBar.UpdateProgress("download", progress.BytesRead, 1000, progress.BytesPerSecond) }) _, _ = progressReader.Read(make([]byte, 500)) @@ -67,6 +67,44 @@ func TestProgressBarShowsMultipleReadsFromProgressReader(t *testing.T) { } } +func TestProgressBarUpdatePercentageShowsSimpleBar(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + + progressBar.UpdatePercentage("building...", 0) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... | |") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + +func TestProgressBarUpdatePercentageMovesBar(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + progressBar.UpdatePercentage("building...", 0) + + progressBar.UpdatePercentage("building...", 10) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... |██ |") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + +func TestProgressBarUpdatePercentageTo100(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + progressBar.UpdatePercentage("building...", 0) + progressBar.UpdatePercentage("building...", 50) + progressBar.UpdatePercentage("building...", 100) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... |████████████████████|") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + func lastLine(output bytes.Buffer) string { lines := strings.Split(output.String(), "\r") return lines[len(lines)-1]