├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── epic.md │ └── story.md └── workflows │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── banner.png ├── cmd ├── create │ └── create.go ├── delete │ └── delete.go ├── execute │ └── execute.go ├── files │ ├── files.go │ ├── filesCreate.go │ ├── filesDelete.go │ ├── filesGet.go │ └── filesList.go ├── get │ ├── get.go │ └── json_output.go ├── help │ └── help.go ├── investigate │ └── investigate.go ├── library │ ├── library.go │ ├── libraryList.go │ ├── libraryListModules.go │ ├── libraryListTools.go │ ├── libraryListWorkflows.go │ └── librarySearch.go ├── list │ └── list.go ├── output │ ├── config.go │ └── output.go ├── root.go ├── scripts │ ├── scripts.go │ ├── scriptsCreate.go │ ├── scriptsDelete.go │ ├── scriptsList.go │ └── scriptsUpdate.go ├── stop │ └── stop.go └── tools │ ├── tools.go │ ├── toolsCreate.go │ ├── toolsDelete.go │ ├── toolsList.go │ └── toolsUpdate.go ├── example-config.yaml ├── go.mod ├── go.sum ├── images ├── download.png ├── execute-file.png ├── execute-store.png ├── get.png ├── list-all.png ├── list-project.png ├── list-space.png ├── store-search.png ├── store-tools.png └── store-workflows.png ├── main.go ├── pkg ├── actions │ └── output.go ├── config │ ├── input.go │ ├── runconfig.go │ ├── url.go │ └── workflowrunspec.go ├── display │ ├── emoji.go │ ├── file.go │ ├── module.go │ ├── project.go │ ├── run │ │ ├── printer.go │ │ └── watcher.go │ ├── script.go │ ├── space.go │ ├── tool.go │ └── workflow.go ├── filesystem │ └── filesystem.go ├── stats │ └── stats.go ├── trickest │ ├── client.go │ ├── file.go │ ├── library.go │ ├── pagination.go │ ├── project.go │ ├── run.go │ ├── script.go │ ├── space.go │ ├── subjob.go │ ├── tool.go │ ├── user.go │ └── workflow.go ├── version │ └── version.go └── workflowbuilder │ ├── connection.go │ ├── lookup.go │ ├── node.go │ ├── parameter.go │ ├── primitive.go │ └── workflowinput.go ├── trickest-cli.png └── util └── util.go /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Issue for bugs 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Problem description 11 | 12 | #### Steps to reproduce 13 | 14 | #### Expected behaviour 15 | 16 | #### Screenshots if applicable 17 | 18 | #### Environment 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/epic.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Epic 3 | about: Issue for epics 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Description 11 | 12 | #### Epic specification link 13 | 14 | #### Issues in this epic: 15 | 16 | - [ ] Issue 1 17 | - [ ] Issue 2 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/story.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Story 3 | about: Issue for stories 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Description 11 | 12 | #### Epic specification link 13 | 14 | #### Story specification link 15 | 16 | #### Issues in this story: 17 | 18 | - [ ] Issue 1 19 | - [ ] Issue 2 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | releases-matrix: 7 | name: Release Go Binary 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | goos: [linux, darwin] 12 | goarch: ["386", amd64, arm64] 13 | exclude: 14 | - goarch: "386" 15 | goos: darwin 16 | - goarch: arm64 17 | goos: windows 18 | steps: 19 | - name: Get Release Info 20 | run: | 21 | echo "RELEASE_TAG=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 22 | echo "REPOSITORY_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 23 | echo "OS_NAME=${{ matrix.goos }}" >> $GITHUB_ENV 24 | - name: OS darwin 25 | if: matrix.goos == 'darwin' 26 | run: echo "OS_NAME=macOS" >> $GITHUB_ENV 27 | - uses: actions/checkout@v3 28 | - uses: wangyoucao577/go-release-action@v1.40 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | goos: ${{ matrix.goos }} 32 | goarch: ${{ matrix.goarch }} 33 | asset_name: '${{ env.REPOSITORY_NAME }}-${{ env.RELEASE_TAG }}-${{ env.OS_NAME }}-${{ matrix.goarch }}' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 golang:1.24-alpine as build 2 | 3 | RUN apk update 4 | 5 | COPY . /app 6 | 7 | WORKDIR /app 8 | 9 | RUN env GOOS=linux GOARCH=amd64 go build . 10 | 11 | FROM --platform=linux/amd64 alpine:3.21 12 | 13 | COPY --from=build /app/trickest-cli /usr/bin/trickest 14 | 15 | ENTRYPOINT ["trickest"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Trickest 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/banner.png -------------------------------------------------------------------------------- /cmd/create/create.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/trickest/trickest-cli/pkg/trickest" 9 | "github.com/trickest/trickest-cli/util" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type Config struct { 15 | Token string 16 | BaseURL string 17 | 18 | SpaceName string 19 | ProjectName string 20 | SpaceDescription string 21 | ProjectDescription string 22 | } 23 | 24 | var cfg = &Config{} 25 | 26 | func init() { 27 | CreateCmd.Flags().StringVar(&cfg.SpaceName, "space", "", "Name of the space to create") 28 | CreateCmd.Flags().StringVar(&cfg.SpaceDescription, "space-description", "", "Description for the space") 29 | CreateCmd.Flags().StringVar(&cfg.ProjectName, "project", "", "Name of the project to create") 30 | CreateCmd.Flags().StringVar(&cfg.ProjectDescription, "project-description", "", "Description for the project") 31 | } 32 | 33 | // CreateCmd represents the create command 34 | var CreateCmd = &cobra.Command{ 35 | Use: "create", 36 | Short: "Creates a space or a project on the Trickest platform", 37 | Long: ``, 38 | Run: func(cmd *cobra.Command, args []string) { 39 | if cfg.SpaceName == "" && cfg.ProjectName == "" { 40 | fmt.Fprintf(os.Stderr, "Error: space or project name is required\n") 41 | os.Exit(1) 42 | } 43 | cfg.Token = util.GetToken() 44 | cfg.BaseURL = util.Cfg.BaseUrl 45 | if err := run(cfg); err != nil { 46 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 47 | os.Exit(1) 48 | } 49 | }, 50 | } 51 | 52 | func run(cfg *Config) error { 53 | client, err := trickest.NewClient( 54 | trickest.WithToken(cfg.Token), 55 | trickest.WithBaseURL(cfg.BaseURL), 56 | ) 57 | if err != nil { 58 | return fmt.Errorf("failed to create client: %w", err) 59 | } 60 | 61 | ctx := context.Background() 62 | 63 | var space *trickest.Space 64 | spaceCreated := false 65 | if cfg.SpaceName != "" { 66 | space, err = client.GetSpaceByName(ctx, cfg.SpaceName) 67 | if err != nil { 68 | space, err = client.CreateSpace(ctx, cfg.SpaceName, cfg.SpaceDescription) 69 | if err != nil { 70 | return fmt.Errorf("failed to create space: %w", err) 71 | } 72 | spaceCreated = true 73 | } 74 | } 75 | 76 | projectCreated := false 77 | if cfg.ProjectName != "" { 78 | _, err := space.GetProjectByName(cfg.ProjectName) 79 | if err != nil { 80 | _, err = client.CreateProject(ctx, cfg.ProjectName, cfg.ProjectDescription, *space.ID) 81 | if err != nil { 82 | return fmt.Errorf("failed to create project: %w", err) 83 | } 84 | projectCreated = true 85 | } 86 | } 87 | 88 | if cfg.ProjectName != "" { 89 | if !projectCreated { 90 | return fmt.Errorf("project %q already exists", cfg.ProjectName) 91 | } 92 | } else if !spaceCreated { 93 | return fmt.Errorf("space %q already exists", cfg.SpaceName) 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /cmd/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/trickest/trickest-cli/pkg/config" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | "github.com/trickest/trickest-cli/util" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | type Config struct { 16 | Token string 17 | BaseURL string 18 | 19 | WorkflowSpec config.WorkflowRunSpec 20 | } 21 | 22 | var cfg = &Config{} 23 | 24 | // DeleteCmd represents the delete command 25 | var DeleteCmd = &cobra.Command{ 26 | Use: "delete", 27 | Short: "Deletes an object on the Trickest platform", 28 | Long: ``, 29 | Run: func(cmd *cobra.Command, args []string) { 30 | cfg.Token = util.GetToken() 31 | cfg.BaseURL = util.Cfg.BaseUrl 32 | cfg.WorkflowSpec = config.WorkflowRunSpec{ 33 | SpaceName: util.SpaceName, 34 | ProjectName: util.ProjectName, 35 | WorkflowName: util.WorkflowName, 36 | URL: util.URL, 37 | } 38 | if err := run(cfg); err != nil { 39 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 40 | os.Exit(1) 41 | } 42 | }, 43 | } 44 | 45 | func run(cfg *Config) error { 46 | client, err := trickest.NewClient( 47 | trickest.WithToken(cfg.Token), 48 | trickest.WithBaseURL(cfg.BaseURL), 49 | ) 50 | if err != nil { 51 | return fmt.Errorf("failed to create client: %w", err) 52 | } 53 | 54 | ctx := context.Background() 55 | 56 | if err := cfg.WorkflowSpec.ResolveSpaceAndProject(ctx, client); err != nil { 57 | return fmt.Errorf("failed to get space/project: %w", err) 58 | } 59 | 60 | var workflow *trickest.Workflow 61 | if cfg.WorkflowSpec.WorkflowName != "" || cfg.WorkflowSpec.URL != "" { 62 | workflow, err = cfg.WorkflowSpec.GetWorkflow(ctx, client) 63 | if err != nil { 64 | return fmt.Errorf("failed to get workflow: %w", err) 65 | } 66 | } 67 | 68 | // Delete only the innermost object found (workflow > project > space) 69 | switch { 70 | case workflow != nil: 71 | err = client.DeleteWorkflow(ctx, workflow.ID) 72 | if err != nil { 73 | return fmt.Errorf("failed to delete workflow: %w", err) 74 | } 75 | case cfg.WorkflowSpec.Project != nil: 76 | err = client.DeleteProject(ctx, *cfg.WorkflowSpec.Project.ID) 77 | if err != nil { 78 | return fmt.Errorf("failed to delete project: %w", err) 79 | } 80 | case cfg.WorkflowSpec.Space != nil: 81 | err = client.DeleteSpace(ctx, *cfg.WorkflowSpec.Space.ID) 82 | if err != nil { 83 | return fmt.Errorf("failed to delete space: %w", err) 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/files/files.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | Files string 9 | ) 10 | 11 | // filesCmd represents the files command 12 | var FilesCmd = &cobra.Command{ 13 | Use: "files", 14 | Short: "Manage files in the Trickest file storage", 15 | Long: ``, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | cmd.Help() 18 | }, 19 | } 20 | 21 | func init() { 22 | FilesCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { 23 | _ = FilesCmd.Flags().MarkHidden("workflow") 24 | _ = FilesCmd.Flags().MarkHidden("project") 25 | _ = FilesCmd.Flags().MarkHidden("space") 26 | _ = FilesCmd.Flags().MarkHidden("url") 27 | 28 | command.Root().HelpFunc()(command, strings) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/files/filesCreate.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | "github.com/trickest/trickest-cli/util" 11 | ) 12 | 13 | type CreateConfig struct { 14 | Token string 15 | BaseURL string 16 | 17 | FilePaths []string 18 | } 19 | 20 | var createCfg = &CreateConfig{} 21 | 22 | func init() { 23 | FilesCmd.AddCommand(filesCreateCmd) 24 | 25 | filesCreateCmd.Flags().StringSliceVar(&createCfg.FilePaths, "file", []string{}, "File(s) to upload") 26 | filesCreateCmd.MarkFlagRequired("file") 27 | } 28 | 29 | // filesCreateCmd represents the filesCreate command 30 | var filesCreateCmd = &cobra.Command{ 31 | Use: "create", 32 | Short: "Create files on the Trickest file storage", 33 | Long: "Create files on the Trickest file storage.\n" + 34 | "Note: If a file with the same name already exists, it will be overwritten.", 35 | Run: func(cmd *cobra.Command, args []string) { 36 | createCfg.Token = util.GetToken() 37 | createCfg.BaseURL = util.Cfg.BaseUrl 38 | if err := runCreate(createCfg); err != nil { 39 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 40 | os.Exit(1) 41 | } 42 | }, 43 | } 44 | 45 | func runCreate(cfg *CreateConfig) error { 46 | client, err := trickest.NewClient( 47 | trickest.WithToken(cfg.Token), 48 | trickest.WithBaseURL(cfg.BaseURL), 49 | ) 50 | if err != nil { 51 | return fmt.Errorf("failed to create client: %w", err) 52 | } 53 | 54 | ctx := context.Background() 55 | 56 | for _, filePath := range cfg.FilePaths { 57 | _, err := client.UploadFile(ctx, filePath, true) 58 | if err != nil { 59 | return fmt.Errorf("failed to upload file %s: %w", filePath, err) 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /cmd/files/filesDelete.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | "github.com/trickest/trickest-cli/util" 11 | ) 12 | 13 | type DeleteConfig struct { 14 | Token string 15 | BaseURL string 16 | 17 | FileNames []string 18 | } 19 | 20 | var deleteCfg = &DeleteConfig{} 21 | 22 | func init() { 23 | FilesCmd.AddCommand(filesDeleteCmd) 24 | 25 | filesDeleteCmd.Flags().StringSliceVar(&deleteCfg.FileNames, "file", []string{}, "File(s) to delete") 26 | filesDeleteCmd.MarkFlagRequired("file") 27 | } 28 | 29 | // filesDeleteCmd represents the filesDelete command 30 | var filesDeleteCmd = &cobra.Command{ 31 | Use: "delete", 32 | Short: "Delete files from the Trickest file storage", 33 | Long: ``, 34 | Run: func(cmd *cobra.Command, args []string) { 35 | deleteCfg.Token = util.GetToken() 36 | deleteCfg.BaseURL = util.Cfg.BaseUrl 37 | if err := runDelete(deleteCfg); err != nil { 38 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 39 | os.Exit(1) 40 | } 41 | }, 42 | } 43 | 44 | func runDelete(cfg *DeleteConfig) error { 45 | client, err := trickest.NewClient( 46 | trickest.WithToken(cfg.Token), 47 | trickest.WithBaseURL(cfg.BaseURL), 48 | ) 49 | if err != nil { 50 | return fmt.Errorf("failed to create client: %w", err) 51 | } 52 | 53 | ctx := context.Background() 54 | 55 | for _, fileName := range cfg.FileNames { 56 | file, err := client.GetFileByName(ctx, fileName) 57 | if err != nil { 58 | return fmt.Errorf("failed to get file: %w", err) 59 | } 60 | 61 | err = client.DeleteFile(ctx, file.ID) 62 | if err != nil { 63 | return fmt.Errorf("failed to delete file: %w", err) 64 | } 65 | 66 | fmt.Printf("Deleted file %q successfully\n", fileName) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/files/filesGet.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/trickest/trickest-cli/pkg/filesystem" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | ) 13 | 14 | type GetConfig struct { 15 | Token string 16 | BaseURL string 17 | 18 | FileNames []string 19 | OutputDir string 20 | PartialNameMatch bool 21 | } 22 | 23 | var getCfg = &GetConfig{} 24 | 25 | func init() { 26 | FilesCmd.AddCommand(filesGetCmd) 27 | 28 | filesGetCmd.Flags().StringSliceVar(&getCfg.FileNames, "file", []string{}, "File(s) to download") 29 | filesGetCmd.MarkFlagRequired("file") 30 | filesGetCmd.Flags().StringVar(&getCfg.OutputDir, "output-dir", ".", "Path to directory which should be used to store files") 31 | filesGetCmd.Flags().BoolVar(&getCfg.PartialNameMatch, "partial-name-match", false, "Get all files with a partial name match") 32 | } 33 | 34 | // filesGetCmd represents the filesGet command 35 | var filesGetCmd = &cobra.Command{ 36 | Use: "get", 37 | Short: "Get files from the Trickest file storage", 38 | Long: ``, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | getCfg.Token = util.GetToken() 41 | getCfg.BaseURL = util.Cfg.BaseUrl 42 | if err := runGet(getCfg); err != nil { 43 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 44 | os.Exit(1) 45 | } 46 | }, 47 | } 48 | 49 | func runGet(cfg *GetConfig) error { 50 | client, err := trickest.NewClient( 51 | trickest.WithToken(cfg.Token), 52 | trickest.WithBaseURL(cfg.BaseURL), 53 | ) 54 | if err != nil { 55 | return fmt.Errorf("failed to create client: %w", err) 56 | } 57 | 58 | ctx := context.Background() 59 | 60 | var files []trickest.File 61 | for _, fileName := range cfg.FileNames { 62 | if cfg.PartialNameMatch { 63 | matchingFiles, err := client.SearchFiles(ctx, fileName) 64 | if err != nil { 65 | return fmt.Errorf("failed to search for files: %w", err) 66 | } 67 | files = append(files, matchingFiles...) 68 | } else { 69 | file, err := client.GetFileByName(ctx, fileName) 70 | if err != nil { 71 | return fmt.Errorf("failed to get file: %w", err) 72 | } 73 | files = append(files, file) 74 | } 75 | } 76 | 77 | for _, file := range files { 78 | signedURL, err := client.GetFileSignedURL(ctx, file.ID) 79 | if err != nil { 80 | return fmt.Errorf("failed to get file signed URL: %w", err) 81 | } 82 | 83 | err = filesystem.DownloadFile(signedURL, cfg.OutputDir, file.Name, true) 84 | if err != nil { 85 | return fmt.Errorf("failed to download file: %w", err) 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/files/filesList.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | type ListConfig struct { 16 | Token string 17 | BaseURL string 18 | 19 | SearchQuery string 20 | JSONOutput bool 21 | } 22 | 23 | var listCfg = &ListConfig{} 24 | 25 | func init() { 26 | FilesCmd.AddCommand(filesListCmd) 27 | 28 | filesListCmd.Flags().StringVar(&listCfg.SearchQuery, "query", "", "Filter listed files using the specified search query") 29 | filesListCmd.Flags().BoolVar(&listCfg.JSONOutput, "json", false, "Display output in JSON format") 30 | } 31 | 32 | // filesListCmd represents the filesGet command 33 | var filesListCmd = &cobra.Command{ 34 | Use: "list", 35 | Short: "List files in the Trickest file storage", 36 | Long: ``, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | listCfg.Token = util.GetToken() 39 | listCfg.BaseURL = util.Cfg.BaseUrl 40 | if err := runList(listCfg); err != nil { 41 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 42 | os.Exit(1) 43 | } 44 | }, 45 | } 46 | 47 | func runList(cfg *ListConfig) error { 48 | client, err := trickest.NewClient( 49 | trickest.WithToken(cfg.Token), 50 | trickest.WithBaseURL(cfg.BaseURL), 51 | ) 52 | if err != nil { 53 | return fmt.Errorf("failed to create client: %w", err) 54 | } 55 | 56 | ctx := context.Background() 57 | 58 | files, err := client.SearchFiles(ctx, cfg.SearchQuery) 59 | if err != nil { 60 | return fmt.Errorf("failed to get files: %w", err) 61 | } 62 | 63 | if cfg.JSONOutput { 64 | data, err := json.Marshal(files) 65 | if err != nil { 66 | return fmt.Errorf("failed to marshall files: %w", err) 67 | } 68 | _, err = fmt.Fprintln(os.Stdout, string(data)) 69 | if err != nil { 70 | return fmt.Errorf("failed to print files: %w", err) 71 | } 72 | } else { 73 | err = display.PrintFiles(os.Stdout, files) 74 | if err != nil { 75 | return fmt.Errorf("failed to print files: %w", err) 76 | } 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | "github.com/trickest/trickest-cli/pkg/config" 12 | display "github.com/trickest/trickest-cli/pkg/display/run" 13 | "github.com/trickest/trickest-cli/pkg/stats" 14 | "github.com/trickest/trickest-cli/pkg/trickest" 15 | "github.com/trickest/trickest-cli/util" 16 | 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | // Config holds the configuration for the get command 21 | type Config struct { 22 | Token string 23 | BaseURL string 24 | 25 | Watch bool 26 | IncludePrimitiveNodes bool 27 | IncludeTaskGroupStats bool 28 | JSONOutput bool 29 | 30 | RunID string 31 | RunSpec config.WorkflowRunSpec 32 | } 33 | 34 | var cfg = &Config{} 35 | 36 | func init() { 37 | GetCmd.Flags().BoolVar(&cfg.Watch, "watch", false, "Watch the workflow execution if it's still running") 38 | GetCmd.Flags().BoolVar(&cfg.IncludePrimitiveNodes, "show-params", false, "Show parameters in the workflow tree") 39 | GetCmd.Flags().BoolVar(&cfg.IncludeTaskGroupStats, "analyze-task-groups", false, "Show detailed statistics for task groups, including task counts, status distribution, and duration analysis (min/max/median/outliers) (experimental)") 40 | GetCmd.Flags().StringVar(&cfg.RunID, "run", "", "Get the status of a specific run") 41 | GetCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 42 | } 43 | 44 | // GetCmd represents the get command 45 | var GetCmd = &cobra.Command{ 46 | Use: "get", 47 | Short: "Displays status of a workflow", 48 | Long: ``, 49 | Run: func(cmd *cobra.Command, args []string) { 50 | cfg.Token = util.GetToken() 51 | cfg.BaseURL = util.Cfg.BaseUrl 52 | cfg.RunSpec = config.WorkflowRunSpec{ 53 | RunID: cfg.RunID, 54 | SpaceName: util.SpaceName, 55 | ProjectName: util.ProjectName, 56 | WorkflowName: util.WorkflowName, 57 | URL: util.URL, 58 | } 59 | if err := run(cfg); err != nil { 60 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 61 | os.Exit(1) 62 | } 63 | }, 64 | } 65 | 66 | func run(cfg *Config) error { 67 | client, err := trickest.NewClient( 68 | trickest.WithToken(cfg.Token), 69 | trickest.WithBaseURL(cfg.BaseURL), 70 | ) 71 | 72 | if err != nil { 73 | return fmt.Errorf("failed to create client: %w", err) 74 | } 75 | 76 | ctx := context.Background() 77 | 78 | runs, err := cfg.RunSpec.GetRuns(ctx, client) 79 | if err != nil { 80 | return fmt.Errorf("failed to get run: %w", err) 81 | } 82 | if len(runs) != 1 { 83 | return fmt.Errorf("expected 1 run, got %d", len(runs)) 84 | } 85 | run := runs[0] 86 | 87 | err = displayRunDetails(ctx, client, &run, cfg) 88 | if err != nil { 89 | return fmt.Errorf("failed to handle run output: %w", err) 90 | } 91 | return nil 92 | } 93 | 94 | func displayRunDetails(ctx context.Context, client *trickest.Client, run *trickest.Run, cfg *Config) error { 95 | // Fetch the complete run details if the fleet information is missing 96 | // This happens when the run is retrieved from the workflow runs list which returns a simplified run object 97 | var err error 98 | if run.Fleet == nil { 99 | run, err = client.GetRun(ctx, *run.ID) 100 | if err != nil { 101 | return fmt.Errorf("failed to get run: %w", err) 102 | } 103 | } 104 | 105 | insights, err := client.GetRunSubJobInsights(ctx, *run.ID) 106 | if err != nil { 107 | fmt.Fprintf(os.Stderr, "Warning: Couldn't get the run insights: %s\n", err) 108 | } else { 109 | run.RunInsights = insights 110 | } 111 | 112 | averageDuration, err := client.GetWorkflowRunsAverageDuration(ctx, *run.WorkflowInfo) 113 | if err != nil { 114 | run.AverageDuration = &trickest.Duration{Duration: time.Duration(0)} 115 | } else { 116 | run.AverageDuration = &trickest.Duration{Duration: averageDuration} 117 | } 118 | 119 | fleet, err := client.GetFleet(ctx, *run.Fleet) 120 | if err != nil { 121 | fmt.Fprintf(os.Stderr, "Warning: Couldn't get the fleet: %s\n", err) 122 | } else { 123 | run.FleetName = fleet.Name 124 | } 125 | 126 | version, err := client.GetWorkflowVersion(ctx, *run.WorkflowVersionInfo) 127 | if err != nil { 128 | return fmt.Errorf("failed to get workflow version: %w", err) 129 | } 130 | subjobs, err := client.GetSubJobs(ctx, *run.ID) 131 | if err != nil { 132 | return fmt.Errorf("failed to get subjobs: %w", err) 133 | } 134 | subjobs = trickest.LabelSubJobs(subjobs, *version) 135 | 136 | ipAddresses, err := client.GetRunIPAddresses(ctx, *run.ID) 137 | if err != nil { 138 | fmt.Fprintf(os.Stderr, "Warning: Couldn't get the run IP addresses: %s\n", err) 139 | } else { 140 | run.IPAddresses = ipAddresses 141 | } 142 | 143 | if cfg.IncludeTaskGroupStats { 144 | for i := range subjobs { 145 | if subjobs[i].TaskGroup { 146 | childSubJobs, err := client.GetChildSubJobs(ctx, subjobs[i].ID) 147 | if err != nil { 148 | return fmt.Errorf("failed to get child subjobs: %w", err) 149 | } 150 | subjobs[i].Children = childSubJobs 151 | } 152 | } 153 | } 154 | 155 | if cfg.JSONOutput { 156 | var jsonRun *JSONRun 157 | if cfg.IncludeTaskGroupStats { 158 | taskGroupStatsMap := make(map[uuid.UUID]stats.TaskGroupStats) 159 | for _, subjob := range subjobs { 160 | if subjob.TaskGroup { 161 | taskGroupStatsMap[subjob.ID] = stats.CalculateTaskGroupStats(subjob) 162 | } 163 | } 164 | jsonRun = NewJSONRun(run, subjobs, taskGroupStatsMap) 165 | } else { 166 | jsonRun = NewJSONRun(run, subjobs, nil) 167 | } 168 | data, err := json.MarshalIndent(jsonRun, "", " ") 169 | if err != nil { 170 | return fmt.Errorf("failed to marshal run data: %w", err) 171 | } 172 | output := string(data) 173 | fmt.Println(output) 174 | } else { 175 | if cfg.Watch { 176 | watcher, err := display.NewRunWatcher( 177 | client, 178 | *run.ID, 179 | display.WithWorkflowVersion(version), 180 | display.WithIncludePrimitiveNodes(cfg.IncludePrimitiveNodes), 181 | display.WithIncludeTaskGroupStats(cfg.IncludeTaskGroupStats), 182 | ) 183 | if err != nil { 184 | return fmt.Errorf("failed to create run watcher: %w", err) 185 | } 186 | 187 | err = watcher.Watch(ctx) 188 | if err != nil { 189 | return fmt.Errorf("failed to watch run: %w", err) 190 | } 191 | } else { 192 | printer := display.NewRunPrinter(cfg.IncludePrimitiveNodes, os.Stdout) 193 | printer.PrintAll(run, subjobs, version, cfg.IncludeTaskGroupStats) 194 | } 195 | } 196 | return nil 197 | } 198 | -------------------------------------------------------------------------------- /cmd/get/json_output.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/trickest/trickest-cli/pkg/stats" 8 | "github.com/trickest/trickest-cli/pkg/trickest" 9 | ) 10 | 11 | // JSONRun represents a simplified version of a Run for JSON output 12 | type JSONRun struct { 13 | ID uuid.UUID `json:"id"` 14 | 15 | Status string `json:"status"` 16 | 17 | Author string `json:"author"` 18 | CreationType string `json:"creation_type"` 19 | 20 | CreatedDate *time.Time `json:"created_date"` 21 | StartedDate *time.Time `json:"started_date"` 22 | CompletedDate *time.Time `json:"completed_date"` 23 | 24 | Duration trickest.Duration `json:"duration"` 25 | AverageDuration trickest.Duration `json:"average_duration,omitempty"` 26 | 27 | WorkflowName string `json:"workflow_name"` 28 | WorkflowInfo uuid.UUID `json:"workflow_info"` 29 | WorkflowVersionInfo uuid.UUID `json:"workflow_version_info"` 30 | 31 | Fleet uuid.UUID `json:"fleet"` 32 | FleetName string `json:"fleet_name"` 33 | UseStaticIPs bool `json:"use_static_ips"` 34 | Machines int `json:"machines"` 35 | IPAddresses []string `json:"ip_addresses"` 36 | 37 | RunInsights *trickest.RunSubJobInsights `json:"run_insights,omitempty"` 38 | SubJobs []JSONSubJob `json:"subjobs"` 39 | } 40 | 41 | // JSONSubJob represents a simplified version of a SubJob for JSON output 42 | type JSONSubJob struct { 43 | Label string `json:"label,omitempty"` 44 | Name string `json:"name,omitempty"` 45 | 46 | Status string `json:"status"` 47 | Message string `json:"message,omitempty"` 48 | 49 | StartedDate *time.Time `json:"started_date,omitempty"` 50 | FinishedDate *time.Time `json:"finished_date,omitempty"` 51 | Duration trickest.Duration `json:"duration,omitempty"` 52 | 53 | IPAddress string `json:"ip_address,omitempty"` 54 | 55 | TaskGroup bool `json:"task_group,omitempty"` 56 | TaskCount int `json:"task_count,omitempty"` 57 | Children []JSONSubJob `json:"children,omitempty"` 58 | TaskIndex int `json:"task_index,omitempty"` 59 | TaskGroupStats *stats.TaskGroupStats `json:"task_group_stats,omitempty"` 60 | } 61 | 62 | // NewJSONRun creates a new JSONRun from a trickest.Run 63 | func NewJSONRun(run *trickest.Run, subjobs []trickest.SubJob, taskGroupStatsMap map[uuid.UUID]stats.TaskGroupStats) *JSONRun { 64 | jsonRun := &JSONRun{ 65 | ID: *run.ID, 66 | Status: run.Status, 67 | Author: run.Author, 68 | CreationType: run.CreationType, 69 | CreatedDate: run.CreatedDate, 70 | StartedDate: run.StartedDate, 71 | CompletedDate: run.CompletedDate, 72 | AverageDuration: *run.AverageDuration, 73 | WorkflowName: run.WorkflowName, 74 | WorkflowInfo: *run.WorkflowInfo, 75 | WorkflowVersionInfo: *run.WorkflowVersionInfo, 76 | Fleet: *run.Fleet, 77 | FleetName: run.FleetName, 78 | UseStaticIPs: *run.UseStaticIPs, 79 | IPAddresses: run.IPAddresses, 80 | RunInsights: run.RunInsights, 81 | } 82 | 83 | if run.Status == "RUNNING" { 84 | jsonRun.Duration = trickest.Duration{Duration: time.Since(*run.StartedDate)} 85 | } else { 86 | jsonRun.Duration = trickest.Duration{Duration: run.CompletedDate.Sub(*run.StartedDate)} 87 | } 88 | 89 | if run.Machines.Default != nil { 90 | jsonRun.Machines = *run.Machines.Default 91 | } else if run.Machines.SelfHosted != nil { 92 | jsonRun.Machines = *run.Machines.SelfHosted 93 | } 94 | 95 | jsonRun.SubJobs = make([]JSONSubJob, len(subjobs)) 96 | for i, subjob := range subjobs { 97 | jsonRun.SubJobs[i] = *NewJSONSubJob(&subjob, taskGroupStatsMap) 98 | } 99 | 100 | return jsonRun 101 | } 102 | 103 | // NewJSONSubJob creates a new JSONSubJob from a trickest.SubJob 104 | func NewJSONSubJob(subjob *trickest.SubJob, taskGroupStats map[uuid.UUID]stats.TaskGroupStats) *JSONSubJob { 105 | jsonSubJob := &JSONSubJob{ 106 | Label: subjob.Label, 107 | Name: subjob.Name, 108 | Status: subjob.Status, 109 | Message: subjob.Message, 110 | StartedDate: &subjob.StartedDate, 111 | FinishedDate: &subjob.FinishedDate, 112 | IPAddress: subjob.IPAddress, 113 | TaskGroup: subjob.TaskGroup, 114 | TaskIndex: subjob.TaskIndex, 115 | } 116 | 117 | if !subjob.StartedDate.IsZero() { 118 | if subjob.FinishedDate.IsZero() { 119 | jsonSubJob.FinishedDate = nil 120 | jsonSubJob.Duration = trickest.Duration{Duration: time.Since(subjob.StartedDate)} 121 | } else { 122 | jsonSubJob.Duration = trickest.Duration{Duration: subjob.FinishedDate.Sub(subjob.StartedDate)} 123 | } 124 | } else { 125 | jsonSubJob.StartedDate = nil 126 | } 127 | 128 | if len(subjob.Children) > 0 { 129 | jsonSubJob.TaskCount = len(subjob.Children) 130 | } 131 | 132 | if subjob.TaskGroup && taskGroupStats != nil && stats.HasInterestingStats(subjob.Name) { 133 | stats, ok := taskGroupStats[subjob.ID] 134 | if ok { 135 | jsonSubJob.TaskGroupStats = &stats 136 | } 137 | for _, child := range subjob.Children { 138 | jsonSubJob.Children = append(jsonSubJob.Children, *NewJSONSubJob(&child, nil)) 139 | } 140 | } 141 | 142 | return jsonSubJob 143 | } 144 | -------------------------------------------------------------------------------- /cmd/help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/charmbracelet/glamour" 13 | "github.com/trickest/trickest-cli/cmd/execute" 14 | "github.com/trickest/trickest-cli/pkg/config" 15 | display "github.com/trickest/trickest-cli/pkg/display/run" 16 | "github.com/trickest/trickest-cli/pkg/trickest" 17 | "github.com/trickest/trickest-cli/pkg/workflowbuilder" 18 | "github.com/trickest/trickest-cli/util" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | const ( 24 | defaultMachineCount = 10 25 | ) 26 | 27 | type Config struct { 28 | Token string 29 | BaseURL string 30 | 31 | WorkflowSpec config.WorkflowRunSpec 32 | } 33 | 34 | var cfg = &Config{} 35 | 36 | // HelpCmd represents the help command 37 | var HelpCmd = &cobra.Command{ 38 | Use: "help", 39 | Short: "Get help for a workflow", 40 | Long: ``, 41 | Run: func(cmd *cobra.Command, args []string) { 42 | if util.URL == "" && util.WorkflowName == "" { 43 | fmt.Println("Error: the workflow name or URL must be provided") 44 | os.Exit(1) 45 | } 46 | cfg.Token = util.GetToken() 47 | cfg.BaseURL = util.Cfg.BaseUrl 48 | cfg.WorkflowSpec = config.WorkflowRunSpec{ 49 | SpaceName: util.SpaceName, 50 | ProjectName: util.ProjectName, 51 | WorkflowName: util.WorkflowName, 52 | URL: util.URL, 53 | } 54 | if err := run(cfg); err != nil { 55 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 56 | os.Exit(1) 57 | } 58 | }, 59 | } 60 | 61 | func generateHelpMarkdown(workflow *trickest.Workflow, labeledPrimitiveNodes []*trickest.PrimitiveNode, labeledNodes []*trickest.Node, workflowSpec config.WorkflowRunSpec, runs []trickest.Run, maxMachines int) string { 62 | workflowURL := constructWorkflowURL(workflow) 63 | 64 | // Sort input nodes by their position on the workflow canvas on the Y axis (top to bottom) 65 | sort.Slice(labeledPrimitiveNodes, func(i, j int) bool { 66 | return labeledPrimitiveNodes[i].Coordinates.Y < labeledPrimitiveNodes[j].Coordinates.Y 67 | }) 68 | 69 | // Sort output nodes by their position on the workflow canvas on the X axis (right to left) 70 | sort.Slice(labeledNodes, func(i, j int) bool { 71 | return labeledNodes[i].Meta.Coordinates.X > labeledNodes[j].Meta.Coordinates.X 72 | }) 73 | 74 | var sb strings.Builder 75 | 76 | // Title 77 | sb.WriteString(fmt.Sprintf("# %s\n\n", workflow.Name)) 78 | 79 | // Description if it exists 80 | if workflow.Description != "" { 81 | sb.WriteString(fmt.Sprintf("%s\n\n", workflow.Description)) 82 | } 83 | 84 | runStats := []struct { 85 | date time.Time 86 | machines int 87 | duration time.Duration 88 | url string 89 | }{} 90 | for _, run := range runs { 91 | machines := run.Machines.Default 92 | if machines == nil { 93 | machines = run.Machines.SelfHosted 94 | } 95 | date := *run.StartedDate 96 | duration := run.CompletedDate.Sub(date) 97 | runURL := fmt.Sprintf("%s?run=%s", workflowURL, run.ID) 98 | runStats = append(runStats, struct { 99 | date time.Time 100 | machines int 101 | duration time.Duration 102 | url string 103 | }{date, *machines, duration, runURL}) 104 | } 105 | 106 | machineCount := defaultMachineCount 107 | if maxMachines > 0 && maxMachines < defaultMachineCount { 108 | machineCount = maxMachines 109 | } else if len(runStats) > 0 { 110 | highestMachineCount := 0 111 | for _, runStat := range runStats { 112 | if runStat.machines > highestMachineCount { 113 | highestMachineCount = runStat.machines 114 | } 115 | } 116 | machineCount = highestMachineCount 117 | } 118 | 119 | // Author info 120 | sb.WriteString(fmt.Sprintf("**Author:** %s\n\n", workflow.Author)) 121 | 122 | // Example command 123 | sb.WriteString("## Example Command\n\n") 124 | exampleCommand := fmt.Sprintf("%s execute", os.Args[0]) 125 | workflowRef := "" 126 | // Use the same reference format the user used to run this command 127 | if workflowSpec.URL != "" { 128 | workflowRef = fmt.Sprintf("--url \"%s\"", workflowURL) 129 | } else { 130 | workflowRef = fmt.Sprintf("--space \"%s\"", workflowSpec.SpaceName) 131 | if workflowSpec.ProjectName != "" { 132 | workflowRef += fmt.Sprintf(" --project \"%s\"", workflowSpec.ProjectName) 133 | } 134 | workflowRef += fmt.Sprintf(" --workflow \"%s\"", workflowSpec.WorkflowName) 135 | } 136 | exampleCommand += fmt.Sprintf(" %s", workflowRef) 137 | // Add inputs with example values 138 | for _, node := range labeledPrimitiveNodes { 139 | nodeValue := getPrimitiveNodeValue(node) 140 | if nodeValue == "" { 141 | nodeValue = fmt.Sprintf("<%s-value>", strings.ReplaceAll(node.Label, " ", "-")) 142 | } 143 | exampleCommand += fmt.Sprintf(" --input \"%s=%s\"", node.Label, nodeValue) 144 | } 145 | // Add the first output only to avoid cluttering the command too much 146 | if len(labeledNodes) > 0 { 147 | exampleCommand += fmt.Sprintf(" --output \"%s\"", labeledNodes[0].Meta.Label) 148 | } 149 | 150 | if machineCount > 1 { 151 | exampleCommand += fmt.Sprintf(" --machines %d", machineCount) 152 | } 153 | sb.WriteString(fmt.Sprintf("```bash\n%s\n```\n\n", exampleCommand)) 154 | 155 | // Inputs section 156 | if len(labeledPrimitiveNodes) > 0 { 157 | sb.WriteString("## Inputs\n\n") 158 | for _, node := range labeledPrimitiveNodes { 159 | inputLine := fmt.Sprintf("- `%s` (%s)", node.Label, strings.ToLower(node.Type)) 160 | nodeValue := getPrimitiveNodeValue(node) 161 | if nodeValue != "" { 162 | inputLine += fmt.Sprintf(" = %s", nodeValue) 163 | } 164 | sb.WriteString(fmt.Sprintf("%s\n", inputLine)) 165 | } 166 | sb.WriteString("\n\n") 167 | sb.WriteString("Use the `--input` flag to set the inputs you want to change.\n\n") 168 | } 169 | 170 | // Outputs section 171 | if len(labeledNodes) > 0 { 172 | sb.WriteString("## Outputs\n\n") 173 | labelCounts := make(map[string]int) 174 | for _, node := range labeledNodes { 175 | labelCounts[node.Meta.Label]++ 176 | } 177 | 178 | for _, node := range labeledNodes { 179 | if labelCounts[node.Meta.Label] == 1 { 180 | sb.WriteString(fmt.Sprintf("- `%s`\n", node.Meta.Label)) 181 | } 182 | } 183 | sb.WriteString("\n\n") 184 | sb.WriteString("Use the `--output` flag to specify the outputs you want to get.\n\n") 185 | } 186 | 187 | // Past runs section 188 | if len(runStats) > 0 { 189 | sb.WriteString("## Past Runs\n\n") 190 | sb.WriteString("| Started at | Machines | Duration | URL |\n") 191 | sb.WriteString("|------------|----------|----------|-----|\n") 192 | for _, runStat := range runStats { 193 | machines := runStat.machines 194 | date := runStat.date.Format("2006-01-02 15:04") 195 | duration := runStat.duration 196 | durationStr := display.FormatDuration(duration) 197 | runURL := runStat.url 198 | sb.WriteString(fmt.Sprintf("| %s | %d | %s | [View](%s) |\n", date, machines, durationStr, runURL)) 199 | } 200 | sb.WriteString("\n") 201 | sb.WriteString("Use the `--machines` flag to set the number of machines to run the workflow on.\n\n") 202 | } 203 | 204 | // Long description (README content) 205 | if workflow.LongDescription != "" { 206 | sb.WriteString("## Author's Notes\n\n") 207 | sb.WriteString(workflow.LongDescription) 208 | sb.WriteString("\n\n") 209 | } 210 | 211 | // Links to the workflow and execute docs 212 | sb.WriteString("## Links\n\n") 213 | sb.WriteString(fmt.Sprintf("- [View on Trickest](%s)\n", workflowURL)) 214 | sb.WriteString("- [Learn more about executing workflows](https://github.com/trickest/trickest-cli#execute)") 215 | 216 | return sb.String() 217 | } 218 | 219 | func run(cfg *Config) error { 220 | client, err := trickest.NewClient( 221 | trickest.WithToken(cfg.Token), 222 | trickest.WithBaseURL(cfg.BaseURL), 223 | ) 224 | if err != nil { 225 | return fmt.Errorf("failed to create client: %w", err) 226 | } 227 | 228 | ctx := context.Background() 229 | 230 | workflow, err := cfg.WorkflowSpec.GetWorkflow(ctx, client) 231 | if err != nil { 232 | return fmt.Errorf("failed to get workflow: %w", err) 233 | } 234 | 235 | workflowVersion, err := client.GetLatestWorkflowVersion(ctx, workflow.ID) 236 | if err != nil { 237 | return fmt.Errorf("failed to get workflow version: %w", err) 238 | } 239 | 240 | versionMaxMachines := 0 241 | fleet, err := client.GetFleetByName(ctx, execute.DefaultFleetName) 242 | if err == nil { 243 | maxMachines, err := client.GetWorkflowVersionMaxMachines(ctx, workflowVersion.ID, fleet.ID) 244 | if err == nil { 245 | if maxMachines.Default != nil { 246 | versionMaxMachines = *maxMachines.Default 247 | } 248 | if maxMachines.SelfHosted != nil { 249 | versionMaxMachines = *maxMachines.SelfHosted 250 | } 251 | } 252 | } 253 | 254 | labeledPrimitiveNodes, err := workflowbuilder.GetLabeledPrimitiveNodes(workflowVersion) 255 | if err != nil { 256 | return fmt.Errorf("failed to get labeled primitive nodes: %w", err) 257 | } 258 | 259 | labeledNodes, err := workflowbuilder.GetLabeledNodes(workflowVersion) 260 | if err != nil { 261 | return fmt.Errorf("failed to get labeled nodes: %w", err) 262 | } 263 | 264 | runs, err := client.GetRuns(ctx, workflow.ID, "COMPLETED", 5) 265 | if err != nil { 266 | return fmt.Errorf("failed to get runs: %w", err) 267 | } 268 | 269 | helpMarkdown := generateHelpMarkdown(workflow, labeledPrimitiveNodes, labeledNodes, cfg.WorkflowSpec, runs, versionMaxMachines) 270 | r, _ := glamour.NewTermRenderer( 271 | glamour.WithAutoStyle(), 272 | glamour.WithWordWrap(-1), 273 | ) 274 | out, err := r.Render(helpMarkdown) 275 | if err != nil { 276 | return fmt.Errorf("failed to render help output: %w", err) 277 | } 278 | fmt.Println(out) 279 | 280 | return nil 281 | } 282 | 283 | func constructWorkflowURL(workflow *trickest.Workflow) string { 284 | return fmt.Sprintf("https://trickest.io/editor/%s", workflow.ID) 285 | } 286 | 287 | func getPrimitiveNodeValue(node *trickest.PrimitiveNode) string { 288 | if node.Type == "BOOLEAN" { 289 | return strconv.FormatBool(node.Value.(bool)) 290 | } 291 | return fmt.Sprintf("%v", node.Value) 292 | } 293 | -------------------------------------------------------------------------------- /cmd/library/library.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | type Config struct { 8 | Token string 9 | BaseURL string 10 | 11 | JSONOutput bool 12 | } 13 | 14 | var cfg = &Config{} 15 | 16 | // LibraryCmd represents the library command 17 | var LibraryCmd = &cobra.Command{ 18 | Use: "library", 19 | Short: "Browse workflows and tools in the Trickest library", 20 | Long: ``, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | _ = cmd.Help() 23 | }, 24 | } 25 | 26 | func init() { 27 | LibraryCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { 28 | _ = command.Flags().MarkHidden("space") 29 | _ = command.Flags().MarkHidden("project") 30 | _ = command.Flags().MarkHidden("workflow") 31 | 32 | command.Root().HelpFunc()(command, strings) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/library/libraryList.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // libraryListCmd represents the libraryList command 8 | var libraryListCmd = &cobra.Command{ 9 | Use: "list", 10 | Short: "List modules,workflows, and tools from the Trickest library", 11 | Long: ``, 12 | Run: func(cmd *cobra.Command, args []string) { 13 | _ = cmd.Help() 14 | }, 15 | } 16 | 17 | func init() { 18 | LibraryCmd.AddCommand(libraryListCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/library/libraryListModules.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | // libraryListModulesCmd represents the libraryListModules command 16 | var libraryListModulesCmd = &cobra.Command{ 17 | Use: "modules", 18 | Short: "List modules from the Trickest library", 19 | Long: ``, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | cfg.Token = util.GetToken() 22 | cfg.BaseURL = util.Cfg.BaseUrl 23 | if err := runListModules(cfg); err != nil { 24 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 25 | os.Exit(1) 26 | } 27 | }, 28 | } 29 | 30 | func init() { 31 | libraryListCmd.AddCommand(libraryListModulesCmd) 32 | libraryListModulesCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 33 | } 34 | 35 | func runListModules(cfg *Config) error { 36 | client, err := trickest.NewClient( 37 | trickest.WithToken(cfg.Token), 38 | trickest.WithBaseURL(cfg.BaseURL), 39 | ) 40 | if err != nil { 41 | return fmt.Errorf("failed to create client: %w", err) 42 | } 43 | 44 | ctx := context.Background() 45 | 46 | modules, err := client.ListLibraryModules(ctx) 47 | if err != nil { 48 | return fmt.Errorf("failed to get modules: %w", err) 49 | } 50 | 51 | if len(modules) == 0 { 52 | return fmt.Errorf("couldn't find any module in the library") 53 | } 54 | 55 | if cfg.JSONOutput { 56 | data, err := json.Marshal(modules) 57 | if err != nil { 58 | return fmt.Errorf("failed to marshal modules: %w", err) 59 | } 60 | fmt.Println(string(data)) 61 | } else { 62 | err = display.PrintModules(os.Stdout, modules) 63 | if err != nil { 64 | return fmt.Errorf("failed to print modules: %w", err) 65 | } 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/library/libraryListTools.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | // libraryListToolsCmd represents the libraryListTools command 16 | var libraryListToolsCmd = &cobra.Command{ 17 | Use: "tools", 18 | Short: "List tools from the Trickest library", 19 | Long: ``, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | cfg.Token = util.GetToken() 22 | cfg.BaseURL = util.Cfg.BaseUrl 23 | if err := runListTools(cfg); err != nil { 24 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 25 | os.Exit(1) 26 | } 27 | }, 28 | } 29 | 30 | func init() { 31 | libraryListCmd.AddCommand(libraryListToolsCmd) 32 | libraryListToolsCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 33 | } 34 | 35 | func runListTools(cfg *Config) error { 36 | client, err := trickest.NewClient( 37 | trickest.WithToken(cfg.Token), 38 | trickest.WithBaseURL(cfg.BaseURL), 39 | ) 40 | if err != nil { 41 | return fmt.Errorf("failed to create client: %w", err) 42 | } 43 | 44 | ctx := context.Background() 45 | 46 | tools, err := client.ListLibraryTools(ctx) 47 | if err != nil { 48 | return fmt.Errorf("failed to get tools: %w", err) 49 | } 50 | 51 | if len(tools) == 0 { 52 | return fmt.Errorf("couldn't find any tool in the library") 53 | } 54 | 55 | if cfg.JSONOutput { 56 | data, err := json.Marshal(tools) 57 | if err != nil { 58 | return fmt.Errorf("failed to marshal tools: %w", err) 59 | } 60 | fmt.Println(string(data)) 61 | } else { 62 | err = display.PrintTools(os.Stdout, tools) 63 | if err != nil { 64 | return fmt.Errorf("failed to print tools: %w", err) 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/library/libraryListWorkflows.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | // libraryListWorkflowsCmd represents the libraryListWorkflows command 16 | var libraryListWorkflowsCmd = &cobra.Command{ 17 | Use: "workflows", 18 | Short: "List workflows from the Trickest library", 19 | Long: ``, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | cfg.Token = util.GetToken() 22 | cfg.BaseURL = util.Cfg.BaseUrl 23 | if err := runListWorkflows(cfg); err != nil { 24 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 25 | os.Exit(1) 26 | } 27 | }, 28 | } 29 | 30 | func init() { 31 | libraryListCmd.AddCommand(libraryListWorkflowsCmd) 32 | libraryListWorkflowsCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 33 | } 34 | 35 | func runListWorkflows(cfg *Config) error { 36 | client, err := trickest.NewClient( 37 | trickest.WithToken(cfg.Token), 38 | trickest.WithBaseURL(cfg.BaseURL), 39 | ) 40 | if err != nil { 41 | return fmt.Errorf("failed to create client: %w", err) 42 | } 43 | 44 | ctx := context.Background() 45 | 46 | workflows, err := client.ListLibraryWorkflows(ctx) 47 | if err != nil { 48 | return fmt.Errorf("failed to get workflows: %w", err) 49 | } 50 | 51 | if len(workflows) == 0 { 52 | return fmt.Errorf("couldn't find any workflow in the library") 53 | } 54 | 55 | if cfg.JSONOutput { 56 | data, err := json.Marshal(workflows) 57 | if err != nil { 58 | return fmt.Errorf("failed to marshal workflows: %w", err) 59 | } 60 | fmt.Println(string(data)) 61 | } else { 62 | err = display.PrintWorkflows(os.Stdout, workflows) 63 | if err != nil { 64 | return fmt.Errorf("failed to print workflows: %w", err) 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/library/librarySearch.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | // librarySearchCmd represents the librarySearch command 16 | var librarySearchCmd = &cobra.Command{ 17 | Use: "search", 18 | Short: "Search for workflows, modules, and tools in the Trickest library", 19 | Long: ``, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | if len(args) == 0 { 22 | fmt.Fprintf(os.Stderr, "Error: search query is required\n") 23 | os.Exit(1) 24 | } 25 | cfg.Token = util.GetToken() 26 | cfg.BaseURL = util.Cfg.BaseUrl 27 | search := args[0] 28 | if err := runSearch(cfg, search); err != nil { 29 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 30 | os.Exit(1) 31 | } 32 | }, 33 | } 34 | 35 | func init() { 36 | LibraryCmd.AddCommand(librarySearchCmd) 37 | librarySearchCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 38 | } 39 | 40 | func runSearch(cfg *Config, search string) error { 41 | client, err := trickest.NewClient( 42 | trickest.WithToken(cfg.Token), 43 | trickest.WithBaseURL(cfg.BaseURL), 44 | ) 45 | if err != nil { 46 | return fmt.Errorf("failed to create client: %w", err) 47 | } 48 | 49 | ctx := context.Background() 50 | 51 | modules, err := client.SearchLibraryModules(ctx, search) 52 | if err != nil { 53 | return fmt.Errorf("failed to search for modules: %w", err) 54 | } 55 | 56 | workflows, err := client.SearchLibraryWorkflows(ctx, search) 57 | if err != nil { 58 | return fmt.Errorf("failed to search for workflows: %w", err) 59 | } 60 | 61 | tools, err := client.SearchLibraryTools(ctx, search) 62 | if err != nil { 63 | return fmt.Errorf("failed to search for tools: %w", err) 64 | } 65 | 66 | if cfg.JSONOutput { 67 | results := map[string]interface{}{ 68 | "workflows": workflows, 69 | "modules": modules, 70 | "tools": tools, 71 | } 72 | data, err := json.Marshal(results) 73 | if err != nil { 74 | return fmt.Errorf("failed to marshal response data: %w", err) 75 | } 76 | fmt.Println(string(data)) 77 | } else { 78 | if len(modules) > 0 { 79 | err = display.PrintModules(os.Stdout, modules) 80 | if err != nil { 81 | return fmt.Errorf("failed to print modules: %w", err) 82 | } 83 | } 84 | if len(workflows) > 0 { 85 | err = display.PrintWorkflows(os.Stdout, workflows) 86 | if err != nil { 87 | return fmt.Errorf("failed to print workflows: %w", err) 88 | } 89 | } 90 | if len(tools) > 0 { 91 | err = display.PrintTools(os.Stdout, tools) 92 | if err != nil { 93 | return fmt.Errorf("failed to print tools: %w", err) 94 | } 95 | } 96 | if len(modules) == 0 && len(workflows) == 0 && len(tools) == 0 { 97 | return fmt.Errorf("no results found for search query: %s", search) 98 | } 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/trickest/trickest-cli/pkg/config" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type Config struct { 18 | Token string 19 | BaseURL string 20 | 21 | WorkflowSpec config.WorkflowRunSpec 22 | 23 | JSONOutput bool 24 | } 25 | 26 | var cfg = &Config{} 27 | 28 | func init() { 29 | ListCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 30 | } 31 | 32 | // ListCmd represents the list command 33 | var ListCmd = &cobra.Command{ 34 | Use: "list", 35 | Short: "Lists objects on the Trickest platform", 36 | Long: ``, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | cfg.Token = util.GetToken() 39 | cfg.BaseURL = util.Cfg.BaseUrl 40 | cfg.WorkflowSpec = config.WorkflowRunSpec{ 41 | SpaceName: util.SpaceName, 42 | ProjectName: util.ProjectName, 43 | WorkflowName: util.WorkflowName, 44 | URL: util.URL, 45 | } 46 | if err := run(cfg); err != nil { 47 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 48 | os.Exit(1) 49 | } 50 | }, 51 | } 52 | 53 | func run(cfg *Config) error { 54 | client, err := trickest.NewClient( 55 | trickest.WithToken(cfg.Token), 56 | trickest.WithBaseURL(cfg.BaseURL), 57 | ) 58 | if err != nil { 59 | return fmt.Errorf("failed to create client: %w", err) 60 | } 61 | 62 | ctx := context.Background() 63 | 64 | if cfg.WorkflowSpec.SpaceName == "" && cfg.WorkflowSpec.ProjectName == "" && cfg.WorkflowSpec.WorkflowName == "" && cfg.WorkflowSpec.URL == "" { 65 | spaces, err := client.GetSpaces(ctx, "") 66 | if err != nil { 67 | return fmt.Errorf("failed to get spaces: %w", err) 68 | } 69 | if cfg.JSONOutput { 70 | data, err := json.Marshal(spaces) 71 | if err != nil { 72 | return fmt.Errorf("failed to marshal spaces: %w", err) 73 | } 74 | fmt.Println(string(data)) 75 | } else { 76 | display.PrintSpaces(os.Stdout, spaces) 77 | } 78 | return nil 79 | } 80 | 81 | if err := cfg.WorkflowSpec.ResolveSpaceAndProject(ctx, client); err != nil { 82 | return fmt.Errorf("failed to get space/project: %w", err) 83 | } 84 | 85 | var workflow *trickest.Workflow 86 | if cfg.WorkflowSpec.WorkflowName != "" || cfg.WorkflowSpec.URL != "" { 87 | workflow, err = cfg.WorkflowSpec.GetWorkflow(ctx, client) 88 | if err != nil { 89 | return fmt.Errorf("failed to get workflow: %w", err) 90 | } 91 | } 92 | 93 | var output any 94 | if workflow != nil { 95 | output = workflow 96 | } else if cfg.WorkflowSpec.Project != nil { 97 | output = cfg.WorkflowSpec.Project 98 | } else if cfg.WorkflowSpec.Space != nil { 99 | output = cfg.WorkflowSpec.Space 100 | } 101 | 102 | if project, ok := output.(*trickest.Project); ok { 103 | workflows, err := client.GetWorkflows(ctx, *cfg.WorkflowSpec.Space.ID, *project.ID, "") 104 | if err != nil { 105 | return fmt.Errorf("failed to get project workflows: %w", err) 106 | } 107 | project.Workflows = workflows 108 | output = project 109 | } 110 | 111 | if cfg.JSONOutput { 112 | data, err := json.Marshal(output) 113 | if err != nil { 114 | return fmt.Errorf("failed to marshal data: %w", err) 115 | } 116 | fmt.Println(string(data)) 117 | return nil 118 | } 119 | 120 | switch v := output.(type) { 121 | case *trickest.Workflow: 122 | err = display.PrintWorkflow(os.Stdout, *v) 123 | case *trickest.Project: 124 | err = display.PrintProject(os.Stdout, *v) 125 | case *trickest.Space: 126 | err = display.PrintSpace(os.Stdout, *v) 127 | } 128 | 129 | if err != nil { 130 | return fmt.Errorf("failed to print object: %w", err) 131 | } 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /cmd/output/config.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/url" 7 | "os" 8 | "strings" 9 | 10 | "github.com/trickest/trickest-cli/pkg/config" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | // Config holds the configuration for the output command 15 | type Config struct { 16 | Token string 17 | BaseURL string 18 | 19 | ConfigFile string 20 | 21 | Nodes string 22 | Files string 23 | 24 | AllRuns bool 25 | NumberOfRuns int 26 | RunID string 27 | RunSpec config.WorkflowRunSpec 28 | 29 | OutputDir string 30 | } 31 | 32 | // OutputsConfig is the yaml configuration file format for the output command 33 | type OutputsConfig struct { 34 | Outputs []string `yaml:"outputs"` 35 | } 36 | 37 | // readNodesFromFile reads the nodes from the config file 38 | func (c *Config) readNodesFromFile() ([]string, error) { 39 | var nodes []string 40 | 41 | file, err := os.Open(c.ConfigFile) 42 | if err != nil { 43 | return nil, fmt.Errorf("couldn't open config file to read outputs: %w", err) 44 | } 45 | defer file.Close() 46 | 47 | bytes, err := io.ReadAll(file) 48 | if err != nil { 49 | return nil, fmt.Errorf("couldn't read outputs config: %w", err) 50 | } 51 | 52 | var conf OutputsConfig 53 | err = yaml.Unmarshal(bytes, &conf) 54 | if err != nil { 55 | return nil, fmt.Errorf("couldn't unmarshal outputs config: %w", err) 56 | } 57 | 58 | for _, node := range conf.Outputs { 59 | nodes = append(nodes, strings.ReplaceAll(node, "/", "-")) 60 | } 61 | 62 | return nodes, nil 63 | } 64 | 65 | // GetNodes returns the nodes to download from the command line flag, config file, or URL 66 | func (c *Config) GetNodes() []string { 67 | var nodes []string 68 | 69 | if c.Nodes != "" { 70 | for _, node := range strings.Split(c.Nodes, ",") { 71 | node = strings.TrimSpace(node) 72 | if node != "" { 73 | nodes = append(nodes, strings.ReplaceAll(node, "/", "-")) 74 | } 75 | } 76 | return nodes 77 | } 78 | 79 | if c.ConfigFile != "" { 80 | if fileNodes, err := c.readNodesFromFile(); err == nil { 81 | nodes = append(nodes, fileNodes...) 82 | return nodes 83 | } 84 | } 85 | 86 | if c.RunSpec.URL != "" { 87 | u, err := url.Parse(c.RunSpec.URL) 88 | if err == nil { 89 | queryParams, err := url.ParseQuery(u.RawQuery) 90 | if err == nil { 91 | if nodeParams, found := queryParams["node"]; found && len(nodeParams) == 1 { 92 | node := nodeParams[0] 93 | if node != "" { 94 | nodes = append(nodes, node) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | return nodes 102 | } 103 | 104 | // GetFiles returns the files to download from the command line flag 105 | func (c *Config) GetFiles() []string { 106 | if c.Files != "" { 107 | return strings.Split(c.Files, ",") 108 | } 109 | return []string{} 110 | } 111 | 112 | // GetOutputPath returns the output directory path, either from the config or constructed from space/project/workflow 113 | func (c *Config) GetOutputPath() string { 114 | if c.OutputDir != "" { 115 | return c.OutputDir 116 | } 117 | return c.formatPath() 118 | } 119 | 120 | // formatPath formats the path for the output command based on the space, project, and workflow names if they are provided 121 | func (c *Config) formatPath() string { 122 | path := strings.Trim(c.RunSpec.SpaceName, "/") 123 | if c.RunSpec.ProjectName != "" { 124 | path += "/" + strings.Trim(c.RunSpec.ProjectName, "/") 125 | } 126 | if c.RunSpec.WorkflowName != "" { 127 | path += "/" + strings.Trim(c.RunSpec.WorkflowName, "/") 128 | } 129 | return path 130 | } 131 | -------------------------------------------------------------------------------- /cmd/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/trickest/trickest-cli/pkg/actions" 9 | "github.com/trickest/trickest-cli/pkg/config" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var cfg = &Config{} 17 | 18 | func init() { 19 | OutputCmd.Flags().StringVar(&cfg.ConfigFile, "config", "", "YAML file to determine which nodes output(s) should be downloaded") 20 | OutputCmd.Flags().BoolVar(&cfg.AllRuns, "all", false, "Download output data for all runs") 21 | OutputCmd.Flags().IntVar(&cfg.NumberOfRuns, "runs", 1, "Number of recent runs which outputs should be downloaded") 22 | OutputCmd.Flags().StringVar(&cfg.RunID, "run", "", "Download output data of a specific run") 23 | OutputCmd.Flags().StringVar(&cfg.OutputDir, "output-dir", "", "Path to directory which should be used to store outputs") 24 | OutputCmd.Flags().StringVar(&cfg.Nodes, "nodes", "", "A comma-separated list of nodes whose outputs should be downloaded") 25 | OutputCmd.Flags().StringVar(&cfg.Files, "files", "", "A comma-separated list of file names that should be downloaded from the selected node") 26 | } 27 | 28 | // OutputCmd represents the download command 29 | var OutputCmd = &cobra.Command{ 30 | Use: "output", 31 | Short: "Download workflow outputs", 32 | Long: `This command downloads sub-job outputs of a completed workflow run. 33 | Downloaded files will be stored into space/project/workflow/run-timestamp directory. Every node will have it's own 34 | directory named after it's label or ID (if the label is not unique), and an optional prefix ("-") if it's 35 | connected to a splitter. 36 | 37 | Use raw command line arguments or a config file to specify which nodes' output you would like to fetch. 38 | If there is no node names specified, all outputs will be downloaded. 39 | 40 | The YAML config file should be formatted like: 41 | outputs: 42 | - foo 43 | - bar 44 | `, 45 | Run: func(cmd *cobra.Command, args []string) { 46 | cfg.Token = util.GetToken() 47 | cfg.BaseURL = util.Cfg.BaseUrl 48 | cfg.RunSpec = config.WorkflowRunSpec{ 49 | RunID: cfg.RunID, 50 | AllRuns: cfg.AllRuns, 51 | NumberOfRuns: cfg.NumberOfRuns, 52 | SpaceName: util.SpaceName, 53 | ProjectName: util.ProjectName, 54 | WorkflowName: util.WorkflowName, 55 | URL: util.URL, 56 | } 57 | if err := run(cfg); err != nil { 58 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 59 | os.Exit(1) 60 | } 61 | }, 62 | } 63 | 64 | func run(cfg *Config) error { 65 | client, err := trickest.NewClient( 66 | trickest.WithToken(cfg.Token), 67 | trickest.WithBaseURL(cfg.BaseURL), 68 | ) 69 | if err != nil { 70 | return fmt.Errorf("failed to create client: %w", err) 71 | } 72 | 73 | ctx := context.Background() 74 | runs, err := cfg.RunSpec.GetRuns(ctx, client) 75 | if err != nil { 76 | return fmt.Errorf("failed to get runs: %w", err) 77 | } 78 | if len(runs) == 0 { 79 | return fmt.Errorf("no runs found for the specified workflow") 80 | } 81 | 82 | nodes := cfg.GetNodes() 83 | files := cfg.GetFiles() 84 | path := cfg.GetOutputPath() 85 | 86 | for _, run := range runs { 87 | if err := actions.DownloadRunOutput(client, &run, nodes, files, path); err != nil { 88 | return fmt.Errorf("failed to download run output: %w", err) 89 | } 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/trickest/trickest-cli/cmd/create" 7 | "github.com/trickest/trickest-cli/cmd/delete" 8 | "github.com/trickest/trickest-cli/cmd/execute" 9 | "github.com/trickest/trickest-cli/cmd/files" 10 | "github.com/trickest/trickest-cli/cmd/get" 11 | "github.com/trickest/trickest-cli/cmd/help" 12 | "github.com/trickest/trickest-cli/cmd/investigate" 13 | "github.com/trickest/trickest-cli/cmd/library" 14 | "github.com/trickest/trickest-cli/cmd/list" 15 | "github.com/trickest/trickest-cli/cmd/output" 16 | "github.com/trickest/trickest-cli/cmd/scripts" 17 | "github.com/trickest/trickest-cli/cmd/stop" 18 | "github.com/trickest/trickest-cli/cmd/tools" 19 | "github.com/trickest/trickest-cli/pkg/version" 20 | "github.com/trickest/trickest-cli/util" 21 | 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // RootCmd represents the base command when called without any subcommands 26 | var RootCmd = &cobra.Command{ 27 | Use: "trickest", 28 | Short: "Trickest client for platform access from your local machine", 29 | Long: ``, 30 | Run: func(cmd *cobra.Command, args []string) { 31 | _ = cmd.Help() 32 | }, 33 | Version: version.Version, 34 | } 35 | 36 | // Execute adds all child commands to the root command and sets flags appropriately. 37 | // This is called by main.main(). It only needs to happen once to the rootCmd. 38 | func Execute() { 39 | log.SetFlags(0) 40 | cobra.CheckErr(RootCmd.Execute()) 41 | } 42 | 43 | func init() { 44 | RootCmd.PersistentFlags().StringVar(&util.Cfg.User.Token, "token", "", "Trickest authentication token") 45 | RootCmd.PersistentFlags().StringVar(&util.Cfg.User.TokenFilePath, "token-file", "", "Trickest authentication token file") 46 | RootCmd.PersistentFlags().StringVar(&util.SpaceName, "space", "", "Space name") 47 | RootCmd.PersistentFlags().StringVar(&util.ProjectName, "project", "", "Project name") 48 | RootCmd.PersistentFlags().StringVar(&util.WorkflowName, "workflow", "", "Workflow name") 49 | RootCmd.PersistentFlags().StringVar(&util.URL, "url", "", "URL for referencing a workflow, project, or space") 50 | RootCmd.PersistentFlags().StringVar(&util.Cfg.Dependency, "node-dependency", "", "This flag doesn't affect the execution logic of the CLI in any way and is intended for controlling node execution order on the Trickest platform only.") 51 | RootCmd.PersistentFlags().StringVar(&util.Cfg.BaseUrl, "api-endpoint", "https://api.trickest.io", "The base Trickest platform API endpoint.") 52 | 53 | RootCmd.AddCommand(list.ListCmd) 54 | RootCmd.AddCommand(library.LibraryCmd) 55 | RootCmd.AddCommand(create.CreateCmd) 56 | RootCmd.AddCommand(delete.DeleteCmd) 57 | RootCmd.AddCommand(output.OutputCmd) 58 | RootCmd.AddCommand(execute.ExecuteCmd) 59 | RootCmd.AddCommand(get.GetCmd) 60 | RootCmd.AddCommand(files.FilesCmd) 61 | RootCmd.AddCommand(tools.ToolsCmd) 62 | RootCmd.AddCommand(scripts.ScriptsCmd) 63 | RootCmd.AddCommand(stop.StopCmd) 64 | RootCmd.AddCommand(help.HelpCmd) 65 | RootCmd.AddCommand(investigate.InvestigateCmd) 66 | 67 | RootCmd.SetVersionTemplate(`{{printf "Trickest CLI %s\n" .Version}}`) 68 | } 69 | -------------------------------------------------------------------------------- /cmd/scripts/scripts.go: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ScriptsCmd = &cobra.Command{ 8 | Use: "scripts", 9 | Short: "Manage private scripts", 10 | Long: ``, 11 | Run: func(cmd *cobra.Command, args []string) { 12 | cmd.Help() 13 | }, 14 | } 15 | 16 | func init() { 17 | ScriptsCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { 18 | _ = ScriptsCmd.Flags().MarkHidden("workflow") 19 | _ = ScriptsCmd.Flags().MarkHidden("project") 20 | _ = ScriptsCmd.Flags().MarkHidden("space") 21 | _ = ScriptsCmd.Flags().MarkHidden("url") 22 | 23 | command.Root().HelpFunc()(command, strings) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/scripts/scriptsCreate.go: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | "github.com/trickest/trickest-cli/util" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type CreateConfig struct { 15 | Token string 16 | BaseURL string 17 | 18 | FilePath string 19 | } 20 | 21 | var createCfg = &CreateConfig{} 22 | 23 | func init() { 24 | ScriptsCmd.AddCommand(scriptsCreateCmd) 25 | 26 | scriptsCreateCmd.Flags().StringVar(&createCfg.FilePath, "file", "", "YAML file for script definition") 27 | scriptsCreateCmd.MarkFlagRequired("file") 28 | } 29 | 30 | var scriptsCreateCmd = &cobra.Command{ 31 | Use: "create", 32 | Short: "Create a new private script", 33 | Long: ``, 34 | Run: func(cmd *cobra.Command, args []string) { 35 | createCfg.Token = util.GetToken() 36 | createCfg.BaseURL = util.Cfg.BaseUrl 37 | if err := runCreate(createCfg); err != nil { 38 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 39 | os.Exit(1) 40 | } 41 | }, 42 | } 43 | 44 | func runCreate(cfg *CreateConfig) error { 45 | data, err := os.ReadFile(cfg.FilePath) 46 | if err != nil { 47 | return fmt.Errorf("failed to read %s: %w", cfg.FilePath, err) 48 | } 49 | 50 | client, err := trickest.NewClient(trickest.WithToken(cfg.Token), trickest.WithBaseURL(cfg.BaseURL)) 51 | if err != nil { 52 | return fmt.Errorf("failed to create client: %w", err) 53 | } 54 | 55 | ctx := context.Background() 56 | 57 | var scriptImportRequest trickest.ScriptImport 58 | err = yaml.Unmarshal(data, &scriptImportRequest) 59 | if err != nil { 60 | return fmt.Errorf("failed to parse %s: %w", cfg.FilePath, err) 61 | } 62 | 63 | _, err = client.CreatePrivateScript(ctx, &scriptImportRequest) 64 | if err != nil { 65 | return fmt.Errorf("failed to create script: %w", err) 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/scripts/scriptsDelete.go: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | ) 13 | 14 | type DeleteConfig struct { 15 | Token string 16 | BaseURL string 17 | 18 | ScriptID string 19 | ScriptName string 20 | } 21 | 22 | var deleteCfg = &DeleteConfig{} 23 | 24 | func init() { 25 | ScriptsCmd.AddCommand(scriptsDeleteCmd) 26 | 27 | scriptsDeleteCmd.Flags().StringVar(&deleteCfg.ScriptID, "id", "", "ID of the script to delete") 28 | scriptsDeleteCmd.Flags().StringVar(&deleteCfg.ScriptName, "name", "", "Name of the script to delete") 29 | } 30 | 31 | var scriptsDeleteCmd = &cobra.Command{ 32 | Use: "delete", 33 | Short: "Delete a private script", 34 | Long: ``, 35 | Run: func(cmd *cobra.Command, args []string) { 36 | if deleteCfg.ScriptName == "" && deleteCfg.ScriptID == "" { 37 | fmt.Fprintf(os.Stderr, "Error: script ID or name is required\n") 38 | os.Exit(1) 39 | } 40 | 41 | if deleteCfg.ScriptID != "" && deleteCfg.ScriptName != "" { 42 | fmt.Fprintf(os.Stderr, "Error: script ID and name cannot both be provided\n") 43 | os.Exit(1) 44 | } 45 | 46 | deleteCfg.Token = util.GetToken() 47 | deleteCfg.BaseURL = util.Cfg.BaseUrl 48 | if err := runDelete(deleteCfg); err != nil { 49 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 50 | os.Exit(1) 51 | } 52 | }, 53 | } 54 | 55 | func runDelete(cfg *DeleteConfig) error { 56 | client, err := trickest.NewClient( 57 | trickest.WithToken(cfg.Token), 58 | trickest.WithBaseURL(cfg.BaseURL), 59 | ) 60 | if err != nil { 61 | return fmt.Errorf("failed to create client: %w", err) 62 | } 63 | 64 | ctx := context.Background() 65 | 66 | var scriptID uuid.UUID 67 | 68 | if deleteCfg.ScriptID != "" { 69 | scriptID, err = uuid.Parse(deleteCfg.ScriptID) 70 | if err != nil { 71 | return fmt.Errorf("failed to parse script ID: %w", err) 72 | } 73 | } else { 74 | script, err := client.GetPrivateScriptByName(ctx, deleteCfg.ScriptName) 75 | if err != nil { 76 | return fmt.Errorf("failed to find script: %w", err) 77 | } 78 | scriptID = *script.ID 79 | } 80 | 81 | err = client.DeletePrivateScript(ctx, scriptID) 82 | if err != nil { 83 | return fmt.Errorf("failed to delete script: %w", err) 84 | } 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /cmd/scripts/scriptsList.go: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | type Config struct { 16 | Token string 17 | BaseURL string 18 | 19 | JSONOutput bool 20 | } 21 | 22 | var cfg = &Config{} 23 | 24 | var scriptsListCmd = &cobra.Command{ 25 | Use: "list", 26 | Short: "List private scripts", 27 | Long: ``, 28 | Run: func(cmd *cobra.Command, args []string) { 29 | cfg.Token = util.GetToken() 30 | cfg.BaseURL = util.Cfg.BaseUrl 31 | if err := runList(cfg); err != nil { 32 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 33 | os.Exit(1) 34 | } 35 | }, 36 | } 37 | 38 | func init() { 39 | ScriptsCmd.AddCommand(scriptsListCmd) 40 | 41 | scriptsListCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 42 | } 43 | 44 | func runList(cfg *Config) error { 45 | client, err := trickest.NewClient( 46 | trickest.WithToken(cfg.Token), 47 | trickest.WithBaseURL(cfg.BaseURL), 48 | ) 49 | if err != nil { 50 | return fmt.Errorf("failed to create client: %w", err) 51 | } 52 | 53 | ctx := context.Background() 54 | 55 | scripts, err := client.ListPrivateScripts(ctx) 56 | if err != nil { 57 | return fmt.Errorf("failed to list scripts: %w", err) 58 | } 59 | 60 | if len(scripts) == 0 { 61 | return fmt.Errorf("couldn't find any private scripts") 62 | } 63 | 64 | if cfg.JSONOutput { 65 | data, err := json.Marshal(scripts) 66 | if err != nil { 67 | return fmt.Errorf("failed to marshal scripts: %w", err) 68 | } 69 | fmt.Println(string(data)) 70 | } else { 71 | err = display.PrintScripts(os.Stdout, scripts) 72 | if err != nil { 73 | return fmt.Errorf("failed to print scripts: %w", err) 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /cmd/scripts/scriptsUpdate.go: -------------------------------------------------------------------------------- 1 | package scripts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type UpdateConfig struct { 16 | Token string 17 | BaseURL string 18 | 19 | FilePath string 20 | ScriptID string 21 | ScriptName string 22 | } 23 | 24 | var updateCfg = &UpdateConfig{} 25 | 26 | func init() { 27 | ScriptsCmd.AddCommand(scriptsUpdateCmd) 28 | 29 | scriptsUpdateCmd.Flags().StringVar(&updateCfg.FilePath, "file", "", "YAML file for script definition") 30 | scriptsUpdateCmd.MarkFlagRequired("file") 31 | scriptsUpdateCmd.Flags().StringVar(&updateCfg.ScriptID, "id", "", "ID of the script to update") 32 | scriptsUpdateCmd.Flags().StringVar(&updateCfg.ScriptName, "name", "", "Name of the script to update") 33 | } 34 | 35 | var scriptsUpdateCmd = &cobra.Command{ 36 | Use: "update", 37 | Short: "Update a private script", 38 | Long: `Update a private script by specifying either its ID or name. If neither is provided, the script name will be read from the YAML file.`, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | updateCfg.Token = util.GetToken() 41 | updateCfg.BaseURL = util.Cfg.BaseUrl 42 | if err := runUpdate(updateCfg); err != nil { 43 | fmt.Printf("Error: %s\n", err) 44 | os.Exit(1) 45 | } 46 | }, 47 | } 48 | 49 | func runUpdate(cfg *UpdateConfig) error { 50 | data, err := os.ReadFile(cfg.FilePath) 51 | if err != nil { 52 | return fmt.Errorf("failed to read %s: %w", cfg.FilePath, err) 53 | } 54 | 55 | client, err := trickest.NewClient(trickest.WithToken(cfg.Token), trickest.WithBaseURL(cfg.BaseURL)) 56 | if err != nil { 57 | return fmt.Errorf("failed to create client: %w", err) 58 | } 59 | 60 | ctx := context.Background() 61 | 62 | var scriptImportRequest trickest.ScriptImport 63 | err = yaml.Unmarshal(data, &scriptImportRequest) 64 | if err != nil { 65 | return fmt.Errorf("failed to parse %s: %w", cfg.FilePath, err) 66 | } 67 | var scriptID uuid.UUID 68 | if cfg.ScriptID != "" { 69 | scriptID, err = uuid.Parse(cfg.ScriptID) 70 | if err != nil { 71 | return fmt.Errorf("failed to parse script ID: %w", err) 72 | } 73 | } else { 74 | scriptName := cfg.ScriptName 75 | if scriptName == "" { 76 | scriptName = scriptImportRequest.Name 77 | } 78 | script, err := client.GetPrivateScriptByName(ctx, scriptName) 79 | if err != nil { 80 | return fmt.Errorf("failed to find script: %w", err) 81 | } 82 | scriptID = *script.ID 83 | } 84 | 85 | _, err = client.UpdatePrivateScript(ctx, &scriptImportRequest, scriptID) 86 | if err != nil { 87 | return fmt.Errorf("failed to update script: %w", err) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/stop/stop.go: -------------------------------------------------------------------------------- 1 | package stop 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/google/uuid" 11 | "github.com/spf13/cobra" 12 | "github.com/trickest/trickest-cli/pkg/config" 13 | "github.com/trickest/trickest-cli/pkg/trickest" 14 | "github.com/trickest/trickest-cli/util" 15 | ) 16 | 17 | type Config struct { 18 | Token string 19 | BaseURL string 20 | 21 | RunSpec config.WorkflowRunSpec 22 | Nodes []string 23 | ChildrenRanges []string 24 | Children []int 25 | } 26 | 27 | var cfg = &Config{} 28 | 29 | func init() { 30 | StopCmd.Flags().BoolVar(&cfg.RunSpec.AllRuns, "all", false, "Stop all runs") 31 | StopCmd.Flags().StringVar(&cfg.RunSpec.RunID, "run", "", "Stop a specific run") 32 | StopCmd.Flags().StringSliceVar(&cfg.Nodes, "nodes", []string{}, "Nodes to stop. If none specified, the entire run will be stopped. If a node is a task group, the `--child` flag must be used (can be used multiple times)") 33 | StopCmd.Flags().StringSliceVar(&cfg.ChildrenRanges, "child", []string{}, "Child tasks to stop. If a node is a task group, the `--child` flag must be used (can be used multiple times)") 34 | } 35 | 36 | // StopCmd represents the stop command 37 | var StopCmd = &cobra.Command{ 38 | Use: "stop", 39 | Short: "Stop a workflow/node", 40 | Long: ``, 41 | Run: func(cmd *cobra.Command, args []string) { 42 | cfg.Token = util.GetToken() 43 | cfg.BaseURL = util.Cfg.BaseUrl 44 | cfg.RunSpec.ProjectName = util.ProjectName 45 | cfg.RunSpec.SpaceName = util.SpaceName 46 | cfg.RunSpec.WorkflowName = util.WorkflowName 47 | cfg.RunSpec.URL = util.URL 48 | if err := run(cfg); err != nil { 49 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 50 | os.Exit(1) 51 | } 52 | }, 53 | } 54 | 55 | func run(cfg *Config) error { 56 | if len(cfg.Nodes) == 0 { 57 | node, err := config.GetNodeIDFromWorkflowURL(util.URL) 58 | if err == nil { 59 | cfg.Nodes = []string{node} 60 | } 61 | } 62 | 63 | for _, childRange := range cfg.ChildrenRanges { 64 | if !strings.Contains(childRange, "-") { 65 | child, err := strconv.Atoi(childRange) 66 | if err == nil { 67 | cfg.Children = append(cfg.Children, child) 68 | } 69 | continue 70 | } 71 | 72 | rangeParts := strings.Split(childRange, "-") 73 | if len(rangeParts) != 2 { 74 | return fmt.Errorf("invalid child range: %s", childRange) 75 | } 76 | 77 | start, err1 := strconv.Atoi(rangeParts[0]) 78 | end, err2 := strconv.Atoi(rangeParts[1]) 79 | if err1 != nil || err2 != nil || start > end { 80 | return fmt.Errorf("invalid child range: %s", childRange) 81 | } 82 | 83 | for i := start; i <= end; i++ { 84 | cfg.Children = append(cfg.Children, i) 85 | } 86 | } 87 | 88 | client, err := trickest.NewClient( 89 | trickest.WithToken(cfg.Token), 90 | trickest.WithBaseURL(cfg.BaseURL), 91 | ) 92 | if err != nil { 93 | return fmt.Errorf("failed to create client: %w", err) 94 | } 95 | 96 | ctx := context.Background() 97 | 98 | cfg.RunSpec.RunStatus = "RUNNING" 99 | runs, err := cfg.RunSpec.GetRuns(ctx, client) 100 | if err != nil { 101 | return fmt.Errorf("failed to get runs: %w", err) 102 | } 103 | 104 | var errs []error 105 | for _, run := range runs { 106 | if len(cfg.Nodes) == 0 && len(cfg.Children) == 0 { 107 | err := client.StopRun(ctx, *run.ID) 108 | if err != nil { 109 | errs = append(errs, fmt.Errorf("failed to stop run %s: %w", run.ID, err)) 110 | } else { 111 | fmt.Printf("Successfully sent stop request for run %s\n", run.ID) 112 | } 113 | continue 114 | } 115 | 116 | subJobs, err := client.GetSubJobs(ctx, *run.ID) 117 | if err != nil { 118 | return fmt.Errorf("failed to get subjobs for run %s: %w", run.ID.String(), err) 119 | } 120 | 121 | version, err := client.GetWorkflowVersion(ctx, *run.WorkflowVersionInfo) 122 | if err != nil { 123 | return fmt.Errorf("failed to get workflow version for run %s: %w", run.ID.String(), err) 124 | } 125 | subJobs = trickest.LabelSubJobs(subJobs, *version) 126 | 127 | matchingSubJobs, err := trickest.FilterSubJobs(subJobs, cfg.Nodes) 128 | if err != nil { 129 | return fmt.Errorf("no running nodes matching your query were found in the run %s: %w", run.ID.String(), err) 130 | } 131 | 132 | for _, subJob := range matchingSubJobs { 133 | if !subJob.TaskGroup { 134 | if subJob.Status != "RUNNING" { 135 | errs = append(errs, fmt.Errorf("cannot stop node %q (%s) - current status is %q", subJob.Label, subJob.Name, subJob.Status)) 136 | continue 137 | } 138 | if isModule(subJob) { 139 | errs = append(errs, fmt.Errorf("cannot stop node %q (%s) - modules cannot be stopped individually; you must stop the entire run", subJob.Label, subJob.Name)) 140 | continue 141 | } 142 | err := client.StopSubJob(ctx, subJob.ID) 143 | if err != nil { 144 | errs = append(errs, fmt.Errorf("failed to stop node %q (%s) in run %s: %w", subJob.Label, subJob.Name, run.ID, err)) 145 | continue 146 | } else { 147 | fmt.Printf("Successfully sent stop request for node %q (%s) in run %s\n", subJob.Label, subJob.Name, run.ID) 148 | } 149 | } else { 150 | if len(cfg.Children) == 0 { 151 | errs = append(errs, fmt.Errorf("node %q (%s) is a task group - use the --child flag to specify which task(s) to stop (e.g. --child 1 or --child 1-3)", subJob.Label, subJob.Name)) 152 | continue 153 | } 154 | for _, child := range cfg.Children { 155 | subJobChild, err := client.GetChildSubJob(ctx, subJob.ID, child) 156 | if err != nil { 157 | errs = append(errs, fmt.Errorf("failed to get child %d of node %q (%s): %w", child, subJob.Label, subJob.Name, err)) 158 | continue 159 | } 160 | 161 | if subJobChild.Status != "RUNNING" { 162 | errs = append(errs, fmt.Errorf("cannot stop child %d of node %q (%s) - current status is %q", child, subJob.Label, subJob.Name, subJobChild.Status)) 163 | continue 164 | } 165 | 166 | err = client.StopSubJob(ctx, subJobChild.ID) 167 | if err != nil { 168 | errs = append(errs, fmt.Errorf("failed to stop child %d of node %q (%s) in run %s: %w", child, subJob.Label, subJob.Name, run.ID, err)) 169 | continue 170 | } else { 171 | fmt.Printf("Successfully sent stop request for child %d of node %q (%s) in run %s\n", child, subJob.Label, subJob.Name, run.ID) 172 | } 173 | } 174 | } 175 | } 176 | } 177 | 178 | if len(errs) > 0 { 179 | return fmt.Errorf("errors encountered while sending stop requests:\n%s", strings.Join(func() []string { 180 | var msgs []string 181 | for _, err := range errs { 182 | msgs = append(msgs, " - "+err.Error()) 183 | } 184 | return msgs 185 | }(), "\n")) 186 | } 187 | return nil 188 | } 189 | 190 | func isModule(subJob trickest.SubJob) bool { 191 | return subJob.TWEid == uuid.Nil 192 | } 193 | -------------------------------------------------------------------------------- /cmd/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func init() { 8 | ToolsCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { 9 | _ = ToolsCmd.Flags().MarkHidden("workflow") 10 | _ = ToolsCmd.Flags().MarkHidden("project") 11 | _ = ToolsCmd.Flags().MarkHidden("space") 12 | _ = ToolsCmd.Flags().MarkHidden("url") 13 | 14 | command.Root().HelpFunc()(command, strings) 15 | }) 16 | } 17 | 18 | var ToolsCmd = &cobra.Command{ 19 | Use: "tools", 20 | Short: "Manage private tools", 21 | Long: ``, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | cmd.Help() 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /cmd/tools/toolsCreate.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | "github.com/trickest/trickest-cli/util" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type CreateConfig struct { 15 | Token string 16 | BaseURL string 17 | 18 | FilePath string 19 | } 20 | 21 | var createCfg = &CreateConfig{} 22 | 23 | func init() { 24 | ToolsCmd.AddCommand(toolsCreateCmd) 25 | 26 | toolsCreateCmd.Flags().StringVar(&createCfg.FilePath, "file", "", "YAML file for tool definition") 27 | toolsCreateCmd.MarkFlagRequired("file") 28 | } 29 | 30 | var toolsCreateCmd = &cobra.Command{ 31 | Use: "create", 32 | Short: "Create a new private tool integration", 33 | Long: ``, 34 | Run: func(cmd *cobra.Command, args []string) { 35 | createCfg.Token = util.GetToken() 36 | createCfg.BaseURL = util.Cfg.BaseUrl 37 | if err := runCreate(createCfg); err != nil { 38 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 39 | os.Exit(1) 40 | } 41 | }, 42 | } 43 | 44 | func runCreate(cfg *CreateConfig) error { 45 | data, err := os.ReadFile(cfg.FilePath) 46 | if err != nil { 47 | return fmt.Errorf("failed to read %s: %w", cfg.FilePath, err) 48 | } 49 | 50 | client, err := trickest.NewClient(trickest.WithToken(cfg.Token), trickest.WithBaseURL(cfg.BaseURL)) 51 | if err != nil { 52 | return fmt.Errorf("failed to create client: %w", err) 53 | } 54 | 55 | ctx := context.Background() 56 | 57 | var toolImportRequest trickest.ToolImport 58 | err = yaml.Unmarshal(data, &toolImportRequest) 59 | if err != nil { 60 | return fmt.Errorf("failed to parse %s: %w", cfg.FilePath, err) 61 | } 62 | 63 | _, err = client.CreatePrivateTool(ctx, &toolImportRequest) 64 | if err != nil { 65 | return fmt.Errorf("failed to create tool: %w", err) 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/tools/toolsDelete.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | ) 13 | 14 | type DeleteConfig struct { 15 | Token string 16 | BaseURL string 17 | 18 | ToolID string 19 | ToolName string 20 | } 21 | 22 | var deleteCfg = &DeleteConfig{} 23 | 24 | func init() { 25 | ToolsCmd.AddCommand(toolsDeleteCmd) 26 | 27 | toolsDeleteCmd.Flags().StringVar(&deleteCfg.ToolID, "id", "", "ID of the tool to delete") 28 | toolsDeleteCmd.Flags().StringVar(&deleteCfg.ToolName, "name", "", "Name of the tool to delete") 29 | } 30 | 31 | var toolsDeleteCmd = &cobra.Command{ 32 | Use: "delete", 33 | Short: "Delete a private tool integration", 34 | Long: ``, 35 | Run: func(cmd *cobra.Command, args []string) { 36 | if deleteCfg.ToolName == "" && deleteCfg.ToolID == "" { 37 | fmt.Fprintf(os.Stderr, "Error: tool ID or name is required\n") 38 | os.Exit(1) 39 | } 40 | 41 | if deleteCfg.ToolID != "" && deleteCfg.ToolName != "" { 42 | fmt.Fprintf(os.Stderr, "Error: tool ID and name cannot both be provided\n") 43 | os.Exit(1) 44 | } 45 | 46 | deleteCfg.Token = util.GetToken() 47 | deleteCfg.BaseURL = util.Cfg.BaseUrl 48 | if err := runDelete(deleteCfg); err != nil { 49 | fmt.Printf("Error: %s\n", err) 50 | os.Exit(1) 51 | } 52 | }, 53 | } 54 | 55 | func runDelete(cfg *DeleteConfig) error { 56 | client, err := trickest.NewClient(trickest.WithToken(cfg.Token), trickest.WithBaseURL(cfg.BaseURL)) 57 | if err != nil { 58 | return fmt.Errorf("failed to create client: %w", err) 59 | } 60 | 61 | ctx := context.Background() 62 | 63 | var toolID uuid.UUID 64 | if cfg.ToolID != "" { 65 | toolID, err = uuid.Parse(cfg.ToolID) 66 | if err != nil { 67 | return fmt.Errorf("failed to parse tool ID: %w", err) 68 | } 69 | } else { 70 | tool, err := client.GetPrivateToolByName(ctx, cfg.ToolName) 71 | if err != nil { 72 | return fmt.Errorf("failed to find tool: %w", err) 73 | } 74 | toolID = *tool.ID 75 | } 76 | 77 | err = client.DeletePrivateTool(ctx, toolID) 78 | if err != nil { 79 | return fmt.Errorf("failed to delete tool: %w", err) 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /cmd/tools/toolsList.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/display" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | "github.com/trickest/trickest-cli/util" 13 | ) 14 | 15 | type Config struct { 16 | Token string 17 | BaseURL string 18 | 19 | JSONOutput bool 20 | } 21 | 22 | var cfg = &Config{} 23 | 24 | var toolsListCmd = &cobra.Command{ 25 | Use: "list", 26 | Short: "List private tool integrations", 27 | Long: ``, 28 | Run: func(cmd *cobra.Command, args []string) { 29 | cfg.Token = util.GetToken() 30 | cfg.BaseURL = util.Cfg.BaseUrl 31 | if err := runList(cfg); err != nil { 32 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 33 | os.Exit(1) 34 | } 35 | }, 36 | } 37 | 38 | func init() { 39 | ToolsCmd.AddCommand(toolsListCmd) 40 | 41 | toolsListCmd.Flags().BoolVar(&cfg.JSONOutput, "json", false, "Display output in JSON format") 42 | } 43 | 44 | func runList(cfg *Config) error { 45 | client, err := trickest.NewClient( 46 | trickest.WithToken(cfg.Token), 47 | trickest.WithBaseURL(cfg.BaseURL), 48 | ) 49 | if err != nil { 50 | return fmt.Errorf("failed to create client: %w", err) 51 | } 52 | 53 | ctx := context.Background() 54 | tools, err := client.ListPrivateTools(ctx) 55 | if err != nil { 56 | return fmt.Errorf("failed to list tools: %w", err) 57 | } 58 | 59 | if len(tools) == 0 { 60 | return fmt.Errorf("couldn't find any private tools") 61 | } 62 | 63 | if cfg.JSONOutput { 64 | data, err := json.Marshal(tools) 65 | if err != nil { 66 | return fmt.Errorf("failed to marshal tools: %w", err) 67 | } 68 | fmt.Println(string(data)) 69 | } else { 70 | err = display.PrintTools(os.Stdout, tools) 71 | if err != nil { 72 | return fmt.Errorf("failed to print tools: %w", err) 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /cmd/tools/toolsUpdate.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | "github.com/spf13/cobra" 10 | "github.com/trickest/trickest-cli/pkg/trickest" 11 | "github.com/trickest/trickest-cli/util" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type UpdateConfig struct { 16 | Token string 17 | BaseURL string 18 | 19 | FilePath string 20 | ToolID string 21 | ToolName string 22 | } 23 | 24 | var updateCfg = &UpdateConfig{} 25 | 26 | func init() { 27 | ToolsCmd.AddCommand(toolsUpdateCmd) 28 | 29 | toolsUpdateCmd.Flags().StringVar(&updateCfg.FilePath, "file", "", "YAML file for tool definition") 30 | toolsUpdateCmd.MarkFlagRequired("file") 31 | toolsUpdateCmd.Flags().StringVar(&updateCfg.ToolID, "id", "", "ID of the tool to update") 32 | toolsUpdateCmd.Flags().StringVar(&updateCfg.ToolName, "name", "", "Name of the tool to update") 33 | } 34 | 35 | var toolsUpdateCmd = &cobra.Command{ 36 | Use: "update", 37 | Short: "Update a private tool integration", 38 | Long: `Update a private tool integration by specifying either its ID or name. If neither is provided, the tool name will be read from the YAML file.`, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | updateCfg.Token = util.GetToken() 41 | updateCfg.BaseURL = util.Cfg.BaseUrl 42 | if err := runUpdate(updateCfg); err != nil { 43 | fmt.Printf("Error: %s\n", err) 44 | os.Exit(1) 45 | } 46 | }, 47 | } 48 | 49 | func runUpdate(cfg *UpdateConfig) error { 50 | data, err := os.ReadFile(cfg.FilePath) 51 | if err != nil { 52 | return fmt.Errorf("failed to read %s: %w", cfg.FilePath, err) 53 | } 54 | 55 | client, err := trickest.NewClient(trickest.WithToken(cfg.Token), trickest.WithBaseURL(cfg.BaseURL)) 56 | if err != nil { 57 | return fmt.Errorf("failed to create client: %w", err) 58 | } 59 | 60 | ctx := context.Background() 61 | 62 | var toolImportRequest trickest.ToolImport 63 | err = yaml.Unmarshal(data, &toolImportRequest) 64 | if err != nil { 65 | return fmt.Errorf("failed to parse %s: %w", cfg.FilePath, err) 66 | } 67 | 68 | var toolID uuid.UUID 69 | if cfg.ToolID != "" { 70 | toolID, err = uuid.Parse(cfg.ToolID) 71 | if err != nil { 72 | return fmt.Errorf("failed to parse tool ID: %w", err) 73 | } 74 | } else { 75 | toolName := cfg.ToolName 76 | if toolName == "" { 77 | toolName = toolImportRequest.Name 78 | } 79 | tool, err := client.GetPrivateToolByName(ctx, toolName) 80 | if err != nil { 81 | return fmt.Errorf("failed to find tool: %w", err) 82 | } 83 | toolID = *tool.ID 84 | } 85 | 86 | _, err = client.UpdatePrivateTool(ctx, &toolImportRequest, toolID) 87 | if err != nil { 88 | return fmt.Errorf("failed to update tool: %w", err) 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /example-config.yaml: -------------------------------------------------------------------------------- 1 | input: # Input values for workflow nodes. Multiple formats are supported. 2 | domain: example.com # input-node-alias 3 | string-input-1: example.com # input-node-id 4 | 5 | amass-1.domain: example.com # executable-node-id.parameter-name 6 | enum-subdomains.domain: example.com # executable-node-alias.parameter-name 7 | 8 | enum-subdomains.domain: # multiple inputs for the same executable node and parameter 9 | - example.com 10 | - example.net 11 | 12 | output: # List of nodes whose outputs will be downloaded 13 | - zip-to-out-1 # executable-node-id 14 | - report # executable-node-alias 15 | # output: report # A single output is also supported 16 | 17 | machines: 1 # Number of machines to assign to the workflow 18 | fleet: "Managed fleet" # Name of the fleet to use for execution 19 | use-static-ips: true # Use static IP addresses for execution 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/trickest/trickest-cli 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/charmbracelet/glamour v0.9.1 9 | github.com/google/uuid v1.3.0 10 | github.com/gosuri/uilive v0.0.4 11 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 12 | github.com/schollz/progressbar/v3 v3.13.1 13 | github.com/spf13/cobra v1.7.0 14 | github.com/xlab/treeprint v1.2.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 21 | github.com/aymerick/douceur v0.2.0 // indirect 22 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 23 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 24 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 26 | github.com/charmbracelet/x/term v0.2.1 // indirect 27 | github.com/dlclark/regexp2 v1.11.0 // indirect 28 | github.com/gorilla/css v1.0.1 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 31 | github.com/mattn/go-isatty v0.0.20 // indirect 32 | github.com/mattn/go-runewidth v0.0.16 // indirect 33 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 34 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 35 | github.com/muesli/reflow v0.3.0 // indirect 36 | github.com/muesli/termenv v0.16.0 // indirect 37 | github.com/rivo/uniseg v0.4.7 // indirect 38 | github.com/spf13/pflag v1.0.5 // indirect 39 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 40 | github.com/yuin/goldmark v1.7.8 // indirect 41 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 42 | golang.org/x/net v0.33.0 // indirect 43 | golang.org/x/sys v0.31.0 // indirect 44 | golang.org/x/term v0.30.0 // indirect 45 | golang.org/x/text v0.23.0 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 2 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 4 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 12 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= 16 | github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= 17 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 18 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 19 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 20 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 22 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 23 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= 24 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 25 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 26 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 27 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 28 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 32 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 33 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 34 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 35 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 36 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 37 | github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= 38 | github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= 39 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 40 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 41 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 42 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 43 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 44 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 45 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 46 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 47 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 48 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 49 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 50 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 51 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 52 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 53 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 54 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 55 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 56 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 57 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 58 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 59 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 60 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 61 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 62 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 63 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 67 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 68 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 69 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 70 | github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= 71 | github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= 72 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 73 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 74 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 75 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 76 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 77 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 78 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 79 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 80 | github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= 81 | github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= 82 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 84 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 85 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 86 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 87 | github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 88 | github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 89 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 90 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 91 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 92 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 93 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 96 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 97 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 98 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 99 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 100 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 101 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 102 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 103 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 105 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 106 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | -------------------------------------------------------------------------------- /images/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/download.png -------------------------------------------------------------------------------- /images/execute-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/execute-file.png -------------------------------------------------------------------------------- /images/execute-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/execute-store.png -------------------------------------------------------------------------------- /images/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/get.png -------------------------------------------------------------------------------- /images/list-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/list-all.png -------------------------------------------------------------------------------- /images/list-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/list-project.png -------------------------------------------------------------------------------- /images/list-space.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/list-space.png -------------------------------------------------------------------------------- /images/store-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/store-search.png -------------------------------------------------------------------------------- /images/store-tools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/store-tools.png -------------------------------------------------------------------------------- /images/store-workflows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/images/store-workflows.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/trickest/trickest-cli/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /pkg/actions/output.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/google/uuid" 10 | "github.com/trickest/trickest-cli/pkg/filesystem" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | ) 13 | 14 | func DownloadRunOutput(client *trickest.Client, run *trickest.Run, nodes []string, files []string, destinationPath string) error { 15 | if run.Status == "PENDING" || run.Status == "SUBMITTED" { 16 | return fmt.Errorf("run %s has not started yet (status: %s)", run.ID.String(), run.Status) 17 | } 18 | 19 | ctx := context.Background() 20 | 21 | subJobs, err := client.GetSubJobs(ctx, *run.ID) 22 | if err != nil { 23 | return fmt.Errorf("failed to get subjobs for run %s: %w", run.ID.String(), err) 24 | } 25 | 26 | version, err := client.GetWorkflowVersion(ctx, *run.WorkflowVersionInfo) 27 | if err != nil { 28 | return fmt.Errorf("could not get workflow version for run %s: %w", run.ID.String(), err) 29 | } 30 | subJobs = trickest.LabelSubJobs(subJobs, *version) 31 | 32 | matchingSubJobs, err := trickest.FilterSubJobs(subJobs, nodes) 33 | if err != nil { 34 | return fmt.Errorf("no completed node outputs matching your query were found in the run %s: %w", run.ID.String(), err) 35 | } 36 | 37 | runDir, err := filesystem.CreateRunDir(destinationPath, *run) 38 | if err != nil { 39 | return fmt.Errorf("failed to create directory for run %s: %w", run.ID.String(), err) 40 | } 41 | 42 | for _, subJob := range matchingSubJobs { 43 | isModule := version.Data.Nodes[subJob.Name].Type == "WORKFLOW" 44 | if err := downloadSubJobOutput(client, runDir, &subJob, files, run.ID, isModule); err != nil { 45 | return fmt.Errorf("failed to download output for node %s: %w", subJob.Label, err) 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func downloadSubJobOutput(client *trickest.Client, savePath string, subJob *trickest.SubJob, files []string, runID *uuid.UUID, isModule bool) error { 53 | if !subJob.TaskGroup && subJob.Status != "SUCCEEDED" { 54 | return fmt.Errorf("subjob %s (ID: %s) is not completed (status: %s)", subJob.Label, subJob.ID, subJob.Status) 55 | } 56 | 57 | if subJob.TaskGroup { 58 | return downloadTaskGroupOutput(client, savePath, subJob, files, runID) 59 | } 60 | 61 | return downloadSingleSubJobOutput(client, savePath, subJob, files, runID, isModule) 62 | } 63 | 64 | func downloadTaskGroupOutput(client *trickest.Client, savePath string, subJob *trickest.SubJob, files []string, runID *uuid.UUID) error { 65 | ctx := context.Background() 66 | children, err := client.GetChildSubJobs(ctx, subJob.ID) 67 | if err != nil { 68 | return fmt.Errorf("could not get child subjobs for subjob %s (ID: %s): %w", subJob.Label, subJob.ID, err) 69 | } 70 | if len(children) == 0 { 71 | return fmt.Errorf("no child subjobs found for subjob %s (ID: %s)", subJob.Label, subJob.ID) 72 | } 73 | 74 | var mu sync.Mutex 75 | var errs []error 76 | var wg sync.WaitGroup 77 | sem := make(chan struct{}, 5) 78 | 79 | for i := 1; i <= len(children); i++ { 80 | wg.Add(1) 81 | go func(i int) { 82 | defer wg.Done() 83 | sem <- struct{}{} 84 | defer func() { <-sem }() 85 | 86 | child, err := client.GetChildSubJob(ctx, subJob.ID, i) 87 | if err != nil { 88 | mu.Lock() 89 | errs = append(errs, fmt.Errorf("could not get child %d subjobs for subjob %s (ID: %s): %w", i, subJob.Label, subJob.ID, err)) 90 | mu.Unlock() 91 | return 92 | } 93 | 94 | child.Label = fmt.Sprintf("%d-%s", i, subJob.Label) 95 | if err := downloadSubJobOutput(client, savePath, &child, files, runID, false); err != nil { 96 | mu.Lock() 97 | errs = append(errs, err) 98 | mu.Unlock() 99 | } 100 | }(i) 101 | } 102 | wg.Wait() 103 | 104 | if len(errs) > 0 { 105 | return fmt.Errorf("errors occurred while downloading subjob children outputs:\n%s", errors.Join(errs...)) 106 | } 107 | return nil 108 | } 109 | 110 | func downloadSingleSubJobOutput(client *trickest.Client, savePath string, subJob *trickest.SubJob, files []string, runID *uuid.UUID, isModule bool) error { 111 | ctx := context.Background() 112 | var errs []error 113 | 114 | subJobOutputs, err := getSubJobOutputs(client, ctx, subJob, runID, isModule) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | subJobOutputs = filterSubJobOutputsByFileNames(subJobOutputs, files) 120 | if len(subJobOutputs) == 0 { 121 | return fmt.Errorf("no matching output files found for subjob %s (ID: %s)", subJob.Label, subJob.ID) 122 | } 123 | 124 | for _, output := range subJobOutputs { 125 | if err := downloadOutput(client, savePath, subJob, output); err != nil { 126 | errs = append(errs, err) 127 | } 128 | } 129 | 130 | if len(errs) > 0 { 131 | return fmt.Errorf("errors occurred while downloading subjob outputs:\n%s", errors.Join(errs...)) 132 | } 133 | return nil 134 | } 135 | 136 | func getSubJobOutputs(client *trickest.Client, ctx context.Context, subJob *trickest.SubJob, runID *uuid.UUID, isModule bool) ([]trickest.SubJobOutput, error) { 137 | if isModule { 138 | outputs, err := client.GetModuleSubJobOutputs(ctx, subJob.Name, *runID) 139 | if err != nil { 140 | return nil, fmt.Errorf("could not get subjob outputs for subjob %s (ID: %s): %w", subJob.Label, subJob.ID, err) 141 | } 142 | return outputs, nil 143 | } 144 | 145 | outputs, err := client.GetSubJobOutputs(ctx, subJob.ID) 146 | if err != nil { 147 | return nil, fmt.Errorf("could not get subjob outputs for subjob %s (ID: %s): %w", subJob.Label, subJob.ID, err) 148 | } 149 | return outputs, nil 150 | } 151 | 152 | func downloadOutput(client *trickest.Client, savePath string, subJob *trickest.SubJob, output trickest.SubJobOutput) error { 153 | signedURL, err := client.GetOutputSignedURL(context.Background(), output.ID) 154 | if err != nil { 155 | return fmt.Errorf("could not get signed URL for output %s of subjob %s (ID: %s): %w", output.Name, subJob.Label, subJob.ID, err) 156 | } 157 | 158 | subJobDir, err := filesystem.CreateSubJobDir(savePath, *subJob) 159 | if err != nil { 160 | return fmt.Errorf("could not create directory to store output %s: %w", output.Name, err) 161 | } 162 | 163 | if err := filesystem.DownloadFile(signedURL.Url, subJobDir, output.Name, true); err != nil { 164 | return fmt.Errorf("could not download file for output %s of subjob %s (ID: %s): %w", output.Name, subJob.Label, subJob.ID, err) 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func filterSubJobOutputsByFileNames(outputs []trickest.SubJobOutput, fileNames []string) []trickest.SubJobOutput { 171 | if len(fileNames) == 0 { 172 | return outputs 173 | } 174 | 175 | var matchingOutputs []trickest.SubJobOutput 176 | for _, output := range outputs { 177 | for _, fileName := range fileNames { 178 | if output.Name == fileName { 179 | matchingOutputs = append(matchingOutputs, output) 180 | break 181 | } 182 | } 183 | } 184 | 185 | return matchingOutputs 186 | } 187 | -------------------------------------------------------------------------------- /pkg/config/input.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/trickest/trickest-cli/pkg/workflowbuilder" 8 | ) 9 | 10 | func ParseInputs(inputs map[string]any) ([]workflowbuilder.NodeInput, []workflowbuilder.PrimitiveNodeInput, error) { 11 | nodeInputs := make([]workflowbuilder.NodeInput, 0) 12 | primitiveNodeInputs := make([]workflowbuilder.PrimitiveNodeInput, 0) 13 | 14 | for key, value := range inputs { 15 | if strings.Contains(key, ".") { 16 | // node-ref.param-name input 17 | parts := strings.Split(key, ".") 18 | if len(parts) != 2 { 19 | continue 20 | } 21 | nodeRef := parts[0] 22 | paramName := parts[1] 23 | 24 | // Handle a list of values or a single value 25 | switch v := value.(type) { 26 | case []any: 27 | nodeInputs = append(nodeInputs, workflowbuilder.NodeInput{ 28 | NodeID: nodeRef, 29 | ParamValues: map[string][]any{paramName: v}, 30 | }) 31 | default: 32 | nodeInputs = append(nodeInputs, workflowbuilder.NodeInput{ 33 | NodeID: nodeRef, 34 | ParamValues: map[string][]any{paramName: {v}}, 35 | }) 36 | } 37 | } else { 38 | // primitive node reference 39 | switch v := value.(type) { 40 | case []any: 41 | return nil, nil, fmt.Errorf("invalid input for node %q: got an array of values %v. For primitive input nodes, use a single value '%s: '. For tool/module/script input nodes, use the node-reference format '%s.param-name: ", key, v, key, key) 42 | default: 43 | primitiveNodeInputs = append(primitiveNodeInputs, workflowbuilder.PrimitiveNodeInput{ 44 | PrimitiveNodeID: key, 45 | Value: value, 46 | }) 47 | } 48 | } 49 | } 50 | 51 | return nodeInputs, primitiveNodeInputs, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/config/runconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/trickest/trickest-cli/pkg/workflowbuilder" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // RunConfig represents the configuration for a run 12 | type RunConfig struct { 13 | NodeInputs []workflowbuilder.NodeInput `yaml:"-"` 14 | PrimitiveNodeInputs []workflowbuilder.PrimitiveNodeInput `yaml:"-"` 15 | Outputs []string `yaml:"outputs"` 16 | Machines int `yaml:"machines"` 17 | UseStaticIPs bool `yaml:"use-static-ips"` 18 | Fleet string `yaml:"fleet"` 19 | } 20 | 21 | func ParseConfigFile(path string) (*RunConfig, error) { 22 | data, err := os.ReadFile(path) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to read run config file: %w", err) 25 | } 26 | return parseRunConfig(data) 27 | } 28 | 29 | func parseRunConfig(data []byte) (*RunConfig, error) { 30 | var rawConfig struct { 31 | Input map[string]any `yaml:"input"` // To match the input flag 32 | Inputs map[string]any `yaml:"inputs"` // For backward compatibility 33 | 34 | Output any `yaml:"output"` // To match the output flag 35 | Outputs any `yaml:"outputs"` // For backward compatibility 36 | 37 | Machines int `yaml:"machines"` 38 | UseStaticIPs bool `yaml:"use-static-ips"` 39 | Fleet string `yaml:"fleet"` 40 | } 41 | 42 | if err := yaml.Unmarshal(data, &rawConfig); err != nil { 43 | return nil, fmt.Errorf("failed to unmarshal run config: %w", err) 44 | } 45 | 46 | inputs := rawConfig.Inputs 47 | if inputs == nil { 48 | inputs = rawConfig.Input 49 | } 50 | 51 | nodeInputs, primitiveNodeInputs, err := ParseInputs(inputs) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | config := &RunConfig{ 57 | NodeInputs: nodeInputs, 58 | PrimitiveNodeInputs: primitiveNodeInputs, 59 | Machines: rawConfig.Machines, 60 | UseStaticIPs: rawConfig.UseStaticIPs, 61 | Fleet: rawConfig.Fleet, 62 | } 63 | 64 | rawOutputs := rawConfig.Outputs 65 | if rawOutputs == nil { 66 | rawOutputs = rawConfig.Output 67 | } 68 | 69 | // Handle a single output or a list of outputs 70 | if rawOutputs != nil { 71 | switch v := rawOutputs.(type) { 72 | case string: 73 | config.Outputs = append(config.Outputs, v) 74 | case []any: 75 | for _, item := range v { 76 | config.Outputs = append(config.Outputs, fmt.Sprintf("%v", item)) 77 | } 78 | default: 79 | config.Outputs = append(config.Outputs, fmt.Sprintf("%v", v)) 80 | } 81 | } 82 | 83 | return config, nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/config/url.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func GetNodeIDFromWorkflowURL(workflowURL string) (string, error) { 11 | return geParameterValueFromURL(workflowURL, "node") 12 | } 13 | 14 | func GetRunIDFromWorkflowURL(workflowURL string) (uuid.UUID, error) { 15 | runID, err := geParameterValueFromURL(workflowURL, "run") 16 | if err != nil { 17 | return uuid.Nil, err 18 | } 19 | return uuid.Parse(runID) 20 | } 21 | 22 | func geParameterValueFromURL(workflowURL string, parameter string) (string, error) { 23 | u, err := url.Parse(workflowURL) 24 | if err != nil { 25 | return "", fmt.Errorf("failed to parse URL %q: %w", workflowURL, err) 26 | } 27 | 28 | queryParams, err := url.ParseQuery(u.RawQuery) 29 | if err != nil { 30 | return "", fmt.Errorf("failed to parse query parameters from URL %q: %w", workflowURL, err) 31 | } 32 | 33 | paramValues, found := queryParams[parameter] 34 | if !found { 35 | return "", fmt.Errorf("URL %q does not contain required parameter %q", workflowURL, parameter) 36 | } 37 | 38 | if len(paramValues) != 1 { 39 | return "", fmt.Errorf("URL %q contains %d values for parameter %q, expected exactly 1", workflowURL, len(paramValues), parameter) 40 | } 41 | 42 | return paramValues[0], nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/config/workflowrunspec.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | ) 11 | 12 | // WorkflowRunSpec represents the specification for a workflow or run 13 | type WorkflowRunSpec struct { 14 | // Run identification 15 | RunID string 16 | NumberOfRuns int 17 | AllRuns bool 18 | RunStatus string 19 | 20 | // Workflow identification 21 | SpaceName string 22 | ProjectName string 23 | WorkflowName string 24 | URL string 25 | 26 | // Resolved objects 27 | Space *trickest.Space 28 | Project *trickest.Project 29 | } 30 | 31 | // GetRuns retrieves runs based on the specification 32 | func (s WorkflowRunSpec) GetRuns(ctx context.Context, client *trickest.Client) ([]trickest.Run, error) { 33 | // If we have an specific run ID or no multiple run flags, get a single run 34 | if s.RunID != "" || (s.NumberOfRuns == 0 && !s.AllRuns) || strings.Contains(s.URL, "?run=") { 35 | run, err := s.resolveSingleRun(ctx, client) 36 | if err != nil { 37 | return nil, err 38 | } 39 | if s.RunStatus != "" && run.Status != s.RunStatus { 40 | return nil, fmt.Errorf("run %s has status %q, expected status %q", run.ID, run.Status, s.RunStatus) 41 | } 42 | return []trickest.Run{*run}, nil 43 | } 44 | 45 | // Get multiple runs from the workflow 46 | workflow, err := s.GetWorkflow(ctx, client) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | limit := 0 // 0 means get all runs 52 | if s.NumberOfRuns > 0 { 53 | limit = s.NumberOfRuns 54 | } 55 | 56 | runs, err := client.GetRuns(ctx, workflow.ID, s.RunStatus, limit) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to get runs: %w", err) 59 | } 60 | 61 | // Set the workflow name and ID for each run because the mass get doesn't include them 62 | for i := range runs { 63 | runs[i].WorkflowName = workflow.Name 64 | runs[i].WorkflowInfo = &workflow.ID 65 | } 66 | 67 | return runs, nil 68 | } 69 | 70 | // GetWorkflow gets the workflow ID from the specification 71 | func (s WorkflowRunSpec) GetWorkflow(ctx context.Context, client *trickest.Client) (*trickest.Workflow, error) { 72 | var workflow *trickest.Workflow 73 | var err error 74 | 75 | if s.URL != "" { 76 | workflow, err = client.GetWorkflowByURL(ctx, s.URL) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to get workflow by URL: %w", err) 79 | } 80 | return workflow, nil 81 | } 82 | 83 | if s.SpaceName != "" && s.WorkflowName != "" { 84 | if s.Space == nil { 85 | workflow, err = client.GetWorkflowByLocation(ctx, s.SpaceName, s.ProjectName, s.WorkflowName) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to get workflow by location: %w", err) 88 | } 89 | } else { 90 | var projectID uuid.UUID 91 | if s.Project != nil { 92 | projectID = *s.Project.ID 93 | } 94 | 95 | workflows, err := client.GetWorkflows(ctx, *s.Space.ID, projectID, s.WorkflowName) 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to get workflows by location: %w", err) 98 | } 99 | if len(workflows) == 0 { 100 | return nil, fmt.Errorf("workflow %q not found in space %q", s.WorkflowName, s.SpaceName) 101 | } 102 | for _, wf := range workflows { 103 | if wf.Name == s.WorkflowName { 104 | workflow = &wf 105 | break 106 | } 107 | } 108 | if workflow == nil { 109 | return nil, fmt.Errorf("workflow %q not found in space %q", s.WorkflowName, s.SpaceName) 110 | } 111 | } 112 | return workflow, nil 113 | } 114 | 115 | return nil, fmt.Errorf("must provide either URL or space and workflow name to resolve workflow") 116 | } 117 | 118 | // CreateMissing creates a space if it doesn't exist, and optionally creates a project if one was specified and also doesn't exist 119 | // If the space or project already exist, it will do nothing 120 | func (s *WorkflowRunSpec) CreateMissing(ctx context.Context, client *trickest.Client) error { 121 | if s.SpaceName == "" { 122 | return fmt.Errorf("space name is required") 123 | } 124 | 125 | if s.Space == nil { 126 | space, err := client.CreateSpace(ctx, s.SpaceName, "") 127 | if err != nil { 128 | return fmt.Errorf("failed to create space %q: %w", s.SpaceName, err) 129 | } 130 | s.Space = space 131 | } 132 | 133 | if s.ProjectName != "" && s.Project == nil { 134 | project, err := client.CreateProject(ctx, s.ProjectName, "", *s.Space.ID) 135 | if err != nil { 136 | return fmt.Errorf("failed to create project %q: %w", s.ProjectName, err) 137 | } 138 | s.Project = project 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (s *WorkflowRunSpec) ResolveSpaceAndProject(ctx context.Context, client *trickest.Client) error { 145 | if s.Space == nil { 146 | space, err := client.GetSpaceByName(ctx, s.SpaceName) 147 | if err != nil { 148 | return fmt.Errorf("failed to get space %q: %w", s.SpaceName, err) 149 | } 150 | s.Space = space 151 | } 152 | 153 | if s.ProjectName != "" && s.Project == nil { 154 | project, err := s.Space.GetProjectByName(s.ProjectName) 155 | if err != nil { 156 | return fmt.Errorf("failed to get project %q: %w", s.ProjectName, err) 157 | } 158 | s.Project = project 159 | } 160 | return nil 161 | } 162 | 163 | // resolveSingleRun resolves a single run from the specification 164 | func (s WorkflowRunSpec) resolveSingleRun(ctx context.Context, client *trickest.Client) (*trickest.Run, error) { 165 | if s.RunID != "" { 166 | run, err := client.GetRun(ctx, uuid.MustParse(s.RunID)) 167 | if err != nil { 168 | return nil, fmt.Errorf("failed to get run: %w", err) 169 | } 170 | return run, nil 171 | } 172 | 173 | if s.URL != "" { 174 | // First try to get run ID from URL 175 | run, err := client.GetRunByURL(ctx, s.URL) 176 | if err != nil { 177 | return nil, fmt.Errorf("failed to get run from URL: %w", err) 178 | } 179 | if run != nil { 180 | return run, nil 181 | } 182 | } 183 | 184 | // If no specific run found, get the latest run from the workflow 185 | workflow, err := s.GetWorkflow(ctx, client) 186 | if err != nil { 187 | return nil, err 188 | } 189 | 190 | run, err := client.GetLatestRun(ctx, workflow.ID) 191 | if err != nil { 192 | return nil, fmt.Errorf("failed to get latest run: %w", err) 193 | } 194 | 195 | // Set the workflow name and ID for the run because the mass get doesn't include them 196 | run.WorkflowName = workflow.Name 197 | run.WorkflowInfo = &workflow.ID 198 | 199 | return run, nil 200 | } 201 | -------------------------------------------------------------------------------- /pkg/display/emoji.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | const ( 4 | spaceEmoji = "\U0001f4c2" // 📂 5 | projectEmoji = "\U0001f5c2" // 🗂 6 | workflowEmoji = "\U0001f9be" // 🦾 7 | 8 | moduleEmoji = "\U0001f916" // 🤖 9 | inputEmoji = "\U0001f4e5" // 📥 10 | outputEmoji = "\U0001f4e4" // 📤 11 | 12 | descriptionEmoji = "\U0001f4cb" // 📋 13 | 14 | fileEmoji = "\U0001f4c4" // 📄 15 | sizeEmoji = "\U0001f522" // 🔢 16 | dateEmoji = "\U0001f4c5" // 📅 17 | ) 18 | -------------------------------------------------------------------------------- /pkg/display/file.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | const ( 12 | dateFormat = "2006-01-02 15:04:05" 13 | ) 14 | 15 | // PrintFiles writes the list of files in tree format to the given writer 16 | func PrintFiles(w io.Writer, files []trickest.File) error { 17 | tree := treeprint.New() 18 | tree.SetValue("Files") 19 | for _, file := range files { 20 | fileSubBranch := tree.AddBranch(fileEmoji + " " + file.Name) 21 | fileSubBranch.AddNode(sizeEmoji + " " + file.PrettySize) 22 | fileSubBranch.AddNode(dateEmoji + " " + file.ModifiedDate.Format(dateFormat)) 23 | } 24 | 25 | _, err := fmt.Fprintln(w, tree.String()) 26 | return err 27 | } 28 | -------------------------------------------------------------------------------- /pkg/display/module.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // PrintModules writes the modules list in tree format to the given writer 12 | func PrintModules(w io.Writer, modules []trickest.Module) error { 13 | tree := treeprint.New() 14 | tree.SetValue("Modules") 15 | for _, module := range modules { 16 | mdSubBranch := tree.AddBranch(moduleEmoji + " " + module.Name) 17 | if module.Description != "" { 18 | mdSubBranch.AddNode(descriptionEmoji + " \033[3m" + module.Description + "\033[0m") 19 | } 20 | inputSubBranch := mdSubBranch.AddBranch(inputEmoji + " Inputs") 21 | for _, input := range module.Data.Inputs { 22 | inputSubBranch.AddNode(input.Name) 23 | } 24 | outputSubBranch := mdSubBranch.AddBranch(outputEmoji + " Outputs") 25 | for _, output := range module.Data.Outputs { 26 | outputSubBranch.AddNode(*output.ParameterName) 27 | } 28 | } 29 | 30 | _, err := fmt.Fprintln(w, tree.String()) 31 | return err 32 | } 33 | -------------------------------------------------------------------------------- /pkg/display/project.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // PrintProject writes the project details in tree format to the given writer 12 | func PrintProject(w io.Writer, project trickest.Project) error { 13 | tree := treeprint.New() 14 | tree.SetValue(projectEmoji + " " + project.Name) 15 | if project.Description != "" { 16 | tree.AddNode(descriptionEmoji + " \033[3m" + project.Description + "\033[0m") 17 | } 18 | if project.Workflows != nil && len(project.Workflows) > 0 { 19 | wfBranch := tree.AddBranch("Workflows") 20 | for _, workflow := range project.Workflows { 21 | wfSubBranch := wfBranch.AddBranch(workflowEmoji + " " + workflow.Name) 22 | if workflow.Description != "" { 23 | wfSubBranch.AddNode(descriptionEmoji + " \033[3m" + workflow.Description + "\033[0m") 24 | } 25 | } 26 | } 27 | 28 | _, err := fmt.Fprintln(w, tree.String()) 29 | return err 30 | } 31 | -------------------------------------------------------------------------------- /pkg/display/run/watcher.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | "github.com/gosuri/uilive" 14 | "github.com/trickest/trickest-cli/pkg/trickest" 15 | ) 16 | 17 | // RunWatcher handles watching and displaying the status of a workflow run 18 | type RunWatcher struct { 19 | client *trickest.Client 20 | runID uuid.UUID 21 | workflowVersion *trickest.WorkflowVersion 22 | includePrimitiveNodes bool 23 | includeTaskGroupStats bool 24 | ci bool 25 | writer *uilive.Writer 26 | mutex *sync.Mutex 27 | fetchedTaskGroupChildren map[uuid.UUID][]trickest.SubJob // Cache to store completed children data 28 | } 29 | 30 | // RunWatcherOption is a function that configures a RunWatcher 31 | type RunWatcherOption func(*RunWatcher) 32 | 33 | // WithIncludePrimitiveNodes configures whether to include primitive nodes 34 | func WithIncludePrimitiveNodes(include bool) RunWatcherOption { 35 | return func(w *RunWatcher) { 36 | w.includePrimitiveNodes = include 37 | } 38 | } 39 | 40 | // WithIncludeTaskGroupStats configures whether to include task group stats 41 | func WithIncludeTaskGroupStats(include bool) RunWatcherOption { 42 | return func(w *RunWatcher) { 43 | w.includeTaskGroupStats = include 44 | } 45 | } 46 | 47 | // WithCI configures CI mode 48 | func WithCI(ci bool) RunWatcherOption { 49 | return func(w *RunWatcher) { 50 | w.ci = ci 51 | } 52 | } 53 | 54 | // WithWorkflowVersion sets the workflow version for the watcher 55 | func WithWorkflowVersion(version *trickest.WorkflowVersion) RunWatcherOption { 56 | return func(w *RunWatcher) { 57 | w.workflowVersion = version 58 | } 59 | } 60 | 61 | // NewRunWatcher creates a new RunWatcher instance 62 | func NewRunWatcher(client *trickest.Client, runID uuid.UUID, opts ...RunWatcherOption) (*RunWatcher, error) { 63 | w := &RunWatcher{ 64 | client: client, 65 | runID: runID, 66 | writer: uilive.New(), 67 | mutex: &sync.Mutex{}, 68 | fetchedTaskGroupChildren: make(map[uuid.UUID][]trickest.SubJob), 69 | } 70 | 71 | for _, opt := range opts { 72 | opt(w) 73 | } 74 | 75 | // If workflow version is not set, fetch it from the client 76 | if w.workflowVersion == nil { 77 | run, err := w.client.GetRun(context.Background(), w.runID) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to get run: %w", err) 80 | } 81 | if run == nil { 82 | return nil, fmt.Errorf("run not found") 83 | } 84 | if run.WorkflowVersionInfo == nil { 85 | return nil, fmt.Errorf("workflow version info not found in run") 86 | } 87 | version, err := w.client.GetWorkflowVersion(context.Background(), *run.WorkflowVersionInfo) 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to get workflow version: %w", err) 90 | } 91 | w.workflowVersion = version 92 | } 93 | 94 | return w, nil 95 | } 96 | 97 | // Watch starts watching the run and displaying its status 98 | func (w *RunWatcher) Watch(ctx context.Context) error { 99 | w.writer.Start() 100 | defer w.writer.Stop() 101 | 102 | interruptErr := make(chan error, 1) 103 | go func() { 104 | interruptErr <- w.handleInterrupt(ctx) 105 | }() 106 | 107 | printer := NewRunPrinter(w.includePrimitiveNodes, w.writer) 108 | 109 | run, err := w.client.GetRun(ctx, w.runID) 110 | if err != nil { 111 | return fmt.Errorf("failed to get run: %w", err) 112 | } 113 | 114 | fleet, err := w.client.GetFleet(ctx, *run.Fleet) 115 | if err != nil { 116 | return fmt.Errorf("failed to get fleet: %w", err) 117 | } 118 | fleetName := fleet.Name 119 | 120 | averageDuration, err := w.client.GetWorkflowRunsAverageDuration(ctx, *run.WorkflowInfo) 121 | if err != nil { 122 | averageDuration = 0 123 | } 124 | 125 | for { 126 | select { 127 | case err := <-interruptErr: 128 | if err == nil || err.Error() == "execution interrupted by user" { 129 | return nil 130 | } 131 | return err 132 | default: 133 | w.mutex.Lock() 134 | run, err := w.client.GetRun(ctx, w.runID) 135 | if err != nil { 136 | w.mutex.Unlock() 137 | return fmt.Errorf("failed to get run: %w", err) 138 | } 139 | 140 | if run == nil { 141 | w.mutex.Unlock() 142 | return nil 143 | } 144 | 145 | subJobs, err := w.client.GetSubJobs(ctx, w.runID) 146 | if err != nil { 147 | w.mutex.Unlock() 148 | return fmt.Errorf("failed to get sub-jobs: %w", err) 149 | } 150 | 151 | if w.includeTaskGroupStats { 152 | for i := range subJobs { 153 | if subJobs[i].TaskGroup { 154 | // Only reload children if the task group is still running or hasn't been fetched before 155 | if subJobs[i].Status == "RUNNING" || len(w.fetchedTaskGroupChildren[subJobs[i].ID]) == 0 { 156 | childSubJobs, err := w.client.GetChildSubJobs(ctx, subJobs[i].ID) 157 | if err != nil { 158 | w.mutex.Unlock() 159 | return fmt.Errorf("failed to get child sub-jobs: %w", err) 160 | } 161 | w.fetchedTaskGroupChildren[subJobs[i].ID] = childSubJobs 162 | } 163 | subJobs[i].Children = w.fetchedTaskGroupChildren[subJobs[i].ID] 164 | } 165 | } 166 | } 167 | 168 | insights, err := w.client.GetRunSubJobInsights(ctx, w.runID) 169 | if err != nil { 170 | w.mutex.Unlock() 171 | return fmt.Errorf("failed to get run insights: %w", err) 172 | } 173 | run.RunInsights = insights 174 | run.FleetName = fleetName 175 | run.AverageDuration = &trickest.Duration{Duration: averageDuration} 176 | 177 | printer.PrintAll(run, subJobs, w.workflowVersion, w.includeTaskGroupStats) 178 | _ = w.writer.Flush() 179 | 180 | if run.Finished { 181 | w.mutex.Unlock() 182 | return nil 183 | } 184 | 185 | w.mutex.Unlock() 186 | time.Sleep(time.Second) 187 | } 188 | } 189 | } 190 | 191 | // handleInterrupt handles the interrupt signal (Ctrl+C) 192 | func (w *RunWatcher) handleInterrupt(ctx context.Context) error { 193 | defer w.mutex.Unlock() 194 | signalChannel := make(chan os.Signal, 1) 195 | signal.Notify(signalChannel, os.Interrupt) 196 | <-signalChannel 197 | 198 | w.mutex.Lock() 199 | 200 | if w.ci { 201 | return w.client.StopRun(ctx, w.runID) 202 | } else { 203 | fmt.Println("The program will exit. Would you like to stop the remote execution? (Y/N)") 204 | var answer string 205 | for { 206 | _, _ = fmt.Scan(&answer) 207 | if strings.ToLower(answer) == "y" || strings.ToLower(answer) == "yes" { 208 | return w.client.StopRun(ctx, w.runID) 209 | } else if strings.ToLower(answer) == "n" || strings.ToLower(answer) == "no" { 210 | return fmt.Errorf("execution interrupted by user") 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /pkg/display/script.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // PrintScripts writes the scripts list in tree format to the given writer 12 | func PrintScripts(w io.Writer, scripts []trickest.Script) error { 13 | tree := treeprint.New() 14 | tree.SetValue("Scripts") 15 | for _, script := range scripts { 16 | branch := tree.AddBranch(script.Name) 17 | branch.AddNode(script.Script.Source) 18 | } 19 | 20 | _, err := fmt.Fprintln(w, tree.String()) 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /pkg/display/space.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // PrintSpace writes the space details in tree format to the given writer 12 | func PrintSpace(w io.Writer, space trickest.Space) error { 13 | tree := treeprint.New() 14 | tree.SetValue(spaceEmoji + " " + space.Name) 15 | if space.Description != "" { 16 | tree.AddNode(descriptionEmoji + " \033[3m" + space.Description + "\033[0m") 17 | } 18 | if space.Projects != nil && len(space.Projects) > 0 { 19 | projectsBranch := tree.AddBranch("Projects") 20 | for _, project := range space.Projects { 21 | projectSubBranch := projectsBranch.AddBranch(projectEmoji + " " + project.Name) 22 | if project.Description != "" { 23 | projectSubBranch.AddNode(descriptionEmoji + " \033[3m" + project.Description + "\033[0m") 24 | } 25 | } 26 | } 27 | if space.Workflows != nil && len(space.Workflows) > 0 { 28 | workflowsBranch := tree.AddBranch("Workflows") 29 | for _, workflow := range space.Workflows { 30 | workflowSubBranch := workflowsBranch.AddBranch(workflowEmoji + " " + workflow.Name) 31 | if workflow.Description != "" { 32 | workflowSubBranch.AddNode(descriptionEmoji + " \033[3m" + workflow.Description + "\033[0m") 33 | } 34 | } 35 | } 36 | 37 | _, err := fmt.Fprintln(w, tree.String()) 38 | return err 39 | } 40 | 41 | // PrintSpaces writes the list of spaces in tree format to the given writer 42 | func PrintSpaces(w io.Writer, spaces []trickest.Space) error { 43 | tree := treeprint.New() 44 | tree.SetValue("Spaces") 45 | for _, space := range spaces { 46 | branch := tree.AddBranch(spaceEmoji + " " + space.Name) 47 | if space.Description != "" { 48 | branch.AddNode(descriptionEmoji + " \033[3m" + space.Description + "\033[0m") 49 | } 50 | } 51 | 52 | _, err := fmt.Fprintln(w, tree.String()) 53 | return err 54 | } 55 | -------------------------------------------------------------------------------- /pkg/display/tool.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/trickest/trickest-cli/pkg/trickest" 9 | "github.com/xlab/treeprint" 10 | ) 11 | 12 | // PrintTools writes the tools list in tree format to the given writer 13 | func PrintTools(w io.Writer, tools []trickest.Tool) error { 14 | tree := treeprint.New() 15 | tree.SetValue("Tools") 16 | for _, tool := range tools { 17 | branch := tree.AddBranch(tool.Name + " [" + strings.TrimPrefix(tool.SourceURL, "https://") + "]") 18 | branch.AddNode(descriptionEmoji + " \033[3m" + tool.Description + "\033[0m") 19 | } 20 | 21 | _, err := fmt.Fprintln(w, tree.String()) 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /pkg/display/workflow.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | "github.com/xlab/treeprint" 9 | ) 10 | 11 | // PrintWorkflow writes the workflow details in tree format to the given writer 12 | func PrintWorkflow(w io.Writer, workflow trickest.Workflow) error { 13 | tree := treeprint.New() 14 | tree.SetValue(workflowEmoji + " " + workflow.Name) 15 | if workflow.Description != "" { 16 | tree.AddNode(descriptionEmoji + " \033[3m" + workflow.Description + "\033[0m") 17 | } 18 | tree.AddNode("Author: " + workflow.Author) 19 | 20 | _, err := fmt.Fprintln(w, tree.String()) 21 | return err 22 | } 23 | 24 | // PrintWorkflows writes the workflows list in tree format to the given writer 25 | func PrintWorkflows(w io.Writer, workflows []trickest.Workflow) error { 26 | tree := treeprint.New() 27 | tree.SetValue("Workflows") 28 | for _, workflow := range workflows { 29 | wfSubBranch := tree.AddBranch(workflowEmoji + " " + workflow.Name) 30 | if workflow.Description != "" { 31 | wfSubBranch.AddNode(descriptionEmoji + " \033[3m" + workflow.Description + "\033[0m") 32 | } 33 | } 34 | 35 | _, err := fmt.Fprintln(w, tree.String()) 36 | return err 37 | } 38 | -------------------------------------------------------------------------------- /pkg/filesystem/filesystem.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path" 9 | 10 | "github.com/schollz/progressbar/v3" 11 | "github.com/trickest/trickest-cli/pkg/trickest" 12 | ) 13 | 14 | // CreateRunDir creates a directory for a run based on the run's started date 15 | func CreateRunDir(baseDir string, run trickest.Run) (string, error) { 16 | if run.StartedDate == nil { 17 | return "", fmt.Errorf("run started date is nil, either the run has not started or the run object is incomplete or outdated") 18 | } 19 | 20 | const layout = "2006-01-02T150405Z" 21 | runDir := "run-" + run.StartedDate.Format(layout) 22 | runDir = path.Join(baseDir, runDir) 23 | 24 | if err := os.MkdirAll(runDir, 0755); err != nil { 25 | return "", fmt.Errorf("failed to create run directory: %w", err) 26 | } 27 | 28 | return runDir, nil 29 | } 30 | 31 | // CreateSubJobDir creates a directory for a subjob based on the subjob's label 32 | func CreateSubJobDir(runDir string, subJob trickest.SubJob) (string, error) { 33 | subJobDir := path.Join(runDir, subJob.Label) 34 | if err := os.MkdirAll(subJobDir, 0755); err != nil { 35 | return "", fmt.Errorf("failed to create subjob directory: %w", err) 36 | } 37 | return subJobDir, nil 38 | } 39 | 40 | // DownloadFile downloads a file from a URL to the specified directory and shows a progress bar if showProgress is true 41 | func DownloadFile(url, outputDir, fileName string, showProgress bool) error { 42 | // Create output directory if it doesn't exist, just in case 43 | if err := os.MkdirAll(outputDir, 0755); err != nil { 44 | return fmt.Errorf("failed to create output directory: %w", err) 45 | } 46 | 47 | filePath := path.Join(outputDir, fileName) 48 | outputFile, err := os.Create(filePath) 49 | if err != nil { 50 | return fmt.Errorf("failed to create output file: %w", err) 51 | } 52 | defer outputFile.Close() 53 | 54 | // Download the file 55 | resp, err := http.Get(url) 56 | if err != nil { 57 | return fmt.Errorf("failed to download file: %w", err) 58 | } 59 | defer resp.Body.Close() 60 | 61 | if resp.StatusCode != http.StatusOK { 62 | return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) 63 | } 64 | 65 | // Copy the content with or without progress bar 66 | if showProgress && resp.ContentLength > 0 { 67 | bar := progressbar.NewOptions64( 68 | resp.ContentLength, 69 | progressbar.OptionSetDescription(fmt.Sprintf("Downloading %s... ", fileName)), 70 | progressbar.OptionSetWidth(30), 71 | progressbar.OptionShowBytes(true), 72 | progressbar.OptionShowCount(), 73 | progressbar.OptionOnCompletion(func() { fmt.Println() }), 74 | ) 75 | _, err = io.Copy(io.MultiWriter(outputFile, bar), resp.Body) 76 | } else { 77 | _, err = io.Copy(outputFile, resp.Body) 78 | } 79 | 80 | if err != nil { 81 | return fmt.Errorf("failed to download file: %w", err) 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | "strings" 7 | "time" 8 | 9 | "github.com/trickest/trickest-cli/pkg/trickest" 10 | ) 11 | 12 | type TaskGroupStats struct { 13 | Count int `json:"count"` 14 | Status SubJobStatus `json:"status"` 15 | MinDuration TaskDuration `json:"min_duration,omitempty"` 16 | MaxDuration TaskDuration `json:"max_duration,omitempty"` 17 | Median trickest.Duration `json:"median,omitempty"` 18 | MedianAbsoluteDeviation trickest.Duration `json:"median_absolute_deviation,omitempty"` 19 | Outliers []TaskDuration `json:"outliers,omitempty"` 20 | } 21 | 22 | type TaskDuration struct { 23 | TaskIndex int `json:"task_index"` 24 | Duration trickest.Duration `json:"duration"` 25 | } 26 | 27 | type SubJobStatus struct { 28 | Pending int `json:"pending"` 29 | Running int `json:"running"` 30 | Succeeded int `json:"succeeded"` 31 | Failed int `json:"failed"` 32 | Stopping int `json:"stopping"` 33 | Stopped int `json:"stopped"` 34 | } 35 | 36 | func CalculateTaskGroupStats(sj trickest.SubJob) TaskGroupStats { 37 | stats := TaskGroupStats{ 38 | Count: len(sj.Children), 39 | MinDuration: TaskDuration{ 40 | TaskIndex: -1, 41 | Duration: trickest.Duration{Duration: time.Duration(math.MaxInt64)}, 42 | }, 43 | MaxDuration: TaskDuration{ 44 | TaskIndex: -1, 45 | Duration: trickest.Duration{Duration: time.Duration(math.MinInt64)}, 46 | }, 47 | } 48 | 49 | var taskDurations []TaskDuration 50 | for _, child := range sj.Children { 51 | switch child.Status { 52 | case "PENDING": 53 | stats.Status.Pending++ 54 | case "RUNNING": 55 | stats.Status.Running++ 56 | case "SUCCEEDED": 57 | stats.Status.Succeeded++ 58 | case "FAILED": 59 | stats.Status.Failed++ 60 | case "STOPPING": 61 | stats.Status.Stopping++ 62 | case "STOPPED": 63 | stats.Status.Stopped++ 64 | } 65 | 66 | if child.StartedDate.IsZero() { 67 | continue 68 | } 69 | 70 | taskDuration := TaskDuration{ 71 | TaskIndex: child.TaskIndex, 72 | } 73 | 74 | if child.FinishedDate.IsZero() { 75 | taskDuration.Duration = trickest.Duration{Duration: time.Since(child.StartedDate)} 76 | } else { 77 | taskDuration.Duration = trickest.Duration{Duration: child.FinishedDate.Sub(child.StartedDate)} 78 | } 79 | 80 | taskDurations = append(taskDurations, taskDuration) 81 | 82 | if taskDuration.Duration.Duration > stats.MaxDuration.Duration.Duration { 83 | stats.MaxDuration = taskDuration 84 | } 85 | if taskDuration.Duration.Duration < stats.MinDuration.Duration.Duration { 86 | stats.MinDuration = taskDuration 87 | } 88 | } 89 | 90 | if len(taskDurations) >= 2 { 91 | sort.Slice(taskDurations, func(i, j int) bool { 92 | return taskDurations[i].Duration.Duration < taskDurations[j].Duration.Duration 93 | }) 94 | 95 | medianIndex := len(taskDurations) / 2 96 | stats.Median = taskDurations[medianIndex].Duration 97 | 98 | // Calculate Median Absolute Deviation (MAD) 99 | var absoluteDeviations []trickest.Duration 100 | for _, d := range taskDurations { 101 | diff := d.Duration.Duration - stats.Median.Duration 102 | if diff < 0 { 103 | diff = -diff 104 | } 105 | absoluteDeviations = append(absoluteDeviations, trickest.Duration{Duration: diff}) 106 | } 107 | sort.Slice(absoluteDeviations, func(i, j int) bool { 108 | return absoluteDeviations[i].Duration < absoluteDeviations[j].Duration 109 | }) 110 | stats.MedianAbsoluteDeviation = absoluteDeviations[len(absoluteDeviations)/2] 111 | 112 | // Use absolute threshold for outlier detection 113 | // A task is considered an outlier if it's more than threshold away from the median 114 | threshold := 15 * time.Minute 115 | 116 | for _, d := range taskDurations { 117 | diff := d.Duration.Duration - stats.Median.Duration 118 | if diff < 0 { 119 | diff = -diff 120 | } 121 | if diff > threshold { 122 | stats.Outliers = append(stats.Outliers, d) 123 | } 124 | } 125 | } 126 | 127 | return stats 128 | } 129 | 130 | // HasInterestingStats returns true if the node's stats are worth displaying 131 | func HasInterestingStats(nodeName string) bool { 132 | unInterestingNodeNames := map[string]bool{ 133 | "batch-output": true, 134 | "string-to-file": true, 135 | } 136 | 137 | parts := strings.Split(nodeName, "-") 138 | if len(parts) < 2 { 139 | return true 140 | } 141 | baseNodeName := strings.Join(parts[:len(parts)-1], "-") 142 | return !unInterestingNodeNames[baseNodeName] 143 | } 144 | -------------------------------------------------------------------------------- /pkg/trickest/client.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/google/uuid" 13 | "github.com/trickest/trickest-cli/pkg/version" 14 | ) 15 | 16 | // Client provides access to the Trickest API 17 | type Client struct { 18 | baseURL string 19 | token string 20 | httpClient *http.Client 21 | vaultID uuid.UUID 22 | 23 | Hive *Service 24 | Orchestrator *Service 25 | } 26 | 27 | type Service struct { 28 | client *Client 29 | basePath string 30 | } 31 | 32 | // NewClient creates a new Trickest API client 33 | func NewClient(opts ...Option) (*Client, error) { 34 | c := &Client{ 35 | baseURL: "https://api.trickest.io", 36 | httpClient: &http.Client{}, 37 | } 38 | 39 | c.Hive = &Service{ 40 | client: c, 41 | basePath: "/hive/v1", 42 | } 43 | 44 | c.Orchestrator = &Service{ 45 | client: c, 46 | basePath: "/orchestrator/v1", 47 | } 48 | 49 | for _, opt := range opts { 50 | opt(c) 51 | } 52 | 53 | if c.token == "" { 54 | return nil, fmt.Errorf("token is required") 55 | } 56 | 57 | if c.vaultID == uuid.Nil { 58 | user, err := c.GetCurrentUser(context.Background()) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to get vault ID: %w", err) 61 | } 62 | c.vaultID = user.Profile.VaultInfo.ID 63 | } 64 | 65 | return c, nil 66 | } 67 | 68 | // Option configures a Client 69 | type Option func(*Client) 70 | 71 | // WithBaseURL sets the base URL for the client 72 | func WithBaseURL(url string) Option { 73 | return func(c *Client) { 74 | c.baseURL = strings.TrimSuffix(url, "/") 75 | } 76 | } 77 | 78 | // WithToken sets the authentication token 79 | func WithToken(token string) Option { 80 | return func(c *Client) { 81 | c.token = token 82 | } 83 | } 84 | 85 | // WithVaultID sets a specific vault ID 86 | func WithVaultID(id uuid.UUID) Option { 87 | return func(c *Client) { 88 | c.vaultID = id 89 | } 90 | } 91 | 92 | // WithHTTPClient sets a custom HTTP client 93 | func WithHTTPClient(client *http.Client) Option { 94 | return func(c *Client) { 95 | c.httpClient = client 96 | } 97 | } 98 | 99 | // doJSON performs a JSON request to a service and decodes the response 100 | func (s *Service) doJSON(ctx context.Context, method, path string, body, result any) error { 101 | fullPath := fmt.Sprintf("%s%s", s.basePath, path) 102 | 103 | return doJSON(ctx, s.client, method, fullPath, body, result) 104 | } 105 | 106 | // doRequest performs an HTTP request with common client settings 107 | func doRequest(ctx context.Context, client *Client, method, path string, body any) (*http.Response, error) { 108 | url := fmt.Sprintf("%s%s", client.baseURL, path) 109 | 110 | var reqBody io.Reader 111 | if body != nil { 112 | jsonBody, err := json.Marshal(body) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to marshal request body: %w", err) 115 | } 116 | reqBody = bytes.NewBuffer(jsonBody) 117 | } 118 | 119 | req, err := http.NewRequestWithContext(ctx, method, url, reqBody) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to create request: %w", err) 122 | } 123 | 124 | req.Header.Set("Authorization", "Token "+client.token) 125 | req.Header.Set("Accept", "application/json") 126 | req.Header.Set("User-Agent", fmt.Sprintf("trickest-cli/%s", version.Version)) 127 | if body != nil { 128 | req.Header.Set("Content-Type", "application/json") 129 | } 130 | 131 | resp, err := client.httpClient.Do(req) 132 | if err != nil { 133 | return nil, fmt.Errorf("failed to send request: %w", err) 134 | } 135 | 136 | return resp, nil 137 | } 138 | 139 | // checkResponseStatus checks the HTTP response status code and returns an error if the status is not successful (2xx). 140 | // For 404 status, it returns a "resource not found" error. 141 | // For other non-2xx statuses, it attempts to parse and return the API error details from the JSON response, 142 | // falling back to a generic "unexpected status code: " error if parsing fails. 143 | func checkResponseStatus(resp *http.Response) error { 144 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 145 | var errResp struct { 146 | Details string `json:"details"` 147 | } 148 | if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil && errResp.Details != "" { 149 | return fmt.Errorf("API error: %s", errResp.Details) 150 | } 151 | if resp.StatusCode == http.StatusNotFound { 152 | return fmt.Errorf("resource not found") 153 | } 154 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // decodeResponse decodes a JSON response into the provided value 161 | func decodeResponse(resp *http.Response, v any) error { 162 | defer resp.Body.Close() 163 | 164 | if err := json.NewDecoder(resp.Body).Decode(v); err != nil { 165 | return fmt.Errorf("failed to decode response: %w", err) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | // doJSON performs a JSON request and decodes the response 172 | func doJSON(ctx context.Context, client *Client, method, path string, body, result any) error { 173 | resp, err := doRequest(ctx, client, method, path, body) 174 | if err != nil { 175 | return err 176 | } 177 | defer resp.Body.Close() 178 | 179 | if err := checkResponseStatus(resp); err != nil { 180 | return err 181 | } 182 | 183 | if result == nil { 184 | return nil 185 | } 186 | 187 | return decodeResponse(resp, result) 188 | } 189 | -------------------------------------------------------------------------------- /pkg/trickest/file.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | 15 | "github.com/google/uuid" 16 | "github.com/schollz/progressbar/v3" 17 | ) 18 | 19 | type File struct { 20 | ID uuid.UUID `json:"id"` 21 | Name string `json:"name"` 22 | Size int `json:"size"` 23 | PrettySize string `json:"pretty_size"` 24 | ModifiedDate time.Time `json:"modified_date"` 25 | } 26 | 27 | // SearchFiles searches for files by query 28 | func (c *Client) SearchFiles(ctx context.Context, query string) ([]File, error) { 29 | path := fmt.Sprintf("/file/?search=%s&vault=%s", query, c.vaultID) 30 | 31 | files, err := GetPaginated[File](c.Hive, ctx, path, 0) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return files, nil 37 | } 38 | 39 | // GetFileByName retrieves a file by name by searching for it and returning the result with the exact name 40 | func (c *Client) GetFileByName(ctx context.Context, name string) (File, error) { 41 | files, err := c.SearchFiles(ctx, name) 42 | if err != nil { 43 | return File{}, err 44 | } 45 | 46 | if len(files) == 0 { 47 | return File{}, fmt.Errorf("file not found: %s", name) 48 | } 49 | 50 | // loop through the results to find the file with the exact name 51 | for _, file := range files { 52 | if file.Name == name { 53 | return file, nil 54 | } 55 | } 56 | 57 | return File{}, fmt.Errorf("file not found: %s", name) 58 | } 59 | 60 | // GetFileSignedURL retrieves a signed URL for a file 61 | func (c *Client) GetFileSignedURL(ctx context.Context, id uuid.UUID) (string, error) { 62 | path := fmt.Sprintf("/file/%s/signed_url/", id.String()) 63 | 64 | var signedURL string 65 | if err := c.Hive.doJSON(ctx, http.MethodGet, path, nil, &signedURL); err != nil { 66 | return "", fmt.Errorf("failed to get file signed URL: %w", err) 67 | } 68 | 69 | return signedURL, nil 70 | } 71 | 72 | // DeleteFile deletes a file 73 | func (c *Client) DeleteFile(ctx context.Context, id uuid.UUID) error { 74 | path := fmt.Sprintf("/file/%s/", id.String()) 75 | 76 | if err := c.Hive.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { 77 | return fmt.Errorf("failed to delete file: %w", err) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // UploadFile uploads a file to the Trickest file storage 84 | func (c *Client) UploadFile(ctx context.Context, filePath string, showProgress bool) (File, error) { 85 | file, err := os.Open(filePath) 86 | if err != nil { 87 | return File{}, fmt.Errorf("failed to open file: %w", err) 88 | } 89 | defer file.Close() 90 | 91 | fileName := filepath.Base(filePath) 92 | 93 | body := &bytes.Buffer{} 94 | writer := multipart.NewWriter(body) 95 | defer writer.Close() 96 | 97 | part, err := writer.CreateFormFile("thumb", fileName) 98 | if err != nil { 99 | return File{}, fmt.Errorf("failed to create form file: %w", err) 100 | } 101 | 102 | // Copy the content with or without progress bar 103 | if showProgress { 104 | stat, err := file.Stat() 105 | if err != nil { 106 | return File{}, fmt.Errorf("failed to get file stat: %w", err) 107 | } 108 | bar := progressbar.NewOptions64( 109 | stat.Size(), 110 | progressbar.OptionSetDescription(fmt.Sprintf("Uploading %s...", fileName)), 111 | progressbar.OptionSetWidth(30), 112 | progressbar.OptionShowBytes(true), 113 | progressbar.OptionShowCount(), 114 | progressbar.OptionOnCompletion(func() { fmt.Println() }), 115 | ) 116 | _, err = io.Copy(io.MultiWriter(part, bar), file) 117 | if err != nil { 118 | return File{}, fmt.Errorf("failed to copy file: %w", err) 119 | } 120 | } else { 121 | _, err = io.Copy(part, file) 122 | if err != nil { 123 | return File{}, fmt.Errorf("failed to copy file: %w", err) 124 | } 125 | } 126 | 127 | _, err = part.Write([]byte("\n--" + writer.Boundary() + "--")) 128 | if err != nil { 129 | return File{}, fmt.Errorf("failed to complete form: %w", err) 130 | } 131 | 132 | path := "/file/" 133 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+c.Hive.basePath+path, body) 134 | if err != nil { 135 | return File{}, fmt.Errorf("failed to create request: %w", err) 136 | } 137 | 138 | req.Header.Set("Authorization", "Token "+c.token) 139 | req.Header.Set("Content-Type", writer.FormDataContentType()) 140 | 141 | resp, err := c.httpClient.Do(req) 142 | if err != nil { 143 | return File{}, fmt.Errorf("failed to send request: %w", err) 144 | } 145 | defer resp.Body.Close() 146 | 147 | if resp.StatusCode != http.StatusCreated { 148 | return File{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 149 | } 150 | 151 | var uploadedFile File 152 | if err := json.NewDecoder(resp.Body).Decode(&uploadedFile); err != nil { 153 | return File{}, fmt.Errorf("failed to decode response: %w", err) 154 | } 155 | 156 | return uploadedFile, nil 157 | } 158 | -------------------------------------------------------------------------------- /pkg/trickest/library.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type Category struct { 13 | ID uuid.UUID `json:"id"` 14 | Name string `json:"name"` 15 | Description string `json:"description"` 16 | WorkflowCount int `json:"workflow_count"` 17 | ToolCount int `json:"tool_count"` 18 | } 19 | 20 | type Module struct { 21 | ID *uuid.UUID `json:"id,omitempty"` 22 | Name string `json:"name,omitempty"` 23 | Complexity int `json:"complexity,omitempty"` 24 | Description string `json:"description,omitempty"` 25 | Author string `json:"author,omitempty"` 26 | CreatedDate *time.Time `json:"created_date,omitempty"` 27 | LibraryInfo struct { 28 | Community bool `json:"community,omitempty"` 29 | Verified bool `json:"verified,omitempty"` 30 | } `json:"library_info,omitempty"` 31 | Data struct { 32 | ID string `json:"id,omitempty"` 33 | Name string `json:"name,omitempty"` 34 | Inputs map[string]*NodeInput `json:"inputs,omitempty"` 35 | Outputs map[string]*NodeOutput `json:"outputs,omitempty"` 36 | Type string `json:"type,omitempty"` 37 | } `json:"data,omitempty"` 38 | Workflow string `json:"workflow,omitempty"` 39 | } 40 | 41 | // ListLibraryWorkflows lists all workflows in the library 42 | func (c *Client) ListLibraryWorkflows(ctx context.Context) ([]Workflow, error) { 43 | path := "/library/workflow/" 44 | 45 | workflows, err := GetPaginated[Workflow](c.Hive, ctx, path, 0) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to get workflows: %w", err) 48 | } 49 | 50 | return workflows, nil 51 | } 52 | 53 | // SearchLibraryWorkflows searches for workflows in the library by name 54 | func (c *Client) SearchLibraryWorkflows(ctx context.Context, search string) ([]Workflow, error) { 55 | path := fmt.Sprintf("/library/workflow/?search=%s", search) 56 | 57 | workflows, err := GetPaginated[Workflow](c.Hive, ctx, path, 0) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to get workflows: %w", err) 60 | } 61 | 62 | return workflows, nil 63 | } 64 | 65 | // GetLibraryWorkflowByName retrieves a workflow by name from the library 66 | func (c *Client) GetLibraryWorkflowByName(ctx context.Context, name string) (*Workflow, error) { 67 | workflows, err := c.SearchLibraryWorkflows(ctx, name) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to get workflows: %w", err) 70 | } 71 | 72 | for _, workflow := range workflows { 73 | if workflow.Name == name { 74 | return &workflow, nil 75 | } 76 | } 77 | 78 | return nil, fmt.Errorf("workflow %s was not found in the library", name) 79 | } 80 | 81 | // CopyWorkflowFromLibrary copies a workflow from the library to a space and optionally a project 82 | // Set destinationProjectID to uuid.Nil for no project 83 | func (c *Client) CopyWorkflowFromLibrary(ctx context.Context, workflowID uuid.UUID, destinationSpaceID uuid.UUID, destinationProjectID uuid.UUID) (Workflow, error) { 84 | path := fmt.Sprintf("/library/workflow/%s/copy/", workflowID) 85 | 86 | destination := struct { 87 | SpaceID uuid.UUID `json:"space_info"` 88 | ProjectID *uuid.UUID `json:"project_info,omitempty"` 89 | }{ 90 | SpaceID: destinationSpaceID, 91 | } 92 | 93 | if destinationProjectID != uuid.Nil { 94 | destination.ProjectID = &destinationProjectID 95 | } 96 | 97 | var workflow Workflow 98 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, destination, &workflow); err != nil { 99 | return Workflow{}, fmt.Errorf("failed to copy workflow: %w", err) 100 | } 101 | 102 | return workflow, nil 103 | } 104 | 105 | // ListLibraryTools lists all tools in the library 106 | func (c *Client) ListLibraryTools(ctx context.Context) ([]Tool, error) { 107 | path := "/library/tool/" 108 | 109 | tools, err := GetPaginated[Tool](c.Hive, ctx, path, 0) 110 | if err != nil { 111 | return nil, fmt.Errorf("failed to get tools: %w", err) 112 | } 113 | 114 | return tools, nil 115 | } 116 | 117 | // SearchLibraryTools searches for tools in the library by a search query 118 | func (c *Client) SearchLibraryTools(ctx context.Context, search string) ([]Tool, error) { 119 | path := fmt.Sprintf("/library/tool/?search=%s", search) 120 | 121 | tools, err := GetPaginated[Tool](c.Hive, ctx, path, 0) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to get tools: %w", err) 124 | } 125 | 126 | return tools, nil 127 | } 128 | 129 | // GetLibraryToolByName retrieves a tool by name from the library 130 | func (c *Client) GetLibraryToolByName(ctx context.Context, name string) (*Tool, error) { 131 | path := fmt.Sprintf("/library/tool/?name=%s", name) 132 | 133 | tools, err := GetPaginated[Tool](c.Hive, ctx, path, 0) 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to get tool: %w", err) 136 | } 137 | 138 | if len(tools) == 0 { 139 | return nil, fmt.Errorf("tool %s was not found in the library", name) 140 | } 141 | 142 | return &tools[0], nil 143 | } 144 | 145 | // ListLibraryModules lists all modules in the library 146 | func (c *Client) ListLibraryModules(ctx context.Context) ([]Module, error) { 147 | path := "/library/module/" 148 | 149 | modules, err := GetPaginated[Module](c.Hive, ctx, path, 0) 150 | if err != nil { 151 | return nil, fmt.Errorf("failed to get modules: %w", err) 152 | } 153 | 154 | return modules, nil 155 | } 156 | 157 | // SearchLibraryModules searches for modules in the library by a search query 158 | func (c *Client) SearchLibraryModules(ctx context.Context, search string) ([]Module, error) { 159 | path := fmt.Sprintf("/library/module/?search=%s", search) 160 | 161 | modules, err := GetPaginated[Module](c.Hive, ctx, path, 0) 162 | if err != nil { 163 | return nil, fmt.Errorf("failed to get modules: %w", err) 164 | } 165 | 166 | return modules, nil 167 | } 168 | 169 | func (c *Client) GetLibraryCategoryByName(ctx context.Context, name string) (*Category, error) { 170 | path := fmt.Sprintf("/library/categories/?name=%s", name) 171 | 172 | categories, err := GetPaginated[Category](c.Hive, ctx, path, 0) 173 | if err != nil { 174 | return nil, fmt.Errorf("failed to get categories: %w", err) 175 | } 176 | 177 | if len(categories) == 0 { 178 | return nil, fmt.Errorf("category %s was not found in the library", name) 179 | } 180 | 181 | return &categories[0], nil 182 | } 183 | -------------------------------------------------------------------------------- /pkg/trickest/pagination.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | const defaultPageSize = 500 11 | 12 | // PaginatedResponse represents a generic paginated response from the API 13 | type PaginatedResponse[T any] struct { 14 | Count int `json:"count"` 15 | Next string `json:"next"` 16 | Previous string `json:"previous"` 17 | Results []T `json:"results"` 18 | } 19 | 20 | // withPagination adds pagination parameters to a URL path 21 | func withPagination(path string, page int) string { 22 | if strings.Contains(path, "?") { 23 | path += fmt.Sprintf("&page_size=%d", defaultPageSize) 24 | } else { 25 | path += fmt.Sprintf("?page_size=%d", defaultPageSize) 26 | } 27 | if page > 0 { 28 | path += fmt.Sprintf("&page=%d", page) 29 | } 30 | return path 31 | } 32 | 33 | // GetPaginated gets results from a paginated endpoint with a limit. 34 | // If limit is 0, it will get all results. 35 | func GetPaginated[T any](service *Service, ctx context.Context, path string, limit int) ([]T, error) { 36 | var allResults []T 37 | currentPage := 1 38 | 39 | for { 40 | var response PaginatedResponse[T] 41 | currentPath := withPagination(path, currentPage) 42 | if err := service.doJSON(ctx, http.MethodGet, currentPath, nil, &response); err != nil { 43 | return nil, fmt.Errorf("failed to get page %d: %w", currentPage, err) 44 | } 45 | 46 | allResults = append(allResults, response.Results...) 47 | 48 | if limit > 0 && len(allResults) >= limit { 49 | allResults = allResults[:limit] 50 | break 51 | } 52 | 53 | if response.Next == "" { 54 | break 55 | } 56 | 57 | currentPage++ 58 | } 59 | 60 | return allResults, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/trickest/project.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // Project represents a project 13 | type Project struct { 14 | ID *uuid.UUID `json:"id,omitempty"` 15 | Name string `json:"name,omitempty"` 16 | Description string `json:"description,omitempty"` 17 | SpaceID *uuid.UUID `json:"space_info,omitempty"` 18 | SpaceName string `json:"space_name,omitempty"` 19 | WorkflowCount int `json:"workflow_count,omitempty"` 20 | CreatedDate *time.Time `json:"created_date,omitempty"` 21 | ModifiedDate *time.Time `json:"modified_date,omitempty"` 22 | Author string `json:"author,omitempty"` 23 | Workflows []Workflow `json:"workflows,omitempty"` 24 | } 25 | 26 | func (c *Client) CreateProject(ctx context.Context, name string, description string, spaceID uuid.UUID) (*Project, error) { 27 | path := fmt.Sprintf("/projects/?vault=%s", c.vaultID) 28 | 29 | project := Project{ 30 | Name: name, 31 | Description: description, 32 | SpaceID: &spaceID, 33 | } 34 | 35 | var newProject Project 36 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, &project, &newProject); err != nil { 37 | return nil, fmt.Errorf("failed to create project: %w", err) 38 | } 39 | 40 | return &newProject, nil 41 | } 42 | 43 | // DeleteProject deletes a project 44 | func (c *Client) DeleteProject(ctx context.Context, id uuid.UUID) error { 45 | path := fmt.Sprintf("/projects/%s/", id) 46 | if err := c.Hive.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { 47 | return fmt.Errorf("failed to delete project: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/trickest/run.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | // Run represents a workflow run 15 | type Run struct { 16 | ID *uuid.UUID `json:"id,omitempty"` 17 | Name string `json:"name,omitempty"` 18 | Status string `json:"status,omitempty"` 19 | Machines Machines `json:"machines,omitempty"` 20 | WorkflowVersionInfo *uuid.UUID `json:"workflow_version_info,omitempty"` 21 | WorkflowInfo *uuid.UUID `json:"workflow_info,omitempty"` 22 | WorkflowName string `json:"workflow_name,omitempty"` 23 | SpaceInfo *uuid.UUID `json:"space_info,omitempty"` 24 | SpaceName string `json:"space_name,omitempty"` 25 | ProjectInfo *uuid.UUID `json:"project_info,omitempty"` 26 | ProjectName string `json:"project_name,omitempty"` 27 | CreationType string `json:"creation_type,omitempty"` 28 | CreatedDate *time.Time `json:"created_date,omitempty"` 29 | StartedDate *time.Time `json:"started_date,omitempty"` 30 | CompletedDate *time.Time `json:"completed_date,omitempty"` 31 | Finished bool `json:"finished,omitempty"` 32 | Author string `json:"author,omitempty"` 33 | Fleet *uuid.UUID `json:"fleet,omitempty"` 34 | FleetName string `json:"fleet_name,omitempty"` 35 | Vault *uuid.UUID `json:"vault,omitempty"` 36 | UseStaticIPs *bool `json:"use_static_ips,omitempty"` 37 | IPAddresses []string `json:"ip_addresses,omitempty"` 38 | RunInsights *RunSubJobInsights `json:"run_insights,omitempty"` 39 | Duration *Duration `json:"duration,omitempty"` 40 | AverageDuration *Duration `json:"average_duration,omitempty"` 41 | } 42 | 43 | // Duration is a custom type for duration that json marshals to "Xh Ym" or "Xm Ys" matching the web UI 44 | type Duration struct { 45 | Duration time.Duration 46 | } 47 | 48 | func (d *Duration) MarshalJSON() ([]byte, error) { 49 | seconds := int64(d.Duration.Seconds()) 50 | if seconds >= 3600 { 51 | return json.Marshal(fmt.Sprintf("%dh %dm", seconds/3600, (seconds%3600)/60)) 52 | } 53 | return json.Marshal(fmt.Sprintf("%dm %ds", seconds/60, seconds%60)) 54 | } 55 | 56 | // Machines represents machine configuration 57 | type Machines struct { 58 | Default *int `json:"default,omitempty"` 59 | SelfHosted *int `json:"self_hosted,omitempty"` 60 | } 61 | 62 | type RunSubJobInsights struct { 63 | Total int `json:"total"` 64 | Pending int `json:"pending"` 65 | Running int `json:"running"` 66 | Succeeded int `json:"succeeded"` 67 | Failed int `json:"failed"` 68 | Stopping int `json:"stopping"` 69 | Stopped int `json:"stopped"` 70 | } 71 | 72 | // GetRun retrieves a run by ID 73 | func (c *Client) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) { 74 | var run Run 75 | path := fmt.Sprintf("/execution/%s/", id.String()) 76 | 77 | if err := c.Hive.doJSON(ctx, http.MethodGet, path, nil, &run); err != nil { 78 | return nil, fmt.Errorf("failed to get run: %w", err) 79 | } 80 | 81 | return &run, nil 82 | } 83 | 84 | // GetRunByURL retrieves a run from a workflow URL 85 | func (c *Client) GetRunByURL(ctx context.Context, workflowURL string) (*Run, error) { 86 | u, err := url.Parse(workflowURL) 87 | if err != nil { 88 | return nil, fmt.Errorf("invalid URL: %w", err) 89 | } 90 | 91 | queryParams, err := url.ParseQuery(u.RawQuery) 92 | if err != nil { 93 | return nil, fmt.Errorf("invalid URL query: %w", err) 94 | } 95 | 96 | runIDs, found := queryParams["run"] 97 | if !found { 98 | return nil, nil // No run ID present, but this isn't an error 99 | } 100 | 101 | if len(runIDs) != 1 { 102 | return nil, fmt.Errorf("invalid number of run parameters in URL: %d", len(runIDs)) 103 | } 104 | 105 | runID, err := uuid.Parse(runIDs[0]) 106 | if err != nil { 107 | return nil, fmt.Errorf("invalid run ID format: %w", err) 108 | } 109 | 110 | return c.GetRun(ctx, runID) 111 | } 112 | 113 | // GetRuns retrieves workflow runs with optional filtering 114 | func (c *Client) GetRuns(ctx context.Context, workflowID uuid.UUID, status string, limit int) ([]Run, error) { 115 | path := fmt.Sprintf("/execution/?type=Editor&vault=%s", c.vaultID) 116 | 117 | if workflowID != uuid.Nil { 118 | path += fmt.Sprintf("&workflow=%s", workflowID) 119 | } 120 | 121 | if status != "" { 122 | path += fmt.Sprintf("&status=%s", status) 123 | } 124 | 125 | runs, err := GetPaginated[Run](c.Hive, ctx, path, limit) 126 | if err != nil { 127 | return nil, fmt.Errorf("failed to get runs: %w", err) 128 | } 129 | 130 | return runs, nil 131 | } 132 | 133 | // GetLatestRun retrieves the latest run for a workflow 134 | func (c *Client) GetLatestRun(ctx context.Context, workflowID uuid.UUID) (*Run, error) { 135 | runs, err := c.GetRuns(ctx, workflowID, "", 1) 136 | if err != nil { 137 | return nil, fmt.Errorf("failed to get runs: %w", err) 138 | } 139 | if len(runs) < 1 { 140 | return nil, fmt.Errorf("no runs found for workflow") 141 | } 142 | return &runs[0], nil 143 | } 144 | 145 | // GetRunIPAddresses retrieves the IP addresses associated with a run 146 | func (c *Client) GetRunIPAddresses(ctx context.Context, runID uuid.UUID) ([]string, error) { 147 | var ipAddresses []string 148 | path := fmt.Sprintf("/execution/%s/ips/", runID) 149 | 150 | if err := c.Hive.doJSON(ctx, http.MethodGet, path, nil, &ipAddresses); err != nil { 151 | return nil, fmt.Errorf("failed to get run IP addresses: %w", err) 152 | } 153 | 154 | return ipAddresses, nil 155 | } 156 | 157 | // StopRun stops a workflow run 158 | func (c *Client) StopRun(ctx context.Context, id uuid.UUID) error { 159 | path := fmt.Sprintf("/execution/%s/stop/", id.String()) 160 | 161 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, nil, &Run{}); err != nil { 162 | return fmt.Errorf("failed to stop run: %w", err) 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func (c *Client) CreateRun(ctx context.Context, versionID uuid.UUID, machines int, fleet Fleet, useStaticIPs bool) (*Run, error) { 169 | path := "/execution/" 170 | 171 | if versionID == uuid.Nil { 172 | return nil, fmt.Errorf("version ID cannot be nil") 173 | } 174 | 175 | if fleet.ID == uuid.Nil { 176 | return nil, fmt.Errorf("invalid fleet") 177 | } 178 | 179 | if len(fleet.Machines) == 0 { 180 | return nil, fmt.Errorf("fleet has no machines") 181 | } 182 | 183 | run := Run{ 184 | WorkflowVersionInfo: &versionID, 185 | Fleet: &fleet.ID, 186 | Vault: &fleet.Vault, 187 | UseStaticIPs: &useStaticIPs, 188 | } 189 | 190 | if fleet.Machines[0].Name == "self_hosted" { 191 | run.Machines = Machines{ 192 | SelfHosted: &machines, 193 | } 194 | } else { 195 | run.Machines = Machines{ 196 | Default: &machines, 197 | } 198 | } 199 | 200 | var createdRun Run 201 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, &run, &createdRun); err != nil { 202 | return nil, fmt.Errorf("failed to create run: %w", err) 203 | } 204 | 205 | return &createdRun, nil 206 | } 207 | 208 | func (c *Client) GetRunSubJobInsights(ctx context.Context, runID uuid.UUID) (*RunSubJobInsights, error) { 209 | var insights RunSubJobInsights 210 | path := fmt.Sprintf("/subjob/insight?execution=%s", runID) 211 | 212 | if err := c.Hive.doJSON(ctx, http.MethodGet, path, nil, &insights); err != nil { 213 | return nil, fmt.Errorf("failed to get run insights: %w", err) 214 | } 215 | 216 | return &insights, nil 217 | } 218 | -------------------------------------------------------------------------------- /pkg/trickest/script.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type Script struct { 12 | ID *uuid.UUID `json:"id,omitempty"` 13 | Name string `json:"name,omitempty"` 14 | Description string `json:"description,omitempty"` 15 | VaultInfo *uuid.UUID `json:"vault_info,omitempty"` 16 | Author string `json:"author,omitempty"` 17 | AuthorInfo int `json:"author_info,omitempty"` 18 | Type string `json:"type,omitempty"` 19 | Inputs struct { 20 | File NodeInput `json:"file,omitempty"` 21 | Folder NodeInput `json:"folder,omitempty"` 22 | } `json:"inputs,omitempty"` 23 | Outputs struct { 24 | File NodeOutput `json:"file,omitempty"` 25 | Folder NodeOutput `json:"folder,omitempty"` 26 | } `json:"outputs,omitempty"` 27 | Script struct { 28 | Args []any `json:"args,omitempty"` 29 | Image string `json:"image,omitempty"` 30 | Source string `json:"source,omitempty"` 31 | } `json:"script,omitempty"` 32 | Command string `json:"command,omitempty"` 33 | ScriptType string `json:"script_type,omitempty"` 34 | } 35 | 36 | type ScriptImport struct { 37 | ID *uuid.UUID `json:"id,omitempty"` 38 | VaultInfo *uuid.UUID `json:"vault_info" yaml:"vault_info"` 39 | Name string `json:"name" yaml:"name"` 40 | Description string `json:"description" yaml:"description"` 41 | ScriptType string `json:"script_type" yaml:"script_type"` 42 | Script string `json:"script" yaml:"script"` 43 | } 44 | 45 | // ListPrivateScripts lists all private scripts 46 | func (c *Client) ListPrivateScripts(ctx context.Context) ([]Script, error) { 47 | path := fmt.Sprintf("/library/script/?public=False&vault=%s", c.vaultID) 48 | 49 | scripts, err := GetPaginated[Script](c.Hive, ctx, path, 0) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to get scripts: %w", err) 52 | } 53 | 54 | return scripts, nil 55 | } 56 | 57 | // GetPrivateScriptByName gets a private script by name 58 | func (c *Client) GetPrivateScriptByName(ctx context.Context, scriptName string) (*Script, error) { 59 | path := fmt.Sprintf("/library/script/?public=False&vault=%s&name=%s", c.vaultID, scriptName) 60 | 61 | scripts, err := GetPaginated[Script](c.Hive, ctx, path, 0) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to get script: %w", err) 64 | } 65 | 66 | if len(scripts) == 0 { 67 | return nil, fmt.Errorf("couldn't find script %q", scriptName) 68 | } 69 | 70 | return &scripts[0], nil 71 | } 72 | 73 | // CreatePrivateScript creates a new private script 74 | func (c *Client) CreatePrivateScript(ctx context.Context, script *ScriptImport) (*ScriptImport, error) { 75 | path := "/script/" 76 | 77 | script.VaultInfo = &c.vaultID 78 | 79 | var createdScript ScriptImport 80 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, script, &createdScript); err != nil { 81 | return nil, fmt.Errorf("failed to create private script: %w", err) 82 | } 83 | 84 | return &createdScript, nil 85 | } 86 | 87 | // UpdatePrivateScript updates a private script 88 | func (c *Client) UpdatePrivateScript(ctx context.Context, script *ScriptImport, scriptID uuid.UUID) (*ScriptImport, error) { 89 | path := fmt.Sprintf("/script/%s/", scriptID) 90 | 91 | var updatedScript ScriptImport 92 | if err := c.Hive.doJSON(ctx, http.MethodPatch, path, script, &updatedScript); err != nil { 93 | return nil, fmt.Errorf("failed to update private script: %w", err) 94 | } 95 | 96 | return &updatedScript, nil 97 | } 98 | 99 | // DeletePrivateScript deletes a private script 100 | func (c *Client) DeletePrivateScript(ctx context.Context, scriptID uuid.UUID) error { 101 | path := fmt.Sprintf("/script/%s/", scriptID) 102 | 103 | if err := c.Hive.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { 104 | return fmt.Errorf("failed to delete private script: %w", err) 105 | } 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/trickest/space.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // Space represents a space 13 | type Space struct { 14 | ID *uuid.UUID `json:"id,omitempty"` 15 | Name string `json:"name,omitempty"` 16 | Description string `json:"description,omitempty"` 17 | VaultID *uuid.UUID `json:"vault_info,omitempty"` 18 | Playground bool `json:"playground,omitempty"` 19 | Projects []Project `json:"projects,omitempty"` 20 | ProjectsCount int `json:"projects_count,omitempty"` 21 | Workflows []Workflow `json:"workflows,omitempty"` 22 | WorkflowsCount int `json:"workflows_count,omitempty"` 23 | CreatedDate *time.Time `json:"created_date,omitempty"` 24 | ModifiedDate *time.Time `json:"modified_date,omitempty"` 25 | } 26 | 27 | // GetSpace retrieves a space by ID 28 | func (c *Client) GetSpace(ctx context.Context, id uuid.UUID) (*Space, error) { 29 | var space Space 30 | path := fmt.Sprintf("/spaces/%s/", id) 31 | if err := c.Hive.doJSON(ctx, http.MethodGet, path, nil, &space); err != nil { 32 | return nil, fmt.Errorf("failed to get space: %w", err) 33 | } 34 | 35 | return &space, nil 36 | } 37 | 38 | // GetSpaces retrieves all spaces for the current vault 39 | func (c *Client) GetSpaces(ctx context.Context, name string) ([]Space, error) { 40 | path := fmt.Sprintf("/spaces/?vault=%s", c.vaultID) 41 | if name != "" { 42 | path += fmt.Sprintf("&name=%s", name) 43 | } 44 | 45 | spaces, err := GetPaginated[Space](c.Hive, ctx, path, 0) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to get spaces: %w", err) 48 | } 49 | 50 | return spaces, nil 51 | } 52 | 53 | // GetSpaceByName retrieves a space by name 54 | func (c *Client) GetSpaceByName(ctx context.Context, name string) (*Space, error) { 55 | spaces, err := c.GetSpaces(ctx, name) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if len(spaces) == 0 { 61 | return nil, fmt.Errorf("space %q not found", name) 62 | } 63 | 64 | // loop through the results to find the space with the exact name 65 | for _, space := range spaces { 66 | if space.Name == name { 67 | space, err := c.GetSpace(ctx, *space.ID) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to get space: %w", err) 70 | } 71 | return space, nil 72 | } 73 | } 74 | 75 | return nil, fmt.Errorf("space %q not found", name) 76 | } 77 | 78 | // CreateSpace creates a new space 79 | func (c *Client) CreateSpace(ctx context.Context, name string, description string) (*Space, error) { 80 | path := "/spaces/" 81 | 82 | space := Space{ 83 | Name: name, 84 | Description: description, 85 | VaultID: &c.vaultID, 86 | } 87 | 88 | var newSpace Space 89 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, &space, &newSpace); err != nil { 90 | return nil, fmt.Errorf("failed to create space: %w", err) 91 | } 92 | 93 | return &newSpace, nil 94 | } 95 | 96 | // GetProjectByName retrieves a project by name from a space 97 | func (s *Space) GetProjectByName(name string) (*Project, error) { 98 | for _, project := range s.Projects { 99 | if project.Name == name { 100 | return &project, nil 101 | } 102 | } 103 | return nil, fmt.Errorf("project %q not found in space %q", name, s.Name) 104 | } 105 | 106 | // DeleteSpace deletes a space 107 | func (c *Client) DeleteSpace(ctx context.Context, id uuid.UUID) error { 108 | path := fmt.Sprintf("/spaces/%s/", id) 109 | if err := c.Hive.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { 110 | return fmt.Errorf("failed to delete space: %w", err) 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /pkg/trickest/subjob.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "slices" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // SubJob represents a sub-job in a workflow run 16 | type SubJob struct { 17 | ID uuid.UUID `json:"id"` 18 | Status string `json:"status"` 19 | Name string `json:"name"` 20 | OutputsStatus string `json:"outputs_status"` 21 | Finished bool `json:"finished"` 22 | StartedDate time.Time `json:"started_at"` 23 | FinishedDate time.Time `json:"finished_at"` 24 | Params map[string]any `json:"params"` 25 | Message string `json:"message"` 26 | TaskGroup bool `json:"task_group"` 27 | TaskIndex int `json:"task_index"` 28 | IPAddress string `json:"ip_address"` 29 | TWEid uuid.UUID `json:"twe_id"` 30 | Label string 31 | Children []SubJob 32 | TaskCount int 33 | } 34 | 35 | type SubJobOutput struct { 36 | ID uuid.UUID `json:"id"` 37 | Name string `json:"name"` 38 | Size int `json:"size"` 39 | PrettySize string `json:"pretty_size"` 40 | Format string `json:"format"` 41 | Path string `json:"path"` 42 | SignedURL string `json:"signed_url,omitempty"` 43 | } 44 | 45 | type SignedURL struct { 46 | Url string `json:"url"` 47 | Size int `json:"size"` 48 | PrettySize string `json:"pretty_size"` 49 | } 50 | 51 | // GetSubJobs retrieves all sub-jobs for a run 52 | func (c *Client) GetSubJobs(ctx context.Context, runID uuid.UUID) ([]SubJob, error) { 53 | path := fmt.Sprintf("/subjob/?execution=%s", runID.String()) 54 | 55 | subjobs, err := GetPaginated[SubJob](c.Hive, ctx, path, 0) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to get sub-jobs: %w", err) 58 | } 59 | 60 | return subjobs, nil 61 | } 62 | 63 | // StopSubJob stops a sub-job 64 | func (c *Client) StopSubJob(ctx context.Context, subJobID uuid.UUID) error { 65 | path := fmt.Sprintf("/subjob/%s/stop/", subJobID) 66 | 67 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, nil, nil); err != nil { 68 | return fmt.Errorf("failed to stop sub-job: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // GetChildSubJobs retrieves all child sub-jobs for a lifted sub-job (task group) 75 | func (c *Client) GetChildSubJobs(ctx context.Context, parentID uuid.UUID) ([]SubJob, error) { 76 | path := fmt.Sprintf("/subjob/children/?parent=%s", parentID.String()) 77 | 78 | children, err := GetPaginated[SubJob](c.Hive, ctx, path, 0) 79 | if err != nil { 80 | return nil, fmt.Errorf("failed to get child sub-jobs: %w", err) 81 | } 82 | 83 | return children, nil 84 | } 85 | 86 | // GetChildSubJob retrieves a specific child sub-job by task index 87 | func (c *Client) GetChildSubJob(ctx context.Context, parentID uuid.UUID, taskIndex int) (SubJob, error) { 88 | path := fmt.Sprintf("/subjob/children/?parent=%s&task_index=%d", parentID.String(), taskIndex) 89 | 90 | children, err := GetPaginated[SubJob](c.Hive, ctx, path, 1) 91 | if err != nil { 92 | return SubJob{}, fmt.Errorf("failed to get child sub-job: %w", err) 93 | } 94 | 95 | if len(children) == 0 { 96 | return SubJob{}, fmt.Errorf("no child sub-job found for task index %d", taskIndex) 97 | } 98 | 99 | return children[0], nil 100 | } 101 | 102 | func (c *Client) GetSubJobOutputs(ctx context.Context, subJobID uuid.UUID) ([]SubJobOutput, error) { 103 | path := fmt.Sprintf("/subjob-output/?subjob=%s", subJobID.String()) 104 | 105 | subJobOutputs, err := GetPaginated[SubJobOutput](c.Hive, ctx, path, 0) 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to get sub-job outputs: %w", err) 108 | } 109 | 110 | return subJobOutputs, nil 111 | } 112 | 113 | func (c *Client) GetModuleSubJobOutputs(ctx context.Context, moduleName string, runID uuid.UUID) ([]SubJobOutput, error) { 114 | path := fmt.Sprintf("/subjob-output/module-outputs/?module_name=%s&execution=%s", moduleName, runID.String()) 115 | 116 | subJobOutputs, err := GetPaginated[SubJobOutput](c.Hive, ctx, path, 0) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to get module sub-job outputs: %w", err) 119 | } 120 | 121 | return subJobOutputs, nil 122 | } 123 | 124 | func (c *Client) GetOutputSignedURL(ctx context.Context, outputID uuid.UUID) (SignedURL, error) { 125 | path := fmt.Sprintf("/job/%s/signed_url/", outputID) 126 | 127 | var signedURL SignedURL 128 | if err := c.Orchestrator.doJSON(ctx, http.MethodGet, path, nil, &signedURL); err != nil { 129 | return SignedURL{}, fmt.Errorf("failed to get output signed URL: %w", err) 130 | } 131 | 132 | return signedURL, nil 133 | } 134 | 135 | func LabelSubJobs(subJobs []SubJob, version WorkflowVersion) []SubJob { 136 | labels := make(map[string]bool) 137 | for i := range subJobs { 138 | subJobs[i].Label = version.Data.Nodes[subJobs[i].Name].Meta.Label 139 | subJobs[i].Label = strings.ReplaceAll(subJobs[i].Label, "/", "-") 140 | if labels[subJobs[i].Label] { 141 | existingLabel := subJobs[i].Label 142 | subJobs[i].Label = subJobs[i].Name 143 | if labels[subJobs[i].Label] { 144 | subJobs[i].Label += "-1" 145 | for c := 1; c >= 1; c++ { 146 | if labels[subJobs[i].Label] { 147 | subJobs[i].Label = strings.TrimSuffix(subJobs[i].Label, "-"+strconv.Itoa(c)) 148 | subJobs[i].Label += "-" + strconv.Itoa(c+1) 149 | } else { 150 | labels[subJobs[i].Label] = true 151 | break 152 | } 153 | } 154 | } else { 155 | for s := 0; s < i; s++ { 156 | if subJobs[s].Label == existingLabel { 157 | subJobs[s].Label = subJobs[s].Name 158 | if subJobs[s].Children != nil { 159 | for j := range subJobs[s].Children { 160 | subJobs[s].Children[j].Label = strconv.Itoa(subJobs[s].Children[j].TaskIndex) + "-" + subJobs[s].Name 161 | } 162 | } 163 | } 164 | } 165 | labels[subJobs[i].Label] = true 166 | } 167 | } else { 168 | labels[subJobs[i].Label] = true 169 | } 170 | } 171 | return subJobs 172 | } 173 | 174 | // FilterSubJobs filters the subjobs based on the identifiers (label or name (node ID)) 175 | func FilterSubJobs(subJobs []SubJob, identifiers []string) ([]SubJob, error) { 176 | if len(identifiers) == 0 { 177 | return subJobs, nil 178 | } 179 | 180 | var foundNodes []string 181 | var matchingSubJobs []SubJob 182 | 183 | for _, subJob := range subJobs { 184 | labelExists := slices.Contains(identifiers, subJob.Label) 185 | nameExists := slices.Contains(identifiers, subJob.Name) 186 | 187 | if labelExists { 188 | foundNodes = append(foundNodes, subJob.Label) 189 | } 190 | if nameExists { 191 | foundNodes = append(foundNodes, subJob.Name) 192 | } 193 | 194 | if labelExists || nameExists { 195 | matchingSubJobs = append(matchingSubJobs, subJob) 196 | } 197 | } 198 | 199 | for _, identifier := range identifiers { 200 | if !slices.Contains(foundNodes, identifier) { 201 | return nil, fmt.Errorf("subjob with name or label %s not found", identifier) 202 | } 203 | } 204 | 205 | return matchingSubJobs, nil 206 | } 207 | -------------------------------------------------------------------------------- /pkg/trickest/tool.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | var toolOutputTypes = map[string]string{ 14 | "file": "2", 15 | "folder": "3", 16 | } 17 | 18 | type Tool struct { 19 | ID *uuid.UUID `json:"id,omitempty"` 20 | Name string `json:"name,omitempty"` 21 | Description string `json:"description,omitempty"` 22 | VaultInfo *uuid.UUID `json:"vault_info,omitempty"` 23 | Author string `json:"author,omitempty"` 24 | AuthorInfo int `json:"author_info,omitempty"` 25 | ToolCategory string `json:"tool_category,omitempty"` 26 | ToolCategoryName string `json:"tool_category_name,omitempty"` 27 | Type string `json:"type,omitempty"` 28 | Inputs map[string]NodeInput `json:"inputs,omitempty"` 29 | Container *struct { 30 | Args []string `json:"args,omitempty"` 31 | Image string `json:"image,omitempty"` 32 | Command []string `json:"command,omitempty"` 33 | } `json:"container,omitempty"` 34 | Outputs struct { 35 | File NodeOutput `json:"file,omitempty"` 36 | Folder NodeOutput `json:"folder,omitempty"` 37 | } `json:"outputs,omitempty"` 38 | SourceURL string `json:"source_url,omitempty"` 39 | CreatedDate *time.Time `json:"created_date,omitempty"` 40 | ModifiedDate *time.Time `json:"modified_date,omitempty"` 41 | OutputCommand string `json:"output_command,omitempty"` 42 | LicenseInfo struct { 43 | Name string `json:"name,omitempty"` 44 | Url string `json:"url,omitempty"` 45 | } `json:"license_info,omitempty"` 46 | DocLink string `json:"doc_link,omitempty"` 47 | } 48 | 49 | type ToolImport struct { 50 | VaultInfo *uuid.UUID `json:"vault_info" yaml:"vault_info"` 51 | Name string `json:"name" yaml:"name"` 52 | Description string `json:"description" yaml:"description"` 53 | Category string `json:"tool_category_name" yaml:"category"` 54 | CategoryID *uuid.UUID `json:"tool_category" yaml:"tool_category"` 55 | OutputCommand string `json:"output_command" yaml:"output_parameter"` 56 | SourceURL string `json:"source_url" yaml:"source_url"` 57 | DockerImage string `json:"docker_image" yaml:"docker_image"` 58 | Command string `json:"command" yaml:"command"` 59 | OutputType string `json:"output_type" yaml:"output_type"` 60 | Inputs map[string]NodeInput `json:"inputs" yaml:"inputs"` 61 | LicenseInfo struct { 62 | Name string `json:"name" yaml:"name"` 63 | Url string `json:"url" yaml:"url"` 64 | } `json:"license_info" yaml:"license_info"` 65 | DocLink string `json:"doc_link" yaml:"doc_link"` 66 | } 67 | 68 | func (c *Client) ListPrivateTools(ctx context.Context) ([]Tool, error) { 69 | path := fmt.Sprintf("/library/tool/?public=False&vault=%s", c.vaultID) 70 | 71 | tools, err := GetPaginated[Tool](c.Hive, ctx, path, 0) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to get tools: %w", err) 74 | } 75 | 76 | return tools, nil 77 | } 78 | 79 | // GetPrivateToolByName gets a private tool by name 80 | func (c *Client) GetPrivateToolByName(ctx context.Context, toolName string) (*Tool, error) { 81 | path := fmt.Sprintf("/library/tool/?public=False&vault=%s&name=%s", c.vaultID, toolName) 82 | 83 | tools, err := GetPaginated[Tool](c.Hive, ctx, path, 0) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to get tool: %w", err) 86 | } 87 | 88 | if len(tools) == 0 { 89 | return nil, fmt.Errorf("couldn't find tool %q", toolName) 90 | } 91 | 92 | return &tools[0], nil 93 | } 94 | 95 | // prepareToolImport prepares a tool import for creation by adding the vault ID, category ID, and output type, and setting the visible and type properties of the inputs 96 | func (c *Client) prepareToolImport(ctx context.Context, tool *ToolImport) (*ToolImport, error) { 97 | tool.VaultInfo = &c.vaultID 98 | 99 | category, err := c.GetLibraryCategoryByName(ctx, tool.Category) 100 | if err != nil { 101 | return nil, fmt.Errorf("couldn't use the category %q: %w", tool.Category, err) 102 | } 103 | tool.CategoryID = &category.ID 104 | 105 | if tool.VaultInfo == nil { 106 | tool.VaultInfo = &c.vaultID 107 | } 108 | 109 | tool.OutputType = toolOutputTypes[tool.OutputType] 110 | for name := range tool.Inputs { 111 | if input, ok := tool.Inputs[name]; ok { 112 | if input.Visible == nil { 113 | input.Visible = &[]bool{false}[0] 114 | } 115 | input.Type = strings.ToUpper(tool.Inputs[name].Type) 116 | tool.Inputs[name] = input 117 | } 118 | } 119 | 120 | return tool, nil 121 | } 122 | 123 | // CreatePrivateTool creates a new private tool 124 | func (c *Client) CreatePrivateTool(ctx context.Context, tool *ToolImport) (*ToolImport, error) { 125 | path := "/library/tool/" 126 | 127 | tool, err := c.prepareToolImport(ctx, tool) 128 | if err != nil { 129 | return nil, fmt.Errorf("failed to prepare tool import: %w", err) 130 | } 131 | 132 | var createdTool ToolImport 133 | if err := c.Hive.doJSON(ctx, http.MethodPost, path, tool, &createdTool); err != nil { 134 | return nil, fmt.Errorf("failed to create private tool: %w", err) 135 | } 136 | 137 | return &createdTool, nil 138 | } 139 | 140 | // UpdatePrivateTool updates a private tool 141 | func (c *Client) UpdatePrivateTool(ctx context.Context, tool *ToolImport, toolID uuid.UUID) (*ToolImport, error) { 142 | path := fmt.Sprintf("/library/tool/%s/", toolID) 143 | 144 | tool, err := c.prepareToolImport(ctx, tool) 145 | if err != nil { 146 | return nil, fmt.Errorf("failed to prepare tool import: %w", err) 147 | } 148 | 149 | var updatedTool ToolImport 150 | if err := c.Hive.doJSON(ctx, http.MethodPatch, path, tool, &updatedTool); err != nil { 151 | return nil, fmt.Errorf("failed to update private tool: %w", err) 152 | } 153 | 154 | return &updatedTool, nil 155 | } 156 | 157 | // DeletePrivateTool deletes a private tool 158 | func (c *Client) DeletePrivateTool(ctx context.Context, toolID uuid.UUID) error { 159 | path := fmt.Sprintf("/library/tool/%s/", toolID) 160 | 161 | if err := c.Hive.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { 162 | return fmt.Errorf("failed to delete private tool: %w", err) 163 | } 164 | 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /pkg/trickest/user.go: -------------------------------------------------------------------------------- 1 | package trickest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // User represents the current user's information 13 | type User struct { 14 | ID int `json:"id"` 15 | IsActive bool `json:"is_active"` 16 | Email string `json:"email"` 17 | FirstName string `json:"first_name"` 18 | LastName string `json:"last_name"` 19 | Onboarding bool `json:"onboarding"` 20 | Profile Profile `json:"profile"` 21 | InitialCredit int `json:"initial_credit"` 22 | } 23 | 24 | // Profile represents the user's profile information 25 | type Profile struct { 26 | VaultInfo VaultInfo `json:"vault_info"` 27 | Bio string `json:"bio"` 28 | Type int `json:"type"` 29 | Username string `json:"username"` 30 | EntityType string `json:"entity_type"` 31 | } 32 | 33 | // VaultInfo represents the user's vault information 34 | type VaultInfo struct { 35 | ID uuid.UUID `json:"id"` 36 | Name string `json:"name"` 37 | Type int `json:"type"` 38 | Metadata string `json:"metadata"` 39 | CreatedDate time.Time `json:"created_date"` 40 | ModifiedDate time.Time `json:"modified_date"` 41 | } 42 | 43 | type IPAddress struct { 44 | IPAddress string `json:"ip_address"` 45 | } 46 | 47 | type Fleet struct { 48 | ID uuid.UUID `json:"id"` 49 | Name string `json:"name"` 50 | Vault uuid.UUID `json:"vault"` 51 | Cluster string `json:"cluster"` 52 | State string `json:"state"` 53 | Machines []struct { 54 | Name string `json:"name"` 55 | Description string `json:"description"` 56 | Mem string `json:"mem"` 57 | CPU string `json:"cpu"` 58 | Total int `json:"total"` 59 | Running int `json:"running"` 60 | Up int `json:"up"` 61 | Down int `json:"down"` 62 | } `json:"machines"` 63 | CreatedDate time.Time `json:"created_date"` 64 | ModifiedDate time.Time `json:"modified_date"` 65 | Type string `json:"type"` 66 | Default bool `json:"default"` 67 | } 68 | 69 | // GetCurrentUser retrieves the current user's information 70 | func (c *Client) GetCurrentUser(ctx context.Context) (*User, error) { 71 | var user User 72 | if err := c.Hive.doJSON(ctx, http.MethodGet, "/users/me/", nil, &user); err != nil { 73 | return nil, fmt.Errorf("failed to get user info: %w", err) 74 | } 75 | return &user, nil 76 | } 77 | 78 | // GetVaultIPAddresses retrieves the static IP addresses for the current user's vault 79 | func (c *Client) GetVaultIPAddresses(ctx context.Context) ([]IPAddress, error) { 80 | path := fmt.Sprintf("/ip/?vault=%s", c.vaultID) 81 | 82 | ipAddresses, err := GetPaginated[IPAddress](c.Hive, ctx, path, 0) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to get IP addresses: %w", err) 85 | } 86 | 87 | return ipAddresses, nil 88 | } 89 | 90 | func (c *Client) GetFleets(ctx context.Context) ([]Fleet, error) { 91 | path := fmt.Sprintf("/fleet/?vault=%s", c.vaultID) 92 | 93 | fleets, err := GetPaginated[Fleet](c.Hive, ctx, path, 0) 94 | if err != nil { 95 | return nil, fmt.Errorf("failed to get fleets: %w", err) 96 | } 97 | 98 | return fleets, nil 99 | } 100 | 101 | func (c *Client) GetFleet(ctx context.Context, fleetID uuid.UUID) (*Fleet, error) { 102 | fleets, err := c.GetFleets(ctx) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to get fleets: %w", err) 105 | } 106 | 107 | for _, fleet := range fleets { 108 | if fleet.ID == fleetID { 109 | return &fleet, nil 110 | } 111 | } 112 | 113 | return nil, fmt.Errorf("fleet %q not found", fleetID) 114 | } 115 | 116 | func (c *Client) GetFleetByName(ctx context.Context, fleetName string) (*Fleet, error) { 117 | fleets, err := c.GetFleets(ctx) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to get fleets: %w", err) 120 | } 121 | 122 | if len(fleets) == 0 { 123 | return nil, fmt.Errorf("no fleets found") 124 | } 125 | 126 | for _, fleet := range fleets { 127 | if fleet.Name == fleetName { 128 | return &fleet, nil 129 | } 130 | } 131 | 132 | return nil, fmt.Errorf("fleet %q not found", fleetName) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version is the current version of the CLI 4 | var Version = "v2.0.0" 5 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/connection.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | ) 9 | 10 | // addConnection adds a connection between two nodes to the workflow version 11 | func addConnection(wfVersion *trickest.WorkflowVersion, sourceName, sourcePort, destinationName, destinationPort string) error { 12 | if wfVersion == nil { 13 | return fmt.Errorf("workflow version is nil") 14 | } 15 | 16 | wfVersion.Data.Connections = append(wfVersion.Data.Connections, trickest.Connection{ 17 | Source: trickest.ConnectionEndpoint{ 18 | ID: fmt.Sprintf("output/%s/%s", sourceName, sourcePort), 19 | }, 20 | Destination: trickest.ConnectionEndpoint{ 21 | ID: fmt.Sprintf("input/%s/%s/%s", destinationName, destinationPort, sourceName), 22 | }, 23 | }) 24 | 25 | return nil 26 | } 27 | 28 | // removeConnection removes a connection between nodes from the workflow version 29 | func removeConnection(wfVersion *trickest.WorkflowVersion, sourceName, sourcePort, destinationName, destinationPort string) error { 30 | if wfVersion == nil { 31 | return fmt.Errorf("workflow version is nil") 32 | } 33 | 34 | sourceID := fmt.Sprintf("output/%s/%s", sourceName, sourcePort) 35 | destinationID := fmt.Sprintf("input/%s/%s/%s", destinationName, destinationPort, sourceName) 36 | 37 | for i, connection := range wfVersion.Data.Connections { 38 | if connection.Source.ID == sourceID && connection.Destination.ID == destinationID { 39 | // Remove the connection by appending all connections except the one at index i 40 | wfVersion.Data.Connections = append(wfVersion.Data.Connections[:i], wfVersion.Data.Connections[i+1:]...) 41 | return nil 42 | } 43 | } 44 | 45 | return fmt.Errorf("connection not found") 46 | } 47 | 48 | // findPrimitiveNodesConnectedToParam finds all primitive nodes connected to a specific node's parameter 49 | func findPrimitiveNodesConnectedToParam(wfVersion *trickest.WorkflowVersion, nodeID string, paramName string) ([]string, error) { 50 | if wfVersion == nil { 51 | return nil, fmt.Errorf("workflow version is nil") 52 | } 53 | 54 | var primitiveNodeIDs []string 55 | for _, connection := range wfVersion.Data.Connections { 56 | destTokens := strings.Split(strings.TrimPrefix(connection.Destination.ID, "input/"), "/") 57 | if len(destTokens) < 2 { 58 | continue 59 | } 60 | destNodeID := destTokens[0] 61 | destParam := destTokens[1] 62 | 63 | if destNodeID == nodeID && destParam == paramName { 64 | sourceID := strings.TrimSuffix(strings.TrimPrefix(connection.Source.ID, "output/"), "/output") 65 | // Check if source is a primitive node by looking at the prefix 66 | if strings.HasPrefix(sourceID, "string-input-") || 67 | strings.HasPrefix(sourceID, "boolean-input-") || 68 | strings.HasPrefix(sourceID, "http-input-") || 69 | strings.HasPrefix(sourceID, "git-input-") { 70 | primitiveNodeIDs = append(primitiveNodeIDs, sourceID) 71 | } 72 | } 73 | } 74 | 75 | return primitiveNodeIDs, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/lookup.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/trickest/trickest-cli/pkg/trickest" 7 | ) 8 | 9 | // NodeLookupTable is a lookup table for nodes and primitive nodes in a workflow version that allows for quick lookup of node IDs by label or ID 10 | type NodeLookupTable struct { 11 | Nodes map[string]*trickest.Node 12 | PrimitiveNodes map[string]*trickest.PrimitiveNode 13 | } 14 | 15 | func BuildNodeLookupTable(wfVersion *trickest.WorkflowVersion) *NodeLookupTable { 16 | lookup := &NodeLookupTable{ 17 | Nodes: make(map[string]*trickest.Node), 18 | PrimitiveNodes: make(map[string]*trickest.PrimitiveNode), 19 | } 20 | 21 | for nodeID, node := range wfVersion.Data.Nodes { 22 | lookup.Nodes[nodeID] = node 23 | lookup.Nodes[node.Meta.Label] = node 24 | } 25 | 26 | for nodeID, node := range wfVersion.Data.PrimitiveNodes { 27 | lookup.PrimitiveNodes[nodeID] = node 28 | lookup.PrimitiveNodes[node.Label] = node 29 | } 30 | 31 | return lookup 32 | } 33 | 34 | func (lookup *NodeLookupTable) getNodeIDFromReference(ref string) (string, error) { 35 | if node, exists := lookup.Nodes[ref]; exists { 36 | return node.Name, nil 37 | } 38 | return "", fmt.Errorf("node %q was not found in the workflow", ref) 39 | } 40 | 41 | func (lookup *NodeLookupTable) getPrimitiveNodeIDFromReference(ref string) (string, error) { 42 | if node, exists := lookup.PrimitiveNodes[ref]; exists { 43 | return node.Name, nil 44 | } 45 | return "", fmt.Errorf("primitive node %q was not found in the workflow", ref) 46 | } 47 | 48 | func (lookup *NodeLookupTable) ResolveInputs(inputs *Inputs) error { 49 | for i := range inputs.NodeInputs { 50 | nodeID, err := lookup.getNodeIDFromReference(inputs.NodeInputs[i].NodeID) 51 | if err != nil { 52 | return fmt.Errorf("failed to resolve node reference %q: %w", inputs.NodeInputs[i].NodeID, err) 53 | } 54 | inputs.NodeInputs[i].NodeID = nodeID 55 | } 56 | 57 | for i := range inputs.PrimitiveNodeInputs { 58 | nodeID, err := lookup.getPrimitiveNodeIDFromReference(inputs.PrimitiveNodeInputs[i].PrimitiveNodeID) 59 | if err != nil { 60 | return fmt.Errorf("failed to resolve primitive node reference %q: %w", inputs.PrimitiveNodeInputs[i].PrimitiveNodeID, err) 61 | } 62 | inputs.PrimitiveNodeInputs[i].PrimitiveNodeID = nodeID 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (lookup *NodeLookupTable) GetNodeInputType(nodeID string, paramName string) (string, error) { 69 | node, exists := lookup.Nodes[nodeID] 70 | if !exists { 71 | return "", fmt.Errorf("node %q was not found", nodeID) 72 | } 73 | 74 | param, exists := node.Inputs[paramName] 75 | if !exists { 76 | return "", fmt.Errorf("parameter %q not found for node %q", paramName, nodeID) 77 | } 78 | return param.Type, nil 79 | } 80 | 81 | func (lookup *NodeLookupTable) GetPrimitiveNodeInputType(nodeID string) (string, error) { 82 | node, exists := lookup.PrimitiveNodes[nodeID] 83 | if !exists { 84 | return "", fmt.Errorf("primitive node %q was not found", nodeID) 85 | } 86 | return node.Type, nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/node.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/trickest/trickest-cli/pkg/trickest" 9 | ) 10 | 11 | func GetLabeledNodes(wfVersion *trickest.WorkflowVersion) ([]*trickest.Node, error) { 12 | if wfVersion == nil { 13 | return nil, fmt.Errorf("workflow version is nil") 14 | } 15 | 16 | var labeledNodes []*trickest.Node 17 | for _, node := range wfVersion.Data.Nodes { 18 | if !isDefaultLabel(node.Meta.Label, node.Name) { 19 | labeledNodes = append(labeledNodes, node) 20 | } 21 | } 22 | 23 | return labeledNodes, nil 24 | } 25 | 26 | func isDefaultLabel(label, name string) bool { 27 | parts := strings.Split(name, "-") 28 | if len(parts) < 2 { 29 | return false 30 | } 31 | 32 | _, err := strconv.Atoi(parts[len(parts)-1]) 33 | if err != nil { 34 | return false 35 | } 36 | 37 | defaultLabel := strings.Join(parts[:len(parts)-1], "-") 38 | return label == defaultLabel 39 | } 40 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/parameter.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/trickest/trickest-cli/pkg/trickest" 8 | ) 9 | 10 | // updatePrimitiveNodeReferences updates all references to the primitive node in connected nodes' inputs 11 | // 12 | // When primitive node is connected to a node, an input is created in the destination node with the primitive node's name and value 13 | // For example, if string-input-1 is connected to string-to-file-1's "string" input, string-to-file-1 would look like this: 14 | // 15 | // { 16 | // "name": "string-to-file-1", 17 | // "inputs": { 18 | // "string": { 19 | // "type": "STRING", 20 | // "description": "Write strings to a file", 21 | // "order": 0, 22 | // "multi": true, 23 | // "visible": true 24 | // }, 25 | // "string/string-input-1": { 26 | // "type": "STRING", 27 | // "description": "Write strings to a file", 28 | // "order": 0, 29 | // "multi": true, 30 | // "value": "example", 31 | // "visible": true 32 | // } 33 | // } 34 | // } 35 | func updatePrimitiveNodeReferences(wfVersion *trickest.WorkflowVersion, pNode *trickest.PrimitiveNode) error { 36 | if wfVersion == nil || pNode == nil { 37 | return fmt.Errorf("workflow version and primitive node cannot be nil") 38 | } 39 | 40 | // Find all connections where this primitive node is the source and update the destination node's input value 41 | for _, connection := range wfVersion.Data.Connections { 42 | sourceID := strings.TrimPrefix(connection.Source.ID, "output/") 43 | if !strings.HasPrefix(sourceID, pNode.Name) { 44 | continue 45 | } 46 | 47 | destTokens := strings.Split(strings.TrimPrefix(connection.Destination.ID, "input/"), "/") 48 | if len(destTokens) < 2 { 49 | return fmt.Errorf("connection destination is not formatted correctly: %s", connection.Destination.ID) 50 | } 51 | destNodeID := destTokens[0] 52 | destParam := destTokens[1] 53 | 54 | destNode, exists := wfVersion.Data.Nodes[destNodeID] 55 | if !exists { 56 | return fmt.Errorf("destination node %s does not exist", destNodeID) 57 | } 58 | 59 | if destNode.Inputs == nil { 60 | return fmt.Errorf("destination node %s inputs are nil", destNodeID) 61 | } 62 | 63 | if err := addNodeInputPrimitiveReference(destNode, pNode, destParam); err != nil { 64 | return err 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // addNodeInputPrimitiveReference adds a primitive node reference to a node's inputs list 72 | func addNodeInputPrimitiveReference(node *trickest.Node, pNode *trickest.PrimitiveNode, inputName string) error { 73 | inputKey := fmt.Sprintf("%s/%s", inputName, pNode.Name) 74 | 75 | // Copy the original input definition and create a new input entry with the primitive node name included in the key 76 | originalInput, exists := node.Inputs[inputName] 77 | if !exists { 78 | return fmt.Errorf("input %s not found in node %s", inputName, node.Name) 79 | } 80 | 81 | // The original input, along with the new input, must be visible for the workflow to render 82 | originalInput.Visible = &[]bool{true}[0] 83 | 84 | node.Inputs[inputKey] = &trickest.NodeInput{ 85 | Type: originalInput.Type, 86 | Order: originalInput.Order, 87 | Command: originalInput.Command, 88 | Description: originalInput.Description, 89 | Multi: originalInput.Multi, 90 | Visible: &[]bool{true}[0], 91 | } 92 | 93 | switch pNode.Type { 94 | case "FILE": 95 | urlTokens := strings.Split(pNode.Value.(string), "/") 96 | fileName := urlTokens[len(urlTokens)-1] 97 | value := fmt.Sprintf("in/%s/%s", pNode.Name, fileName) 98 | node.Inputs[inputKey].Value = value 99 | case "FOLDER": 100 | value := fmt.Sprintf("in/%s/", pNode.Name) 101 | node.Inputs[inputKey].Value = value 102 | default: 103 | node.Inputs[inputKey].Value = pNode.Value 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // removeNodeInputPrimitiveReference removes the primitive node reference from a node's inputs 110 | func removeNodeInputPrimitiveReference(wfVersion *trickest.WorkflowVersion, nodeID string, pNodeID string, inputName string) error { 111 | if wfVersion == nil { 112 | return fmt.Errorf("workflow version is nil") 113 | } 114 | 115 | node, exists := wfVersion.Data.Nodes[nodeID] 116 | if !exists { 117 | return fmt.Errorf("node %s not found", nodeID) 118 | } 119 | 120 | if node.Inputs == nil { 121 | return fmt.Errorf("node %s inputs are nil", nodeID) 122 | } 123 | 124 | inputKey := fmt.Sprintf("%s/%s", inputName, pNodeID) 125 | delete(node.Inputs, inputKey) 126 | 127 | return nil 128 | } 129 | 130 | // setupNodeParam sets up a primitive node, its connection, and its input reference 131 | func setupNodeParam(wfVersion *trickest.WorkflowVersion, nodeID string, paramName string, paramType string, value any) error { 132 | if wfVersion == nil { 133 | return fmt.Errorf("workflow version is nil") 134 | } 135 | 136 | node, exists := wfVersion.Data.Nodes[nodeID] 137 | if !exists { 138 | return fmt.Errorf("node %s not found", nodeID) 139 | } 140 | 141 | if _, exists := node.Inputs[paramName]; !exists { 142 | return fmt.Errorf("parameter %s not found for node %s", paramName, nodeID) 143 | } 144 | 145 | primitiveNode, err := addPrimitiveNode(wfVersion, paramType, value) 146 | if err != nil { 147 | return fmt.Errorf("failed to add primitive node: %w", err) 148 | } 149 | 150 | if err := addConnection(wfVersion, primitiveNode.Name, "output", nodeID, paramName); err != nil { 151 | // If connection fails, clean up the primitive node 152 | _ = removePrimitiveNode(wfVersion, primitiveNode.Name) 153 | return fmt.Errorf("failed to add connection: %w", err) 154 | } 155 | 156 | if err := addNodeInputPrimitiveReference(node, primitiveNode, paramName); err != nil { 157 | // If reference fails, clean up the connection and primitive node 158 | _ = removeConnection(wfVersion, primitiveNode.Name, "output", nodeID, paramName) 159 | _ = removePrimitiveNode(wfVersion, primitiveNode.Name) 160 | return fmt.Errorf("failed to add input reference: %w", err) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // cleanupNodeParam removes all primitive nodes, their connections, and their input references for a node parameter 167 | func cleanupNodeParam(wfVersion *trickest.WorkflowVersion, nodeID string, paramName string) error { 168 | if wfVersion == nil { 169 | return fmt.Errorf("workflow version is nil") 170 | } 171 | 172 | primitiveNodeIDs, err := findPrimitiveNodesConnectedToParam(wfVersion, nodeID, paramName) 173 | if err != nil { 174 | return fmt.Errorf("failed to find primitive nodes: %w", err) 175 | } 176 | 177 | for _, pNodeID := range primitiveNodeIDs { 178 | if err := removePrimitiveNode(wfVersion, pNodeID); err != nil { 179 | return fmt.Errorf("failed to remove primitive node %s: %w", pNodeID, err) 180 | } 181 | 182 | if err := removeConnection(wfVersion, pNodeID, "output", nodeID, paramName); err != nil { 183 | return fmt.Errorf("failed to remove connection for primitive node %s: %w", pNodeID, err) 184 | } 185 | 186 | if err := removeNodeInputPrimitiveReference(wfVersion, nodeID, pNodeID, paramName); err != nil { 187 | return fmt.Errorf("failed to remove input reference for primitive node %s: %w", pNodeID, err) 188 | } 189 | } 190 | 191 | return nil 192 | } 193 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/primitive.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/trickest/trickest-cli/pkg/trickest" 9 | ) 10 | 11 | func GetLabeledPrimitiveNodes(wfVersion *trickest.WorkflowVersion) ([]*trickest.PrimitiveNode, error) { 12 | if wfVersion == nil { 13 | return nil, fmt.Errorf("workflow version is nil") 14 | } 15 | 16 | var labeledNodes []*trickest.PrimitiveNode 17 | for _, node := range wfVersion.Data.PrimitiveNodes { 18 | if node.Label == "" { 19 | continue 20 | } 21 | if node.Type == "BOOLEAN" { 22 | if node.Label != strconv.FormatBool(node.Value.(bool)) { 23 | labeledNodes = append(labeledNodes, node) 24 | } 25 | } else { 26 | if node.Label != node.Value { 27 | labeledNodes = append(labeledNodes, node) 28 | } 29 | } 30 | } 31 | 32 | return labeledNodes, nil 33 | } 34 | 35 | // addPrimitiveNode adds a new primitive node or updates an existing one 36 | func addPrimitiveNode(wfVersion *trickest.WorkflowVersion, nodeType string, value any) (*trickest.PrimitiveNode, error) { 37 | if wfVersion == nil { 38 | return nil, fmt.Errorf("workflow version is nil") 39 | } 40 | 41 | node, err := createPrimitiveNode(wfVersion, nodeType, value) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to create primitive node: %w", err) 44 | } 45 | 46 | if wfVersion.Data.PrimitiveNodes == nil { 47 | wfVersion.Data.PrimitiveNodes = make(map[string]*trickest.PrimitiveNode) 48 | } 49 | 50 | wfVersion.Data.PrimitiveNodes[node.Name] = node 51 | return node, nil 52 | } 53 | 54 | // createPrimitiveNode creates a new primitive node based on its type and value 55 | func createPrimitiveNode(wfVersion *trickest.WorkflowVersion, nodeType string, value any) (*trickest.PrimitiveNode, error) { 56 | var name string 57 | var typeName string 58 | switch nodeType { 59 | case "STRING": 60 | name = fmt.Sprintf("string-input-%d", getAvailablePrimitiveNodeID(nodeType, wfVersion)) 61 | typeName = "STRING" 62 | case "BOOLEAN": 63 | name = fmt.Sprintf("boolean-input-%d", getAvailablePrimitiveNodeID(nodeType, wfVersion)) 64 | typeName = "BOOLEAN" 65 | case "FILE": 66 | name = fmt.Sprintf("http-input-%d", getAvailablePrimitiveNodeID(nodeType, wfVersion)) 67 | typeName = "URL" 68 | case "FOLDER": 69 | name = fmt.Sprintf("git-input-%d", getAvailablePrimitiveNodeID(nodeType, wfVersion)) 70 | typeName = "GIT" 71 | default: 72 | return nil, fmt.Errorf("unsupported node type: %s", nodeType) 73 | } 74 | 75 | normalizedValue, label, err := processPrimitiveNodeValue(nodeType, value) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | pNode := &trickest.PrimitiveNode{ 81 | Name: name, 82 | Type: nodeType, 83 | TypeName: typeName, 84 | Value: normalizedValue, 85 | Label: label, 86 | } 87 | 88 | return pNode, nil 89 | } 90 | 91 | // setPrimitiveNodeValue updates the value of a primitive node through the provided pointer 92 | func setPrimitiveNodeValue(pNode *trickest.PrimitiveNode, value any) error { 93 | if pNode == nil { 94 | return fmt.Errorf("primitive node cannot be nil") 95 | } 96 | 97 | normalizedValue, impliedLabel, err := processPrimitiveNodeValue(pNode.Type, value) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | // Only update the label if it matches the current value, indicating it's using the default behavior 103 | // This preserves custom labels that were manually set to user-friendly names 104 | if pNode.Label == pNode.Value { 105 | pNode.Label = impliedLabel 106 | } 107 | pNode.Value = normalizedValue 108 | 109 | return nil 110 | } 111 | 112 | // processPrimitiveNodeValue normalizes and validates primitive node values based on type and returns the normalized value, a display label, and any validation errors. 113 | func processPrimitiveNodeValue(nodeType string, value any) (any, string, error) { 114 | var normalizedValue any 115 | var label string 116 | 117 | switch val := value.(type) { 118 | case string: 119 | switch nodeType { 120 | case "STRING": 121 | normalizedValue = val 122 | case "FILE": 123 | if !strings.HasPrefix(val, "https://") && !strings.HasPrefix(val, "http://") && !strings.HasPrefix(val, "trickest://file/") && !strings.HasPrefix(val, "trickest://output/") { 124 | return nil, "", fmt.Errorf("file input must be a valid URL (http:// or https://) for a remote file, trickest://file/path for stored files, or trickest://output/id for workflow outputs") 125 | } 126 | normalizedValue = val 127 | case "FOLDER": 128 | if !strings.HasPrefix(val, "http://") && !strings.HasPrefix(val, "https://") { 129 | return nil, "", fmt.Errorf("folder input must be a valid git repository URL") 130 | } 131 | normalizedValue = val 132 | case "BOOLEAN": 133 | normalizedValue = val 134 | } 135 | case int: 136 | normalizedValue = strconv.Itoa(val) 137 | case bool: 138 | normalizedValue = val 139 | default: 140 | return nil, "", fmt.Errorf("unsupported value type: %T; only string, int, and bool are valid for primitive nodes", value) 141 | } 142 | 143 | if nodeType == "BOOLEAN" { 144 | label = strconv.FormatBool(normalizedValue.(bool)) 145 | } else { 146 | label = normalizedValue.(string) 147 | } 148 | 149 | return normalizedValue, label, nil 150 | } 151 | 152 | // getAvailablePrimitiveNodeID determines the next available ID for a primitive node type 153 | func getAvailablePrimitiveNodeID(nodeType string, wfVersion *trickest.WorkflowVersion) int { 154 | availableID := 1 155 | 156 | if wfVersion == nil || wfVersion.Data.PrimitiveNodes == nil { 157 | return availableID 158 | } 159 | 160 | var prefix string 161 | switch nodeType { 162 | case "STRING": 163 | prefix = "string-input-" 164 | case "BOOLEAN": 165 | prefix = "boolean-input-" 166 | case "FILE": 167 | prefix = "http-input-" 168 | case "FOLDER": 169 | prefix = "git-input-" 170 | } 171 | for nodeName := range wfVersion.Data.PrimitiveNodes { 172 | if strings.HasPrefix(nodeName, prefix) { 173 | currentID, _ := strconv.Atoi(strings.TrimPrefix(nodeName, prefix)) 174 | if currentID >= availableID { 175 | availableID = currentID + 1 176 | } 177 | } 178 | } 179 | return availableID 180 | } 181 | 182 | // removePrimitiveNode removes a primitive node from the workflow version 183 | func removePrimitiveNode(wfVersion *trickest.WorkflowVersion, pNodeID string) error { 184 | if wfVersion == nil { 185 | return fmt.Errorf("workflow version is nil") 186 | } 187 | 188 | delete(wfVersion.Data.PrimitiveNodes, pNodeID) 189 | return nil 190 | } 191 | -------------------------------------------------------------------------------- /pkg/workflowbuilder/workflowinput.go: -------------------------------------------------------------------------------- 1 | package workflowbuilder 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/trickest/trickest-cli/pkg/trickest" 7 | ) 8 | 9 | type WorkflowInput interface { 10 | ApplyToWorkflowVersion(wfVersion *trickest.WorkflowVersion) error 11 | } 12 | 13 | // Inputs represents a collection of primitive node and node inputs 14 | type Inputs struct { 15 | PrimitiveNodeInputs []PrimitiveNodeInput 16 | NodeInputs []NodeInput 17 | } 18 | 19 | // PrimitiveNodeInput represents a primitive node (string, boolean, file, or folder) input 20 | type PrimitiveNodeInput struct { 21 | PrimitiveNodeID string 22 | Value any 23 | } 24 | 25 | // NodeInput represents a node (tool, script, module, or splitter) input 26 | type NodeInput struct { 27 | NodeID string 28 | ParamValues map[string][]any 29 | } 30 | 31 | // ApplyToWorkflowVersion applies the primitive node input to the workflow version 32 | func (input PrimitiveNodeInput) ApplyToWorkflowVersion(wfVersion *trickest.WorkflowVersion) error { 33 | if wfVersion == nil { 34 | return fmt.Errorf("workflow version is nil") 35 | } 36 | 37 | primitiveNode, exists := wfVersion.Data.PrimitiveNodes[input.PrimitiveNodeID] 38 | if !exists { 39 | return fmt.Errorf("primitive node %s not found", input.PrimitiveNodeID) 40 | } 41 | 42 | if err := setPrimitiveNodeValue(primitiveNode, input.Value); err != nil { 43 | return fmt.Errorf("failed to set primitive node value: %w", err) 44 | } 45 | 46 | if err := updatePrimitiveNodeReferences(wfVersion, primitiveNode); err != nil { 47 | return fmt.Errorf("failed to update primitive node references: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // ApplyToWorkflowVersion applies the node input to the workflow version 54 | func (input NodeInput) ApplyToWorkflowVersion(wfVersion *trickest.WorkflowVersion) error { 55 | if wfVersion == nil { 56 | return fmt.Errorf("workflow version is nil") 57 | } 58 | 59 | node, exists := wfVersion.Data.Nodes[input.NodeID] 60 | if !exists { 61 | return fmt.Errorf("node %s not found", input.NodeID) 62 | } 63 | 64 | for paramName, paramValues := range input.ParamValues { 65 | param, exists := node.Inputs[paramName] 66 | if !exists { 67 | return fmt.Errorf("parameter %s not found for node %s", paramName, input.NodeID) 68 | } 69 | 70 | // First clean up any existing primitive nodes for this parameter to make sure nodes don't keep old values 71 | // Any existing primitive nodes will be removed and replaced with new ones 72 | // This makes the process idempotent and clean as opposed to trying to update existing primitive nodes 73 | if err := cleanupNodeParam(wfVersion, input.NodeID, paramName); err != nil { 74 | return fmt.Errorf("failed to clean up existing primitive nodes: %w", err) 75 | } 76 | 77 | for _, paramValue := range paramValues { 78 | if err := setupNodeParam(wfVersion, input.NodeID, paramName, param.Type, paramValue); err != nil { 79 | return fmt.Errorf("failed to set up new primitive node: %w", err) 80 | } 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /trickest-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trickest/trickest-cli/b64b6ab0c99365e6753a1c3df0c7c632ef39e8eb/trickest-cli.png -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | type Config struct { 9 | User struct { 10 | Token string 11 | TokenFilePath string 12 | } 13 | BaseUrl string 14 | Dependency string 15 | } 16 | 17 | var ( 18 | Cfg Config 19 | SpaceName string 20 | ProjectName string 21 | WorkflowName string 22 | URL string 23 | ) 24 | 25 | func GetToken() string { 26 | if Cfg.User.Token != "" { 27 | return Cfg.User.Token 28 | } 29 | 30 | if Cfg.User.TokenFilePath != "" { 31 | token, err := os.ReadFile(Cfg.User.TokenFilePath) 32 | if err != nil { 33 | log.Fatal("Couldn't read the token file: ", err) 34 | } 35 | Cfg.User.Token = string(token) 36 | return Cfg.User.Token 37 | } 38 | 39 | if tokenEnv, tokenSet := os.LookupEnv("TRICKEST_TOKEN"); tokenSet { 40 | Cfg.User.Token = tokenEnv 41 | return tokenEnv 42 | } 43 | 44 | log.Fatal("Trickest authentication token not set! Use --token, --token-file, or TRICKEST_TOKEN environment variable.") 45 | return "" 46 | } 47 | --------------------------------------------------------------------------------