diff --git a/cache/file_cache.go b/cache/file_cache.go index 54cbc30..e7684dd 100644 --- a/cache/file_cache.go +++ b/cache/file_cache.go @@ -9,11 +9,12 @@ 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,16 +64,16 @@ 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) + fileCacheDirectory := filepath.Join(cacheDirectory, "cache") + _ = os.MkdirAll(fileCacheDirectory, cacheDirectoryPermissions) hash := sha256.Sum256([]byte(key)) - fileName := fmt.Sprintf("%x.cache", hash) - return filepath.Join(cacheDirectory, fileName), nil + fileName := fmt.Sprintf("%x", hash) + return filepath.Join(fileCacheDirectory, fileName), nil } func NewFileCache() *FileCache { diff --git a/definitions/studio.yaml b/definitions/studio.yaml new file mode 100644 index 0000000..7d40e57 --- /dev/null +++ b/definitions/studio.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/identity_ +paths: + {} \ No newline at end of file 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..3410aa6 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" ) @@ -63,9 +64,10 @@ 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(), }, ), *configProvider, diff --git a/plugin/digitizer/digitize_command.go b/plugin/digitizer/digitize_command.go index 958b91b..58c09ba 100644 --- a/plugin/digitizer/digitize_command.go +++ b/plugin/digitizer/digitize_command.go @@ -298,3 +298,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 61e339d..8758163 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", "--file", "myfile"}, context) @@ -40,7 +40,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234"}, context) @@ -67,7 +67,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) @@ -87,7 +87,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) @@ -107,7 +107,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) @@ -137,7 +137,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(400, "validation error"). Build() @@ -168,7 +168,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() @@ -210,7 +210,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() @@ -246,7 +246,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() @@ -287,7 +287,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"}`). @@ -334,7 +334,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..2075776 --- /dev/null +++ b/plugin/external_plugin.go @@ -0,0 +1,109 @@ +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.cacheFilePath(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.Tick("downloading...") + 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.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + }) + return progressReader +} + +func (p ExternalPlugin) cacheFilePath(name string, url string) (string, error) { + cacheDirectory, err := utils.Directories{}.Cache() + if err != nil { + return "", err + } + hash := sha256.Sum256([]byte(url)) + subdirectory := fmt.Sprintf("%s-%x", name, hash) + pluginDirectory := filepath.Join(cacheDirectory, "plugins", subdirectory) + return pluginDirectory, 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..dd073e4 100644 --- a/plugin/orchestrator/download_command.go +++ b/plugin/orchestrator/download_command.go @@ -217,3 +217,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 d77c470..8ca65d3 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..d86b3a4 100644 --- a/plugin/orchestrator/upload_command.go +++ b/plugin/orchestrator/upload_command.go @@ -269,3 +269,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/package_pack_command.go b/plugin/studio/package_pack_command.go new file mode 100644 index 0000000..8ad74bd --- /dev/null +++ b/plugin/studio/package_pack_command.go @@ -0,0 +1,242 @@ +package studio + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "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" +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" + +// 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) +} + +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 + } + projectJson, err := c.readProjectJson(source) + if err != nil { + return err + } + version, _ := c.getParameter("package-version", context.Parameters) + if version == "" { + version = projectJson.ProjectVersion + } + + result, err := c.execute(source, destination, version, projectJson, 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(source string, destination string, version string, projectJson projectJson, debug bool, logger log.Logger) (*packagePackResult, error) { + uipcliPath, err := c.getUipcliPath(logger) + if err != nil { + return nil, err + } + + if !debug { + bar := c.newPackagingProgressBar(logger) + defer close(bar) + } + + path := uipcliPath + args := []string{"package", "pack", source, "--output", destination, "--version", version} + if !c.isWindows() { + path, err = exec.LookPath("dotnet") + if err != nil { + return nil, fmt.Errorf("Could not find dotnet runtime to run pack command: %v", err) + } + args = append([]string{uipcliPath}, args...) + } + + cmd := c.Exec.Command(path, args...) + 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() + + exitCode := cmd.ExitCode() + stderrOutput := stderrOutputBuilder.String() + status := "Succeeded" + var errorMessage *string + output := filepath.Join(destination, projectJson.Name+"."+version+".nupkg") + if exitCode != 0 { + status = "Failed" + output = "" + errorMessage = &stderrOutput + } + + result := newPackagePackResult( + projectJson.Name, + projectJson.Description, + projectJson.ProjectId, + version, + output, + status, + errorMessage) + return result, nil +} + +func (c PackagePackCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { + defer wg.Done() + _ = cmd.Wait() +} + +func (c PackagePackCommand) getUipcliPath(logger log.Logger) (string, error) { + externalPlugin := plugin.NewExternalPlugin(logger) + executable := "tools/uipcli.dll" + if c.isWindows() { + executable = "tools/uipcli.exe" + } + return externalPlugin.GetTool("uipcli", uipcliUrl, executable) +} + +func (c PackagePackCommand) newPackagingProgressBar(logger log.Logger) chan struct{} { + progressBar := utils.NewProgressBar(logger) + ticker := time.NewTicker(10 * time.Millisecond) + cancel := make(chan struct{}) + go func() { + for { + select { + case <-ticker.C: + progressBar.Tick("packaging... ") + 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") + } + 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) readProjectJson(path string) (projectJson, error) { + file, err := os.Open(path) + if err != nil { + return projectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + defer file.Close() + byteValue, err := io.ReadAll(file) + if err != nil { + return projectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + + var project projectJson + err = json.Unmarshal(byteValue, &project) + if err != nil { + return projectJson{}, fmt.Errorf("Error parsing %s file: %v", defaultProjectJson, err) + } + return project, 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") + } + 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, error) { + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + return data, nil + } + } + } + return "", fmt.Errorf("Could not find '%s' parameter", name) +} + +func (c PackagePackCommand) isWindows() bool { + return runtime.GOOS == "windows" +} + +func NewPackagePackCommand() *PackagePackCommand { + return &PackagePackCommand{utils.NewExecProcess()} +} diff --git a/plugin/studio/package_pack_result.go b/plugin/studio/package_pack_result.go new file mode 100644 index 0000000..0c8c717 --- /dev/null +++ b/plugin/studio/package_pack_result.go @@ -0,0 +1,15 @@ +package studio + +type packagePackResult struct { + Name string `json:"name"` + Description string `json:"description"` + ProjectId string `json:"projectId"` + Version string `json:"version"` + Output string `json:"output"` + Status string `json:"status"` + Error *string `json:"error"` +} + +func newPackagePackResult(name string, description string, projectId string, projectVersion string, output string, status string, err *string) *packagePackResult { + return &packagePackResult{name, description, projectId, projectVersion, output, status, err} +} diff --git a/plugin/studio/project/.gitignore b/plugin/studio/project/.gitignore new file mode 100644 index 0000000..658eff9 --- /dev/null +++ b/plugin/studio/project/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!Main.xaml +!project.json \ No newline at end of file diff --git a/plugin/studio/project/Main.xaml b/plugin/studio/project/Main.xaml new file mode 100644 index 0000000..2c90b2c --- /dev/null +++ b/plugin/studio/project/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/project/project.json b/plugin/studio/project/project.json new file mode 100644 index 0000000..77426bd --- /dev/null +++ b/plugin/studio/project/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/project_json.go b/plugin/studio/project_json.go new file mode 100644 index 0000000..41e4f1b --- /dev/null +++ b/plugin/studio/project_json.go @@ -0,0 +1,8 @@ +package studio + +type projectJson struct { + Name string `json:"name"` + Description string `json:"description"` + ProjectId string `json:"projectId"` + ProjectVersion string `json:"projectVersion"` +} diff --git a/plugin/studio/studio_plugin_test.go b/plugin/studio/studio_plugin_test.go new file mode 100644 index 0000000..90874a6 --- /dev/null +++ b/plugin/studio/studio_plugin_test.go @@ -0,0 +1,144 @@ +package studio + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" +) + +const studioDefinition = ` +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/identity_ +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 := studioProjectDirectory() + 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 TestPackNonExistentProject(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 := test.NewFakeExecProcess(1, "Build output", "There was an error") + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioProjectDirectory() + 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 TestPackSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioProjectDirectory() + 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 studioProjectDirectory() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "project") +} + +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/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/fake_exec_process.go b/test/fake_exec_process.go new file mode 100644 index 0000000..9d79751 --- /dev/null +++ b/test/fake_exec_process.go @@ -0,0 +1,52 @@ +package test + +import ( + "io" + "strings" + + "github.com/UiPath/uipathcli/utils" +) + +type FakeExecCmd struct { + StdOut io.ReadCloser + StdErr io.ReadCloser + Exit int +} + +func (c FakeExecCmd) StdoutPipe() (io.ReadCloser, error) { + return c.StdOut, nil +} + +func (c FakeExecCmd) StderrPipe() (io.ReadCloser, error) { + return c.StdErr, nil +} + +func (c FakeExecCmd) Start() error { + return nil +} + +func (c FakeExecCmd) Wait() error { + return nil +} + +func (c FakeExecCmd) ExitCode() int { + return c.Exit +} + +type FakeExecProcess struct { + Cmd utils.ExecCmd +} + +func (e FakeExecProcess) Command(name string, args ...string) utils.ExecCmd { + return e.Cmd +} + +func NewFakeExecProcess(exitCode int, stdout string, stderr string) *FakeExecProcess { + return &FakeExecProcess{ + Cmd: FakeExecCmd{ + StdOut: io.NopCloser(strings.NewReader(stdout)), + StdErr: io.NopCloser(strings.NewReader(stderr)), + Exit: exitCode, + }, + } +} diff --git a/utils/directories.go b/utils/directories.go new file mode 100644 index 0000000..bf91b38 --- /dev/null +++ b/utils/directories.go @@ -0,0 +1,18 @@ +package utils + +import ( + "os" + "path/filepath" +) + +type Directories struct { +} + +func (d Directories) Cache() (string, error) { + userCacheDirectory, err := os.UserCacheDir() + if err != nil { + return "", err + } + cacheDirectory := filepath.Join(userCacheDirectory, "uipath", "uipathcli") + return cacheDirectory, 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_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..8fa22c3 100644 --- a/utils/progress_bar.go +++ b/utils/progress_bar.go @@ -14,6 +14,22 @@ import ( type ProgressBar struct { logger log.Logger renderedLength int + position int +} + +func (b *ProgressBar) Tick(text string) { + position := b.position + 1 + if position > 100 { + position = 0 + } + b.logger.LogError("\r") + length := b.renderTick(text, position) + left := b.renderedLength - length + if left > 0 { + b.logger.LogError(strings.Repeat(" ", left)) + } + b.renderedLength = length + b.position = position } func (b *ProgressBar) Update(text string, current int64, total int64, bytesPerSecond int64) { @@ -33,6 +49,16 @@ func (b *ProgressBar) Remove() { } } +func (b ProgressBar) renderTick(text string, position int) int { + barCount := int(position / 5.0) + bar := strings.Repeat("█", barCount) + strings.Repeat(" ", 20-barCount) + output := fmt.Sprintf("%s |%s|", + text, + bar) + b.logger.LogError(output) + return utf8.RuneCountInString(output) +} + func (b ProgressBar) render(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) @@ -80,5 +106,5 @@ func (b ProgressBar) formatBytesInUnit(count int64, unit string) (string, string } func NewProgressBar(logger log.Logger) *ProgressBar { - return &ProgressBar{logger, 0} + return &ProgressBar{logger, 0, 0} }