From dfe45d2386232df838d3020611a3a532a280d851 Mon Sep 17 00:00:00 2001 From: Thomas Schmitt Date: Tue, 17 Dec 2024 11:05:00 +0200 Subject: [PATCH] Add support to analyze studio projects --- cache/file_cache.go | 6 +- main.go | 1 + plugin/external_plugin.go | 9 +- plugin/studio/analyze_result_json.go | 25 +++ plugin/studio/package_analyze_command.go | 273 +++++++++++++++++++++++ plugin/studio/package_analyze_result.go | 38 ++++ plugin/studio/package_pack_command.go | 35 +-- plugin/studio/studio_plugin_test.go | 78 +++++++ plugin/studio/uipcli.go | 56 +++++ utils/directories.go | 29 ++- 10 files changed, 507 insertions(+), 43 deletions(-) create mode 100644 plugin/studio/analyze_result_json.go create mode 100644 plugin/studio/package_analyze_command.go create mode 100644 plugin/studio/package_analyze_result.go create mode 100644 plugin/studio/uipcli.go diff --git a/cache/file_cache.go b/cache/file_cache.go index e7684dd..00a8822 100644 --- a/cache/file_cache.go +++ b/cache/file_cache.go @@ -14,7 +14,6 @@ import ( ) const cacheFilePermissions = 0600 -const cacheDirectoryPermissions = 0700 const separator = "|" // The FileCache stores data on disk in order to preserve them across @@ -68,12 +67,9 @@ func (c FileCache) cacheFilePath(key string) (string, error) { if err != nil { return "", err } - fileCacheDirectory := filepath.Join(cacheDirectory, "cache") - _ = os.MkdirAll(fileCacheDirectory, cacheDirectoryPermissions) - hash := sha256.Sum256([]byte(key)) fileName := fmt.Sprintf("%x", hash) - return filepath.Join(fileCacheDirectory, fileName), nil + return filepath.Join(cacheDirectory, fileName), nil } func NewFileCache() *FileCache { diff --git a/main.go b/main.go index 3410aa6..ddafbb3 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,7 @@ func main() { plugin_orchestrator.NewUploadCommand(), plugin_orchestrator.NewDownloadCommand(), plugin_studio.NewPackagePackCommand(), + plugin_studio.NewPackageAnalyzeCommand(), }, ), *configProvider, diff --git a/plugin/external_plugin.go b/plugin/external_plugin.go index 2075776..6b771c7 100644 --- a/plugin/external_plugin.go +++ b/plugin/external_plugin.go @@ -22,7 +22,7 @@ type ExternalPlugin struct { } func (p ExternalPlugin) GetTool(name string, url string, executable string) (string, error) { - pluginDirectory, err := p.cacheFilePath(name, url) + pluginDirectory, err := p.pluginDirectory(name, url) if err != nil { return "", fmt.Errorf("Could not download %s: %v", name, err) } @@ -88,15 +88,14 @@ func (p ExternalPlugin) progressReader(text string, completedText string, reader return progressReader } -func (p ExternalPlugin) cacheFilePath(name string, url string) (string, error) { - cacheDirectory, err := utils.Directories{}.Cache() +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) - pluginDirectory := filepath.Join(cacheDirectory, "plugins", subdirectory) - return pluginDirectory, nil + return filepath.Join(pluginDirectory, subdirectory), nil } func (p ExternalPlugin) randomFolderName() string { 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..6958f31 --- /dev/null +++ b/plugin/studio/package_analyze_command.go @@ -0,0 +1,273 @@ +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") + } + + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(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 { + 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{}) + go func() { + for { + select { + case <-ticker.C: + progressBar.Tick("analyzing... ") + 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") + } + 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, 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 PackageAnalyzeCommand) getBoolParameter(name string, parameters []plugin.ExecutionParameter) (bool, error) { + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(bool); ok { + return data, nil + } + } + } + return false, fmt.Errorf("Could not find '%s' parameter", name) +} + +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 index 26a0e4b..a663be9 100644 --- a/plugin/studio/package_pack_command.go +++ b/plugin/studio/package_pack_command.go @@ -8,9 +8,7 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" - "runtime" "strings" "sync" "time" @@ -22,8 +20,6 @@ import ( ) 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 { @@ -72,17 +68,11 @@ func (c PackagePackCommand) Execute(context plugin.ExecutionContext, writer outp } func (c PackagePackCommand) execute(params packagePackParams, 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", params.Source, "--output", params.Destination} if params.PackageVersion != "" { args = append(args, "--version", params.PackageVersion) @@ -100,15 +90,11 @@ func (c PackagePackCommand) execute(params packagePackParams, debug bool, logger args = append(args, "--releaseNotes", params.ReleaseNotes) } - if filepath.Ext(uipcliPath) == ".dll" { - 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...) + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(args...) + if err != nil { + return nil, err } - - cmd := c.Exec.Command(path, args...) stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("Could not run pack command: %v", err) @@ -193,15 +179,6 @@ func (c PackagePackCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { _ = 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) @@ -294,10 +271,6 @@ func (c PackagePackCommand) getBoolParameter(name string, parameters []plugin.Ex return false, 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/studio_plugin_test.go b/plugin/studio/studio_plugin_test.go index cc6f68f..16f2605 100644 --- a/plugin/studio/studio_plugin_test.go +++ b/plugin/studio/studio_plugin_test.go @@ -134,6 +134,84 @@ func TestPackSuccessfully(t *testing.T) { } } +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 TestAnalyzeSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + source := studioProjectDirectory() + 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 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 studioProjectDirectory() string { _, filename, _, _ := runtime.Caller(0) return filepath.Join(filepath.Dir(filename), "project") diff --git a/plugin/studio/uipcli.go b/plugin/studio/uipcli.go new file mode 100644 index 0000000..72f8851 --- /dev/null +++ b/plugin/studio/uipcli.go @@ -0,0 +1,56 @@ +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" + +type uipcli struct { + Exec utils.ExecProcess + Logger log.Logger +} + +func (c uipcli) Execute(args ...string) (utils.ExecCmd, error) { + uipcliPath, err := c.getPath() + 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() (string, error) { + externalPlugin := plugin.NewExternalPlugin(c.Logger) + executable := "tools/uipcli.dll" + if c.isWindows() { + executable = "tools/uipcli.exe" + } + return externalPlugin.GetTool("uipcli", uipcliUrl, 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/utils/directories.go b/utils/directories.go index bf91b38..b57caf6 100644 --- a/utils/directories.go +++ b/utils/directories.go @@ -5,14 +5,39 @@ import ( "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 } - cacheDirectory := filepath.Join(userCacheDirectory, "uipath", "uipathcli") - return cacheDirectory, nil + userDirectory := filepath.Join(userCacheDirectory, "uipath", "uipathcli") + _ = os.MkdirAll(userDirectory, directoryPermissions) + return userDirectory, nil }