diff --git a/.github/workflows/cli-e2e-tests.yml b/.github/workflows/cli-e2e-tests.yml new file mode 100644 index 0000000000..2cd27f39e9 --- /dev/null +++ b/.github/workflows/cli-e2e-tests.yml @@ -0,0 +1,105 @@ +name: cli-e2e-tests + +on: + pull_request: + paths: + - "cmd/hatchet-cli/cli/templates/**" + - "cmd/hatchet-cli/cli/quickstart.go" + - "cmd/hatchet-cli/cli/internal/templater/**" + - "cmd/hatchet-cli/cli/quickstart_e2e_test.go" + - ".github/workflows/test-templates.yml" + push: + branches: + - main + paths: + - "cmd/hatchet-cli/cli/templates/**" + - "cmd/hatchet-cli/cli/quickstart.go" + - "cmd/hatchet-cli/cli/internal/templater/**" + - "cmd/hatchet-cli/cli/quickstart_e2e_test.go" + +jobs: + test-templates: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Poetry + run: pipx install poetry + + - name: Install uv + run: pip install uv + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install Yarn + run: npm install -g yarn + + - name: Install Bun + uses: oven-sh/setup-bun@v1 + + - name: Build CLI + run: | + cd cmd/hatchet-cli + go build -o hatchet-cli + sudo mv hatchet-cli /usr/local/bin/hatchet + hatchet --version + + - name: Start Hatchet server + run: | + echo "Starting Hatchet server (this will wait until healthy)..." + hatchet server start --profile local --dashboard-port 8080 + + echo "Hatchet server is ready!" + + - name: Verify local profile was created + run: | + hatchet profile list + + - name: Run template E2E tests + run: | + cd cmd/hatchet-cli/cli + go test -tags e2e_cli -run TestQuickstartTemplates -v -timeout 15m + env: + # Ensure tests don't try to interact with stdin + CI: true + + - name: Show server logs on failure + if: failure() + run: | + echo "=== Hatchet Container Logs ===" + docker logs hatchet-cli-hatchet-1 2>&1 || echo "Could not get hatchet container logs" + echo "" + echo "=== Postgres Container Logs ===" + docker logs hatchet-cli-postgres-1 2>&1 || echo "Could not get postgres container logs" + echo "" + echo "=== All Containers ===" + docker ps -a + + - name: Cleanup + if: always() + run: | + # Stop Hatchet server (this will stop the containers) + hatchet server stop --profile local || true + + # Cleanup any remaining docker containers + docker ps -q --filter "label=com.docker.compose.project=hatchet-cli" | xargs -r docker stop || true + docker ps -aq --filter "label=com.docker.compose.project=hatchet-cli" | xargs -r docker rm || true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d49b4b0f1..02ddca98ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: - id: trailing-whitespace exclude: ^examples/ - id: check-yaml - exclude: ^examples/ + exclude: (^examples/|^cmd/hatchet-cli/cli/templates/) - repo: https://github.com/golangci/golangci-lint rev: v2.5.0 hooks: diff --git a/README.md b/README.md index 47bc6b99d7..41fc43e5d1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,21 @@ ### What is Hatchet? -Hatchet is a platform for running background tasks, built on top of Postgres. Instead of managing your own task queue or pub/sub system, you can use Hatchet to distribute your functions between a set of workers with minimal configuration or infrastructure. +Hatchet is a platform for running background tasks and durable workflows, built on top of Postgres. It bundles a durable task queue, observability, alerting, a dashboard, and a CLI into a single platform. + +### Get started quickly + +The fastest way to get started with a running Hatchet instance is to install the Hatchet CLI (on MacOS, Linux or WSL) - note that this requires [Docker](https://www.docker.com/get-started) installed locally to work: + +```sh +curl -fsSL https://install.hatchet.run/install.sh | bash +hatchet --version +hatchet server start +``` + +You can also sign up on [Hatchet Cloud](https://cloud.onhatchet.run) to try it out! We recommend this even if you plan on self-hosting, so you can have a look at what a fully-deployed Hatchet platform looks like. + +To view full documentation for self-hosting and using cloud, have a look at the [docs](https://docs.hatchet.run). ### When should I use Hatchet? @@ -650,13 +664,6 @@ Hatchet supports Slack and email-based alerting for when your tasks fail. Alerts -### Quick Start - -Hatchet is available as a cloud version or self-hosted. See the following docs to get up and running quickly: - -- [Hatchet Cloud Quickstart](https://docs.hatchet.run/home/hatchet-cloud-quickstart) -- [Hatchet Self-Hosted](https://docs.hatchet.run/self-hosting) - ### Documentation The most up-to-date documentation can be found at https://docs.hatchet.run. diff --git a/cmd/hatchet-cli/.goreleaser.yml b/cmd/hatchet-cli/.goreleaser.yml index ecfe85fc4d..f234ec6004 100644 --- a/cmd/hatchet-cli/.goreleaser.yml +++ b/cmd/hatchet-cli/.goreleaser.yml @@ -54,22 +54,7 @@ snapshot: version_template: "{{ incpatch .Version }}-next" changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - - "^chore:" - - "^ci:" - groups: - - title: Features - regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' - order: 0 - - title: "Bug fixes" - regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' - order: 1 - - title: Others - order: 999 + disable: true # Cross-platform signing and notarization with quill (works on any OS, including Linux) # This will sign and notarize all macOS binaries @@ -99,15 +84,22 @@ release: prerelease: auto mode: append header: | - ## Hatchet CLI {{ .Tag }} ({{ .Date }}) + ## Hatchet CLI {{ .Tag }} Welcome to this new release of the Hatchet CLI! + + For full documentation, visit [https://docs.hatchet.run/cli](https://docs.hatchet.run/cli) footer: | ## Installation - ### Homebrew (macOS/Linux) + ### MacOS, Linux, WSL + ```bash + curl -fsSL https://install.hatchet.run/install.sh | bash + ``` + + ### MacOS (Homebrew) ```bash - brew install hatchet-dev/hatchet/hatchet + brew install hatchet-dev/hatchet/hatchet --cask ``` ### Manual Installation @@ -115,7 +107,7 @@ release: ### Verify Installation ```bash - hatchet version + hatchet --version ``` name_template: "{{.ProjectName}}-cli-{{.Version}}" disable: false diff --git a/cmd/hatchet-cli/cli/client.go b/cmd/hatchet-cli/cli/client.go new file mode 100644 index 0000000000..84377f8b75 --- /dev/null +++ b/cmd/hatchet-cli/cli/client.go @@ -0,0 +1,30 @@ +package cli + +import ( + "github.com/rs/zerolog" + + "github.com/hatchet-dev/hatchet/pkg/client" + profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" + clientconfig "github.com/hatchet-dev/hatchet/pkg/config/client" + "github.com/hatchet-dev/hatchet/pkg/config/shared" +) + +// NewClientFromProfile creates a new Hatchet client from a profile configuration. +// It properly handles TLS settings, host/port, and authentication based on the profile. +func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger) (client.Client, error) { + // Construct a ClientConfigFile from the profile + configFile := &clientconfig.ClientConfigFile{ + TenantId: profile.TenantId, + Token: profile.Token, + HostPort: profile.GrpcHostPort, + ServerURL: profile.ApiServerURL, + TLS: clientconfig.ClientTLSConfigFile{ + Base: shared.TLSConfigFile{ + TLSStrategy: profile.TLSStrategy, + }, + }, + } + + // Create client with the config file and logger + return client.NewFromConfigFile(configFile, client.WithLogger(logger)) +} diff --git a/cmd/hatchet-cli/cli/internal/config/worker/config.go b/cmd/hatchet-cli/cli/internal/config/worker/config.go index c8aa4ac811..612ea197c7 100644 --- a/cmd/hatchet-cli/cli/internal/config/worker/config.go +++ b/cmd/hatchet-cli/cli/internal/config/worker/config.go @@ -9,11 +9,11 @@ import ( ) type WorkerConfig struct { - Dev WorkerDevConfig `mapstructure:"dev" json:"dev,omitempty"` - Scripts []Script `mapstructure:"scripts" json:"scripts,omitempty"` + Dev WorkerDevConfig `mapstructure:"dev" json:"dev,omitempty"` + Triggers []Trigger `mapstructure:"triggers" json:"triggers,omitempty"` } -type Script struct { +type Trigger struct { // command to execute Command string `mapstructure:"command" json:"command"` diff --git a/cmd/hatchet-cli/cli/internal/drivers/docker/image_pull_progress.go b/cmd/hatchet-cli/cli/internal/drivers/docker/image_pull_progress.go index 7ca8b0b4d1..a17ff23228 100644 --- a/cmd/hatchet-cli/cli/internal/drivers/docker/image_pull_progress.go +++ b/cmd/hatchet-cli/cli/internal/drivers/docker/image_pull_progress.go @@ -118,6 +118,9 @@ func displayImagePullProgress(reader io.Reader, imageName string) { p := tea.NewProgram(m) + // Channel to signal when the goroutine has finished consuming the reader + done := make(chan struct{}) + // Start parsing in background go func() { defer func() { @@ -126,6 +129,7 @@ func displayImagePullProgress(reader io.Reader, imageName string) { _ = r } p.Send(doneMsg{}) + close(done) // Signal that we're done consuming the reader }() scanner := bufio.NewScanner(reader) @@ -175,8 +179,13 @@ func displayImagePullProgress(reader io.Reader, imageName string) { // Run the program (this blocks until done) if _, err := p.Run(); err != nil { - // Fallback to simple message if TUI fails + // Fallback for non-TTY environments: wait for the goroutine to finish + // consuming the reader before returning to ensure the image pull completes + <-done fmt.Fprintf(os.Stderr, "Pulled image %s\n", imageName) + } else { + // Even on success, wait for the goroutine to clean up + <-done } } diff --git a/cmd/hatchet-cli/cli/internal/templater/templater.go b/cmd/hatchet-cli/cli/internal/templater/templater.go index af0bd756c2..1d5a93c48d 100644 --- a/cmd/hatchet-cli/cli/internal/templater/templater.go +++ b/cmd/hatchet-cli/cli/internal/templater/templater.go @@ -12,7 +12,8 @@ import ( // Data holds the template data passed to each file. type Data struct { - Name string + Name string + PackageManager string } // Process reads all files from the specified directory within the embedded filesystem, @@ -69,6 +70,36 @@ func Process(fsys embed.FS, srcDir, dstDir string, data Data) error { }) } +// ProcessMultiSource processes templates from multiple source directories (shared + package-manager-specific). +// It first processes files from the shared directory, then overlays package-manager-specific files. +// For languages that support multiple package managers (python, typescript), this function expects: +// - shared directory: templates/LANG/shared/ +// - package manager directory: templates/LANG/PACKAGE_MANAGER/ +// +// For languages with a single package manager (go), it falls back to the language root directory. +func ProcessMultiSource(fsys embed.FS, language, packageManager, dstDir string, data Data) error { + // For Go, use the old behavior (no shared directory) + if language == "go" { + return Process(fsys, "templates/go", dstDir, data) + } + + // For Python and TypeScript, process shared + package-manager-specific + sharedDir := filepath.Join("templates", language, "shared") + pkgMgrDir := filepath.Join("templates", language, packageManager) + + // Process shared files first + if err := Process(fsys, sharedDir, dstDir, data); err != nil { + return err + } + + // Process package-manager-specific files (may overwrite shared files) + if err := Process(fsys, pkgMgrDir, dstDir, data); err != nil { + return err + } + + return nil +} + // ProcessPostQuickstart reads and processes the POST_QUICKSTART.md file from the template directory. // Returns the processed content as a string, or empty string if the file doesn't exist. func ProcessPostQuickstart(fsys embed.FS, srcDir string, data Data) (string, error) { @@ -101,3 +132,18 @@ func ProcessPostQuickstart(fsys embed.FS, srcDir string, data Data) (string, err return buf.String(), nil } + +// ProcessPostQuickstartMultiSource reads and processes the POST_QUICKSTART.md file from the package-manager-specific directory. +// For languages with multiple package managers, it looks in templates/LANG/PACKAGE_MANAGER/. +// For Go, it looks in the templates/go/ directory. +// Returns the processed content as a string, or empty string if the file doesn't exist. +func ProcessPostQuickstartMultiSource(fsys embed.FS, language, packageManager string, data Data) (string, error) { + var srcDir string + if language == "go" { + srcDir = "templates/go" + } else { + srcDir = filepath.Join("templates", language, packageManager) + } + + return ProcessPostQuickstart(fsys, srcDir, data) +} diff --git a/cmd/hatchet-cli/cli/profile.go b/cmd/hatchet-cli/cli/profile.go index 4aa507beae..66f102513a 100644 --- a/cmd/hatchet-cli/cli/profile.go +++ b/cmd/hatchet-cli/cli/profile.go @@ -1,10 +1,16 @@ package cli import ( + "context" + "crypto/tls" + "errors" "fmt" + "io" + "net" "slices" "sort" "strings" + "time" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -482,40 +488,90 @@ func getProfileFromToken(cmd *cobra.Command, token, nameOverride, tlsOverride st } func determineTLSStrategy(grpcHostPort string) string { - // Extract port from host:port - parts := strings.Split(grpcHostPort, ":") - if len(parts) != 2 { - // If we can't parse the port, default to TLS - return "tls" + // Try to auto-detect TLS by probing the endpoint + strategy, err := probeTLSEndpoint(grpcHostPort) + if err != nil { + cli.Logger.Warnf("could not auto-detect TLS for %s: %v", grpcHostPort, err) + cli.Logger.Info("falling back to user prompt") + + // Fall back to asking the user + var useTLS bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Does the gRPC endpoint %s use TLS?", grpcHostPort)). + Description("Auto-detection failed."). + Value(&useTLS), + ), + ).WithTheme(styles.HatchetTheme()) + + err := form.Run() + if err != nil { + cli.Logger.Fatalf("could not run TLS confirmation form: %v", err) + } + + if useTLS { + return "tls" + } + return "none" } - port := parts[1] + return strategy +} - // If port is 443, default to TLS - if port == "443" { - return "tls" +// probeTLSEndpoint attempts to detect if an endpoint uses TLS by probing it +func probeTLSEndpoint(hostPort string) (string, error) { + // Parse the host:port to ensure it's valid + host, _, err := net.SplitHostPort(hostPort) + if err != nil { + return "", fmt.Errorf("invalid host:port format: %w", err) } - // Otherwise, ask the user - var useTLS bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Does the gRPC endpoint %s use TLS?", grpcHostPort)). - Description("Port 443 typically uses TLS. Other ports may vary."). - Value(&useTLS), - ), - ).WithTheme(styles.HatchetTheme()) + // Create a context with timeout for the probe + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - err := form.Run() - if err != nil { - cli.Logger.Fatalf("could not run TLS confirmation form: %v", err) + // Try TLS connection first (most common case) + tlsConfig := &tls.Config{ + // we just want to see if TLS is spoken, not validate the cert + InsecureSkipVerify: true, // nolint: gosec + ServerName: host, + } + + // Attempt TLS dial + dialer := &net.Dialer{ + Timeout: 5 * time.Second, } - if useTLS { - return "tls" + conn, err := tls.DialWithDialer(dialer, "tcp", hostPort, tlsConfig) + if err == nil { + // TLS connection succeeded + conn.Close() + return "tls", nil + } + + dialNoTLS := func() (string, error) { + plainConn, plainErr := dialer.DialContext(ctx, "tcp", hostPort) + if plainErr == nil { + plainConn.Close() + return "none", nil + } + return "", fmt.Errorf("endpoint not reachable: %w", plainErr) } - return "none" + + // Check if the error is a RecordHeaderError - this means the server sent non-TLS data + var recordHeaderErr tls.RecordHeaderError + if errors.As(err, &recordHeaderErr) { + return dialNoTLS() + } + + // Check for EOF, which commonly occurs when connecting to a non-TLS server with TLS + if errors.Is(err, io.EOF) { + return dialNoTLS() + } + + // If it's neither a RecordHeaderError nor EOF, it's likely a connection error + return "", fmt.Errorf("could not connect to endpoint: %w", err) } func selectProfileForm(useDefault bool) string { diff --git a/cmd/hatchet-cli/cli/quickstart.go b/cmd/hatchet-cli/cli/quickstart.go index 321882477d..9db25bef1e 100644 --- a/cmd/hatchet-cli/cli/quickstart.go +++ b/cmd/hatchet-cli/cli/quickstart.go @@ -21,18 +21,23 @@ var content embed.FS var quickstartCmd = &cobra.Command{ Use: "quickstart", Short: "Generate a quickstart Hatchet worker project", - Long: `Generate a quickstart Hatchet worker project with boilerplate code in your language of choice.`, - Example: ` # Generate a project interactively (prompts for language, name, and directory) + Long: `Generate a quickstart Hatchet worker project with boilerplate code in your language of choice. + +Supports multiple package managers: + Python: poetry, uv, pip + TypeScript: npm, pnpm, yarn, bun + Go: go modules`, + Example: ` # Generate a project interactively (prompts for language, package manager, name, and directory) hatchet quickstart - # Generate a Python project with default settings - hatchet quickstart --language python + # Generate a Python project with Poetry + hatchet quickstart --language python --package-manager poetry - # Generate a TypeScript project with custom name and directory - hatchet quickstart --language typescript --project-name my-worker --directory ./workers/my-worker + # Generate a TypeScript project with pnpm + hatchet quickstart --language typescript --package-manager pnpm --project-name my-worker - # Generate a Go project with short flags - hatchet quickstart -l go -p my-worker -d ./my-worker`, + # Generate a Python project with uv using short flags + hatchet quickstart -l python -m uv -p my-worker -d ./my-worker`, Run: func(cmd *cobra.Command, args []string) { // Check if at least one profile exists profileNames := cli.ListProfiles() @@ -43,6 +48,7 @@ var quickstartCmd = &cobra.Command{ // Get flag values language, _ := cmd.Flags().GetString("language") + packageManager, _ := cmd.Flags().GetString("package-manager") projectName, _ := cmd.Flags().GetString("project-name") dir, _ := cmd.Flags().GetString("directory") @@ -58,6 +64,26 @@ var quickstartCmd = &cobra.Command{ cli.Logger.Fatalf("invalid language: %s (must be one of: python, typescript, go)", language) } + // Get package manager + if packageManager == "" { + packageManager = selectPackageManagerForm(language) + } + + // Validate package manager for the selected language + validPackageManagers := map[string]map[string]bool{ + "python": {"poetry": true, "uv": true, "pip": true}, + "typescript": {"npm": true, "pnpm": true, "yarn": true, "bun": true}, + "go": {"go": true}, + } + + if !validPackageManagers[language][packageManager] { + var validOptions []string + for pm := range validPackageManagers[language] { + validOptions = append(validOptions, pm) + } + cli.Logger.Fatalf("invalid package manager '%s' for language '%s' (must be one of: %s)", packageManager, language, strings.Join(validOptions, ", ")) + } + if projectName == "" { projectName = selectNameForm() } @@ -71,31 +97,43 @@ var quickstartCmd = &cobra.Command{ } } - templateData := templater.Data{ - Name: projectName, - } - - err := templater.Process(content, fmt.Sprintf("templates/%s", language), dir, templateData) - - if err != nil { - cli.Logger.Fatalf("could not process templates: %v", err) - } - - // Process POST_QUICKSTART.md if it exists - postQuickstart, err := templater.ProcessPostQuickstart(content, fmt.Sprintf("templates/%s", language), templateData) + postQuickstart, err := GenerateQuickstart(language, packageManager, projectName, dir) if err != nil { - cli.Logger.Fatalf("could not process post-quickstart content: %v", err) + cli.Logger.Fatalf("could not generate quickstart: %v", err) } fmt.Println(quickstartSuccessView(language, projectName, dir, postQuickstart)) }, } +// GenerateQuickstart generates a quickstart project without interactive forms. +// Returns the post-quickstart content that should be displayed to the user. +func GenerateQuickstart(language, packageManager, projectName, dir string) (string, error) { + templateData := templater.Data{ + Name: projectName, + PackageManager: packageManager, + } + + err := templater.ProcessMultiSource(content, language, packageManager, dir, templateData) + if err != nil { + return "", fmt.Errorf("could not process templates: %w", err) + } + + // Process POST_QUICKSTART.md if it exists + postQuickstart, err := templater.ProcessPostQuickstartMultiSource(content, language, packageManager, templateData) + if err != nil { + return "", fmt.Errorf("could not process post-quickstart content: %w", err) + } + + return postQuickstart, nil +} + func init() { rootCmd.AddCommand(quickstartCmd) // Add flags for quickstart command quickstartCmd.Flags().StringP("language", "l", "", "Programming language (python, typescript, go)") + quickstartCmd.Flags().StringP("package-manager", "m", "", "Package manager (poetry, uv, pip for Python; npm, pnpm, yarn, bun for TypeScript)") quickstartCmd.Flags().StringP("project-name", "p", "", "Name of the project (default: hatchet-worker)") quickstartCmd.Flags().StringP("directory", "d", "", "Directory to create the project in (default: ./{project-name})") } @@ -125,6 +163,50 @@ func selectLanguageForm() string { return language } +func selectPackageManagerForm(language string) string { + packageManager := "" + + var options []huh.Option[string] + + switch language { + case "python": + options = []huh.Option[string]{ + huh.NewOption("Poetry (recommended)", "poetry"), + huh.NewOption("uv", "uv"), + huh.NewOption("pip", "pip"), + } + case "typescript": + options = []huh.Option[string]{ + huh.NewOption("npm", "npm"), + huh.NewOption("pnpm", "pnpm"), + huh.NewOption("Yarn", "yarn"), + huh.NewOption("Bun", "bun"), + } + case "go": + // Go only has one package manager, return default + return "go" + default: + cli.Logger.Fatalf("unsupported language: %s", language) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Choose your package manager"). + Options(options...). + Value(&packageManager), + ), + ).WithTheme(styles.HatchetTheme()) + + err := form.Run() + + if err != nil { + cli.Logger.Fatalf("could not run package manager form: %v", err) + } + + return packageManager +} + func selectNameForm() string { name := "" diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go new file mode 100644 index 0000000000..76fe3820c6 --- /dev/null +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -0,0 +1,326 @@ +//go:build e2e_cli + +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + cliconfig "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/worker" + profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" +) + +// Test matrix of all language and package manager combinations +var templateTests = []struct { + language string + packageManager string +}{ + // Python + {"python", "poetry"}, + {"python", "uv"}, + {"python", "pip"}, + + // TypeScript + {"typescript", "npm"}, + {"typescript", "pnpm"}, + {"typescript", "yarn"}, + {"typescript", "bun"}, + + // Go + {"go", "go"}, +} + +func TestQuickstartTemplates(t *testing.T) { + for _, tt := range templateTests { + t.Run(fmt.Sprintf("%s_%s", tt.language, tt.packageManager), func(t *testing.T) { + testTemplate(t, tt.language, tt.packageManager) + }) + } +} + +func testTemplate(t *testing.T, language, packageManager string) { + // 1. Create temp directory + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "test-project") + projectName := "test-project" + + t.Logf("Testing %s with %s in %s", language, packageManager, projectDir) + + // 2. Generate quickstart project using the CLI implementation + _, err := GenerateQuickstart(language, packageManager, projectName, projectDir) + if err != nil { + t.Fatalf("quickstart generation failed: %v", err) + } + + t.Logf("Project generated successfully") + + // 3. Verify project structure + if err := verifyProjectStructure(t, projectDir, language, packageManager); err != nil { + t.Fatalf("Project structure verification failed: %v", err) + } + + // 4. Change to project directory and load worker config + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer os.Chdir(originalDir) + + if err := os.Chdir(projectDir); err != nil { + t.Fatalf("Failed to change to project directory: %v", err) + } + + workerConfig, err := worker.LoadWorkerConfig() + if err != nil { + t.Fatalf("Failed to load worker config: %v", err) + } + + if workerConfig == nil { + t.Fatal("Worker config is nil") + } + + // 5. Get the local profile (created by hatchet server start) + profile, err := cliconfig.GetProfile("local") + if err != nil { + t.Fatalf("Failed to get local profile: %v", err) + } + + // 6. Start worker in dev mode using the CLI implementation and ensure it runs for 15 seconds without error + t.Log("Starting worker dev mode...") + if err := testWorkerDev(t, workerConfig, profile); err != nil { + t.Fatalf("Worker dev test failed: %v", err) + } + + // 7. Verify Dockerfile builds + t.Log("Verifying Dockerfile builds...") + if err := testDockerfileBuild(t, projectDir, language, packageManager); err != nil { + t.Fatalf("Dockerfile build test failed: %v", err) + } + + t.Logf("Successfully tested %s with %s", language, packageManager) +} + +func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *profileconfig.Profile) error { + // Create a context with timeout (safety net) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Channel to signal when pre-commands complete + preCmdsComplete := make(chan struct{}, 1) + errChan := make(chan error, 1) + + // Start the worker process using the CLI implementation in a goroutine + go func() { + t.Log("Starting worker process using RunWorkerDev...") + // Call the actual CLI implementation + // Note: devConfig.Reload is set to false to avoid file watching in tests + testDevConfig := workerConfig.Dev + testDevConfig.Reload = false + + if err := RunWorkerDev(ctx, profile, &testDevConfig, preCmdsComplete); err != nil { + errChan <- fmt.Errorf("worker process failed: %w", err) + return + } + errChan <- nil + }() + + // Wait for pre-commands to complete (dependency installation) + select { + case <-preCmdsComplete: + t.Log("Pre-commands completed, worker starting...") + case err := <-errChan: + if err != nil { + return err + } + return fmt.Errorf("worker exited before pre-commands completed") + case <-time.After(4 * time.Minute): + return fmt.Errorf("timeout waiting for pre-commands to complete") + } + + // Wait 5 seconds for the worker to fully start, then trigger the workflow + time.Sleep(5 * time.Second) + + // Trigger the "simple" workflow if it exists in the config + if len(workerConfig.Triggers) > 0 { + var simpleTrigger *worker.Trigger + for i := range workerConfig.Triggers { + if workerConfig.Triggers[i].Name == "simple" { + simpleTrigger = &workerConfig.Triggers[i] + break + } + } + + if simpleTrigger != nil { + t.Logf("Triggering workflow using command: %s", simpleTrigger.Command) + triggerCtx, triggerCancel := context.WithTimeout(ctx, 30*time.Second) + if err := executeTriggerCommand(triggerCtx, simpleTrigger.Command, profile); err != nil { + t.Logf("Warning: failed to trigger workflow: %v", err) + } else { + t.Log("Successfully triggered workflow") + } + triggerCancel() + } + } + + // Wait another 10 seconds for the workflow to process + time.Sleep(10 * time.Second) + cancel() + + // Check if any error occurred + select { + case err := <-errChan: + if err != nil { + return err + } + case <-time.After(2 * time.Second): + // Worker process should have stopped by now + t.Log("Worker process cleanup timeout - continuing anyway") + } + + t.Log("Worker ran successfully and workflow was triggered") + return nil +} + +func verifyProjectStructure(t *testing.T, projectDir, language, packageManager string) error { + t.Logf("Verifying project structure for %s/%s", language, packageManager) + + // Common files that should exist + commonFiles := []string{ + "README.md", + "hatchet.yaml", + "Dockerfile", + } + + for _, file := range commonFiles { + path := filepath.Join(projectDir, file) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected file %s does not exist", file) + } + } + + // Language-specific files + switch language { + case "python": + pythonFiles := []string{ + "src/hatchet_client.py", + "src/run.py", + "src/worker.py", + "src/workflows/first_workflow.py", + } + for _, file := range pythonFiles { + path := filepath.Join(projectDir, file) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected Python file %s does not exist", file) + } + } + + // Check for package manager specific files + switch packageManager { + case "poetry": + if _, err := os.Stat(filepath.Join(projectDir, "pyproject.toml")); os.IsNotExist(err) { + return fmt.Errorf("expected pyproject.toml for poetry") + } + case "uv": + if _, err := os.Stat(filepath.Join(projectDir, "pyproject.toml")); os.IsNotExist(err) { + return fmt.Errorf("expected pyproject.toml for uv") + } + case "pip": + if _, err := os.Stat(filepath.Join(projectDir, "requirements.txt")); os.IsNotExist(err) { + return fmt.Errorf("expected requirements.txt for pip") + } + } + + case "typescript": + tsFiles := []string{ + "src/hatchet-client.ts", + "src/run.ts", + "src/worker.ts", + "src/workflows/first-workflow.ts", + "tsconfig.json", + "package.json", + } + for _, file := range tsFiles { + path := filepath.Join(projectDir, file) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected TypeScript file %s does not exist", file) + } + } + + case "go": + goFiles := []string{ + "cmd/worker/main.go", + "cmd/run/main.go", + "client/client.go", + "workflows/first_workflow.go", + "go.mod", + } + for _, file := range goFiles { + path := filepath.Join(projectDir, file) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected Go file %s does not exist", file) + } + } + } + + return nil +} + +func testDockerfileBuild(t *testing.T, projectDir, language, packageManager string) error { + // Verify Dockerfile exists + dockerfilePath := filepath.Join(projectDir, "Dockerfile") + if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { + return fmt.Errorf("Dockerfile does not exist at %s", dockerfilePath) + } + + t.Logf("Building Dockerfile for %s/%s...", language, packageManager) + + // Build the Docker image + // Use a unique tag for each test to avoid conflicts + imageName := fmt.Sprintf("hatchet-test-%s-%s:latest", language, packageManager) + + cmd := exec.Command("docker", "build", "-t", imageName, ".") + cmd.Dir = projectDir + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker build failed: %v\nOutput: %s", err, output) + } + + t.Logf("Successfully built Docker image: %s", imageName) + + // Clean up the image after test + cleanupCmd := exec.Command("docker", "rmi", imageName) + if err := cleanupCmd.Run(); err != nil { + t.Logf("Warning: failed to clean up Docker image %s: %v", imageName, err) + } + + return nil +} + +func executeTriggerCommand(ctx context.Context, command string, profile *profileconfig.Profile) error { + // Use sh -c to execute the command with proper environment + cmd := exec.CommandContext(ctx, "sh", "-c", command) + + // Set environment variables from profile + env := os.Environ() + env = append(env, fmt.Sprintf("HATCHET_CLIENT_TOKEN=%s", profile.Token)) + env = append(env, fmt.Sprintf("HATCHET_CLIENT_TLS_STRATEGY=%s", profile.TLSStrategy)) + if profile.GrpcHostPort != "" { + env = append(env, fmt.Sprintf("HATCHET_CLIENT_HOST_PORT=%s", profile.GrpcHostPort)) + } + cmd.Env = env + + // Capture output for debugging + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("command failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} diff --git a/cmd/hatchet-cli/cli/templates/go/Dockerfile b/cmd/hatchet-cli/cli/templates/go/Dockerfile new file mode 100644 index 0000000000..601e3f4a78 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/go/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN go build -o worker ./cmd/worker + +FROM alpine:latest + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /app/worker . + +CMD ["./worker"] diff --git a/cmd/hatchet-cli/cli/templates/go/hatchet.yaml b/cmd/hatchet-cli/cli/templates/go/hatchet.yaml index 8566323986..a6dbe5ae95 100644 --- a/cmd/hatchet-cli/cli/templates/go/hatchet.yaml +++ b/cmd/hatchet-cli/cli/templates/go/hatchet.yaml @@ -4,3 +4,8 @@ dev: files: - "**/*.go" reload: true + +triggers: + - name: simple + description: "Trigger the first workflow with sample input" + command: "go run cmd/run/main.go" diff --git a/cmd/hatchet-cli/cli/templates/python/README.md b/cmd/hatchet-cli/cli/templates/python/README.md deleted file mode 100644 index 34bff1e703..0000000000 --- a/cmd/hatchet-cli/cli/templates/python/README.md +++ /dev/null @@ -1,49 +0,0 @@ -## Hatchet Python Quickstart - {{ .Name }} - -This is an example project demonstrating how to use Hatchet with Python. For detailed setup instructions, see the [Hatchet Setup Guide](https://docs.hatchet.run/home/setup). - -## Prerequisites - -Before running this project, make sure you have the following: - -1. [Python v3.10 or higher](https://www.python.org/downloads/) -2. [Poetry](https://python-poetry.org/docs/#installation) for dependency management - -## Setup - -1. Clone the repository: - -```bash -git clone https://github.com/hatchet-dev/hatchet-python-quickstart.git -cd hatchet-python-quickstart -``` - -2. Set the required environment variable `HATCHET_CLIENT_TOKEN` created in the [Getting Started Guide](https://docs.hatchet.run/home/hatchet-cloud-quickstart). - -```bash -export HATCHET_CLIENT_TOKEN= -``` - -> Note: If you're self hosting you may need to set `HATCHET_CLIENT_TLS_STRATEGY=none` to disable TLS - -3. Install the project dependencies: - -```bash -poetry install -``` - -### Running an example - -1. Start a Hatchet worker by running the following command: - -```shell -poetry run python src/worker.py -``` - -2. To run the example workflow, open a new terminal and run the following command: - -```shell -poetry run python src/run.py -``` - -This will trigger the workflow on the worker running in the first terminal and print the output to the the second terminal. diff --git a/cmd/hatchet-cli/cli/templates/python/hatchet.yaml b/cmd/hatchet-cli/cli/templates/python/hatchet.yaml deleted file mode 100644 index 4234366e50..0000000000 --- a/cmd/hatchet-cli/cli/templates/python/hatchet.yaml +++ /dev/null @@ -1,12 +0,0 @@ -dev: - preCmds: ["poetry install"] - runCmd: "poetry run python src/worker.py" - files: - - "**/*.py" - - "!**/__pycache__/**" - - "!**/.venv/**" - reload: true -scripts: - - name: "simple" - command: "poetry run python src/run.py" - description: "Trigger a simple workflow" diff --git a/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md b/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md new file mode 100644 index 0000000000..cb73cd95ba --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md @@ -0,0 +1,11 @@ +Trigger a workflow (in another terminal): +```sh +hatchet trigger simple +``` + +**Notes:** +- The virtual environment (`.venv`) will be automatically created by `hatchet worker dev` +- If running commands manually (not via `hatchet worker`), make sure to activate the virtual environment first: + ```sh + source .venv/bin/activate # On Windows: .venv\Scripts\activate + ``` diff --git a/cmd/hatchet-cli/cli/templates/python/pip/requirements.txt b/cmd/hatchet-cli/cli/templates/python/pip/requirements.txt new file mode 100644 index 0000000000..c243987100 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/pip/requirements.txt @@ -0,0 +1 @@ +hatchet-sdk>=1.20.2 diff --git a/cmd/hatchet-cli/cli/templates/python/POST_QUICKSTART.md b/cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md similarity index 59% rename from cmd/hatchet-cli/cli/templates/python/POST_QUICKSTART.md rename to cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md index a33938dd3c..67f6e04072 100644 --- a/cmd/hatchet-cli/cli/templates/python/POST_QUICKSTART.md +++ b/cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md @@ -1,4 +1,4 @@ Trigger a workflow (in another terminal): ```sh -hatchet worker run --script simple +hatchet trigger simple ``` diff --git a/cmd/hatchet-cli/cli/templates/python/poetry.lock b/cmd/hatchet-cli/cli/templates/python/poetry/poetry.lock similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/poetry.lock rename to cmd/hatchet-cli/cli/templates/python/poetry/poetry.lock diff --git a/cmd/hatchet-cli/cli/templates/python/pyproject.toml b/cmd/hatchet-cli/cli/templates/python/poetry/pyproject.toml similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/pyproject.toml rename to cmd/hatchet-cli/cli/templates/python/poetry/pyproject.toml diff --git a/cmd/hatchet-cli/cli/templates/python/shared/.gitignore b/cmd/hatchet-cli/cli/templates/python/shared/.gitignore new file mode 100644 index 0000000000..446f83c160 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/shared/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment files +.env +.env.local + +# Poetry +poetry.lock + +# OS +.DS_Store +Thumbs.db diff --git a/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile b/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile new file mode 100644 index 0000000000..60cc8878ed --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile @@ -0,0 +1,46 @@ +FROM python:3.11-slim + +WORKDIR /app + +{{- if eq .PackageManager "poetry"}} +# Install Poetry +RUN pip install poetry + +# Copy dependency files +COPY pyproject.toml poetry.lock ./ + +# Install dependencies +RUN poetry config virtualenvs.create false && poetry install --no-root --only main + +# Copy source code +COPY src ./src + +CMD ["poetry", "run", "python", "src/worker.py"] + +{{- else if eq .PackageManager "uv"}} +# Install uv +RUN pip install uv + +# Copy dependency files +COPY pyproject.toml ./ + +# Install dependencies +RUN uv venv && uv pip install -e . + +# Copy source code +COPY src ./src + +CMD ["uv", "run", "python", "src/worker.py"] + +{{- else if eq .PackageManager "pip"}} +# Copy dependency files +COPY requirements.txt ./ + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY src ./src + +CMD ["python", "src/worker.py"] +{{- end}} diff --git a/cmd/hatchet-cli/cli/templates/python/shared/README.md b/cmd/hatchet-cli/cli/templates/python/shared/README.md new file mode 100644 index 0000000000..515112ec58 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/shared/README.md @@ -0,0 +1,97 @@ +## Hatchet Python Quickstart - {{ .Name }} + +This is an example project demonstrating how to use Hatchet with Python. For detailed setup instructions, see the [Hatchet Setup Guide](https://docs.hatchet.run/home/setup). + +## Prerequisites + +Before running this project, make sure you have the following: + +1. [Python v3.10 or higher](https://www.python.org/downloads/) +{{- if eq .PackageManager "poetry"}} +2. [Poetry](https://python-poetry.org/docs/#installation) for dependency management +{{- else if eq .PackageManager "uv"}} +2. [uv](https://docs.astral.sh/uv/) for dependency management +{{- else if eq .PackageManager "pip"}} +2. pip (included with Python) +{{- end}} + +## Setup + +1. Clone the repository: + +```bash +git clone https://github.com/hatchet-dev/hatchet-python-quickstart.git +cd hatchet-python-quickstart +``` + +2. Set the required environment variable `HATCHET_CLIENT_TOKEN` created in the [Getting Started Guide](https://docs.hatchet.run/home/hatchet-cloud-quickstart). + +```bash +export HATCHET_CLIENT_TOKEN= +``` + +> Note: If you're self hosting you may need to set `HATCHET_CLIENT_TLS_STRATEGY=none` to disable TLS + +3. Install the project dependencies: + +```bash +{{- if eq .PackageManager "poetry"}} +poetry install +{{- else if eq .PackageManager "uv"}} +# Create a virtual environment (if it doesn't exist) +uv venv + +# Install dependencies +uv pip install -e . +{{- else if eq .PackageManager "pip"}} +# Create a virtual environment +python -m venv .venv + +# Activate the virtual environment +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +{{- end}} +``` + +### Running an example + +{{- if eq .PackageManager "pip"}} +> **Note**: Make sure your virtual environment is activated before running these commands: +> ```bash +> source .venv/bin/activate # On Windows: .venv\Scripts\activate +> ``` +{{- end}} + +1. Start a Hatchet worker by running the following command: + +```shell +{{- if eq .PackageManager "poetry"}} +poetry run python src/worker.py +{{- else if eq .PackageManager "uv"}} +uv run python src/worker.py +{{- else if eq .PackageManager "pip"}} +python src/worker.py +{{- end}} +``` + +2. To run the example workflow, open a new terminal and run the following command: + +```shell +{{- if eq .PackageManager "poetry"}} +poetry run python src/run.py +{{- else if eq .PackageManager "uv"}} +uv run python src/run.py +{{- else if eq .PackageManager "pip"}} +# Make sure to activate the venv first: source .venv/bin/activate +python src/run.py +{{- end}} +``` + +This will trigger the workflow on the worker running in the first terminal and print the output to the the second terminal. + +{{- if eq .PackageManager "uv"}} + +> **Note**: `uv run` automatically uses the virtual environment created by `uv venv`. You don't need to manually activate it. +{{- end}} diff --git a/cmd/hatchet-cli/cli/templates/python/__init__.py b/cmd/hatchet-cli/cli/templates/python/shared/__init__.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/__init__.py rename to cmd/hatchet-cli/cli/templates/python/shared/__init__.py diff --git a/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml b/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml new file mode 100644 index 0000000000..bdebcb4156 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml @@ -0,0 +1,26 @@ +dev: +{{- if eq .PackageManager "poetry"}} + preCmds: ["poetry install"] + runCmd: "poetry run python src/worker.py" +{{- else if eq .PackageManager "uv"}} + preCmds: ["uv venv --allow-existing", "uv pip install -e ."] + runCmd: "uv run python src/worker.py" +{{- else if eq .PackageManager "pip"}} + preCmds: ["python -m venv .venv 2>/dev/null || true", ".venv/bin/pip install -r requirements.txt"] + runCmd: ".venv/bin/python src/worker.py" +{{- end}} + files: + - "**/*.py" + - "!**/__pycache__/**" + - "!**/.venv/**" + reload: true +triggers: + - name: "simple" +{{- if eq .PackageManager "poetry"}} + command: "poetry run python src/run.py" +{{- else if eq .PackageManager "uv"}} + command: "uv run python src/run.py" +{{- else if eq .PackageManager "pip"}} + command: ".venv/bin/python src/run.py" +{{- end}} + description: "Trigger a simple workflow" diff --git a/cmd/hatchet-cli/cli/templates/python/src/__init__.py b/cmd/hatchet-cli/cli/templates/python/shared/src/__init__.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/__init__.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/__init__.py diff --git a/cmd/hatchet-cli/cli/templates/python/src/hatchet_client.py b/cmd/hatchet-cli/cli/templates/python/shared/src/hatchet_client.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/hatchet_client.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/hatchet_client.py diff --git a/cmd/hatchet-cli/cli/templates/python/src/run.py b/cmd/hatchet-cli/cli/templates/python/shared/src/run.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/run.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/run.py diff --git a/cmd/hatchet-cli/cli/templates/python/src/worker.py b/cmd/hatchet-cli/cli/templates/python/shared/src/worker.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/worker.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/worker.py diff --git a/cmd/hatchet-cli/cli/templates/python/src/workflows/__init__.py b/cmd/hatchet-cli/cli/templates/python/shared/src/workflows/__init__.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/workflows/__init__.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/workflows/__init__.py diff --git a/cmd/hatchet-cli/cli/templates/python/src/workflows/first_workflow.py b/cmd/hatchet-cli/cli/templates/python/shared/src/workflows/first_workflow.py similarity index 100% rename from cmd/hatchet-cli/cli/templates/python/src/workflows/first_workflow.py rename to cmd/hatchet-cli/cli/templates/python/shared/src/workflows/first_workflow.py diff --git a/cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md b/cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md new file mode 100644 index 0000000000..56f49ef89b --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md @@ -0,0 +1,9 @@ +Trigger a workflow (in another terminal): +```sh +hatchet trigger simple +``` + +**Notes:** +- The virtual environment will be automatically created by `hatchet worker dev` +- `uv run` automatically uses the virtual environment, no need to activate it manually +- The `uv.lock` file will be automatically generated when dependencies are installed diff --git a/cmd/hatchet-cli/cli/templates/python/uv/pyproject.toml b/cmd/hatchet-cli/cli/templates/python/uv/pyproject.toml new file mode 100644 index 0000000000..01a8cff58d --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/python/uv/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "hatchet-python-quickstart" +version = "0.1.0" +description = "Simple Setup to Run Hatchet Workflows" +authors = [{name = "gabriel ruttner", email = "gabe@hatchet.run"}] +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "hatchet-sdk>=1.20.2", +] + +[project.scripts] +simple = "src.run:main" +worker = "src.worker:main" diff --git a/cmd/hatchet-cli/cli/templates/typescript/Dockerfile b/cmd/hatchet-cli/cli/templates/typescript/Dockerfile deleted file mode 100644 index 9e67a9da5b..0000000000 --- a/cmd/hatchet-cli/cli/templates/typescript/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:22 - -RUN apt-get update -y && apt-get install -y openssl - -WORKDIR /app - -COPY package*.json ./ -RUN npm install - -COPY src ./src -COPY tsconfig.json ./ -RUN npm run build - -CMD ["npm", "run", "start"] diff --git a/cmd/hatchet-cli/cli/templates/typescript/bun/package.json b/cmd/hatchet-cli/cli/templates/typescript/bun/package.json new file mode 100644 index 0000000000..a24178e3b3 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/bun/package.json @@ -0,0 +1,21 @@ +{ + "name": "hatchet-typescript-quickstart", + "version": "1.0.0", + "description": "This is an example project demonstrating how to use Hatchet with Typescript.", + "main": "index.js", + "scripts": { + "start": "bun run src/worker.ts", + "run:simple": "bun run src/run.ts", + "build": "tsc --outDir dist" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^22.15.3", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hatchet-dev/typescript-sdk": "^1.10.3" + } +} diff --git a/cmd/hatchet-cli/cli/templates/typescript/hatchet.yaml b/cmd/hatchet-cli/cli/templates/typescript/hatchet.yaml deleted file mode 100644 index d174fb5e74..0000000000 --- a/cmd/hatchet-cli/cli/templates/typescript/hatchet.yaml +++ /dev/null @@ -1,7 +0,0 @@ -dev: - preCmds: ["pnpm install"] - runCmd: "pnpm start" - files: - - "**/*.ts" - - "!**/node_modules/**" - reload: true diff --git a/cmd/hatchet-cli/cli/templates/typescript/npm/package.json b/cmd/hatchet-cli/cli/templates/typescript/npm/package.json new file mode 100644 index 0000000000..e2e89e3f22 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "hatchet-typescript-quickstart", + "version": "1.0.0", + "description": "This is an example project demonstrating how to use Hatchet with Typescript.", + "main": "index.js", + "scripts": { + "start": "npx ts-node src/worker.ts", + "run:simple": "npx ts-node src/run.ts", + "build": "tsc --outDir dist" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^22.15.3", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hatchet-dev/typescript-sdk": "^1.10.3" + } +} diff --git a/cmd/hatchet-cli/cli/templates/typescript/package.json b/cmd/hatchet-cli/cli/templates/typescript/pnpm/package.json similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/package.json rename to cmd/hatchet-cli/cli/templates/typescript/pnpm/package.json diff --git a/cmd/hatchet-cli/cli/templates/typescript/pnpm-lock.yaml b/cmd/hatchet-cli/cli/templates/typescript/pnpm/pnpm-lock.yaml similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/pnpm-lock.yaml rename to cmd/hatchet-cli/cli/templates/typescript/pnpm/pnpm-lock.yaml diff --git a/cmd/hatchet-cli/cli/templates/typescript/shared/.gitignore b/cmd/hatchet-cli/cli/templates/typescript/shared/.gitignore new file mode 100644 index 0000000000..59bf8d9e59 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment files +.env +.env.local + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Lock files (package manager specific) +package-lock.json +yarn.lock +pnpm-lock.yaml +bun.lockb diff --git a/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile b/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile new file mode 100644 index 0000000000..7b62540e0d --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile @@ -0,0 +1,45 @@ +FROM node:22 + +RUN apt-get update -y && apt-get install -y openssl + +WORKDIR /app + +{{- if eq .PackageManager "npm"}} +COPY package*.json ./ +RUN npm install + +COPY src ./src +COPY tsconfig.json ./ +RUN npm run build + +CMD ["npm", "run", "start"] +{{- else if eq .PackageManager "pnpm"}} +RUN npm install -g pnpm +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY src ./src +COPY tsconfig.json ./ +RUN pnpm run build + +CMD ["pnpm", "start"] +{{- else if eq .PackageManager "yarn"}} +COPY package.json ./ +RUN yarn install + +COPY src ./src +COPY tsconfig.json ./ +RUN yarn build + +CMD ["yarn", "start"] +{{- else if eq .PackageManager "bun"}} +COPY --from=oven/bun:latest /usr/local/bin/bun /usr/local/bin/bun +COPY package.json ./ +RUN bun install + +COPY src ./src +COPY tsconfig.json ./ +RUN bun run build + +CMD ["bun", "start"] +{{- end}} diff --git a/cmd/hatchet-cli/cli/templates/typescript/README.md b/cmd/hatchet-cli/cli/templates/typescript/shared/README.md similarity index 53% rename from cmd/hatchet-cli/cli/templates/typescript/README.md rename to cmd/hatchet-cli/cli/templates/typescript/shared/README.md index 772ec6edc4..3cb0843a3e 100644 --- a/cmd/hatchet-cli/cli/templates/typescript/README.md +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/README.md @@ -1,4 +1,4 @@ -# Hatchet First Workflow Example +# Hatchet TypeScript Quickstart - {{ .Name }} This is an example project demonstrating how to use Hatchet with TypeScript. For detailed setup instructions, see the [Hatchet Setup Guide](https://docs.hatchet.run/home/setup). @@ -7,7 +7,15 @@ This is an example project demonstrating how to use Hatchet with TypeScript. For Before running this project, make sure you have the following: 1. [Node.js v16 or higher](https://nodejs.org/en/download) -2. npm, yarn, or pnpm package manager +{{- if eq .PackageManager "npm"}} +2. npm package manager (included with Node.js) +{{- else if eq .PackageManager "pnpm"}} +2. [pnpm](https://pnpm.io/installation) package manager +{{- else if eq .PackageManager "yarn"}} +2. [Yarn](https://yarnpkg.com/getting-started/install) package manager +{{- else if eq .PackageManager "bun"}} +2. [Bun](https://bun.sh/) runtime and package manager +{{- end}} ## Setup @@ -29,11 +37,15 @@ export HATCHET_CLIENT_TOKEN= 3. Install the project dependencies: ```bash +{{- if eq .PackageManager "npm"}} npm install -# or -yarn install -# or +{{- else if eq .PackageManager "pnpm"}} pnpm install +{{- else if eq .PackageManager "yarn"}} +yarn install +{{- else if eq .PackageManager "bun"}} +bun install +{{- end}} ``` ### Running an example @@ -41,13 +53,29 @@ pnpm install 1. Start a Hatchet worker: ```bash +{{- if eq .PackageManager "npm"}} npm run start +{{- else if eq .PackageManager "pnpm"}} +pnpm start +{{- else if eq .PackageManager "yarn"}} +yarn start +{{- else if eq .PackageManager "bun"}} +bun start +{{- end}} ``` 2. In a new terminal, run the example task: ```bash +{{- if eq .PackageManager "npm"}} npm run run:simple +{{- else if eq .PackageManager "pnpm"}} +pnpm run:simple +{{- else if eq .PackageManager "yarn"}} +yarn run:simple +{{- else if eq .PackageManager "bun"}} +bun run:simple +{{- end}} ``` This will trigger the task on the worker running in the first terminal and print the output to the second terminal. diff --git a/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml b/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml new file mode 100644 index 0000000000..09b53dd189 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml @@ -0,0 +1,31 @@ +dev: +{{- if eq .PackageManager "npm"}} + preCmds: ["npm install"] + runCmd: "npm start" +{{- else if eq .PackageManager "pnpm"}} + preCmds: ["pnpm install"] + runCmd: "pnpm start" +{{- else if eq .PackageManager "yarn"}} + preCmds: ["yarn install"] + runCmd: "yarn start" +{{- else if eq .PackageManager "bun"}} + preCmds: ["bun install"] + runCmd: "bun start" +{{- end}} + files: + - "**/*.ts" + - "!**/node_modules/**" + reload: true + +triggers: + - name: simple + description: "Trigger the first workflow with sample input" +{{- if eq .PackageManager "npm"}} + command: "npm run run:simple" +{{- else if eq .PackageManager "pnpm"}} + command: "pnpm run run:simple" +{{- else if eq .PackageManager "yarn"}} + command: "yarn run:simple" +{{- else if eq .PackageManager "bun"}} + command: "bun run run:simple" +{{- end}} diff --git a/cmd/hatchet-cli/cli/templates/typescript/src/hatchet-client.ts b/cmd/hatchet-cli/cli/templates/typescript/shared/src/hatchet-client.ts similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/src/hatchet-client.ts rename to cmd/hatchet-cli/cli/templates/typescript/shared/src/hatchet-client.ts diff --git a/cmd/hatchet-cli/cli/templates/typescript/src/run.ts b/cmd/hatchet-cli/cli/templates/typescript/shared/src/run.ts similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/src/run.ts rename to cmd/hatchet-cli/cli/templates/typescript/shared/src/run.ts diff --git a/cmd/hatchet-cli/cli/templates/typescript/src/worker.ts b/cmd/hatchet-cli/cli/templates/typescript/shared/src/worker.ts similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/src/worker.ts rename to cmd/hatchet-cli/cli/templates/typescript/shared/src/worker.ts diff --git a/cmd/hatchet-cli/cli/templates/typescript/src/workflows/first-workflow.ts b/cmd/hatchet-cli/cli/templates/typescript/shared/src/workflows/first-workflow.ts similarity index 100% rename from cmd/hatchet-cli/cli/templates/typescript/src/workflows/first-workflow.ts rename to cmd/hatchet-cli/cli/templates/typescript/shared/src/workflows/first-workflow.ts diff --git a/cmd/hatchet-cli/cli/templates/typescript/shared/tsconfig.json b/cmd/hatchet-cli/cli/templates/typescript/shared/tsconfig.json new file mode 100644 index 0000000000..904d43ff28 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/tsconfig.json @@ -0,0 +1,113 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "libReplacement": true, /* Enable lib replacement. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/cmd/hatchet-cli/cli/templates/typescript/yarn/package.json b/cmd/hatchet-cli/cli/templates/typescript/yarn/package.json new file mode 100644 index 0000000000..e2e89e3f22 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/yarn/package.json @@ -0,0 +1,21 @@ +{ + "name": "hatchet-typescript-quickstart", + "version": "1.0.0", + "description": "This is an example project demonstrating how to use Hatchet with Typescript.", + "main": "index.js", + "scripts": { + "start": "npx ts-node src/worker.ts", + "run:simple": "npx ts-node src/run.ts", + "build": "tsc --outDir dist" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^22.15.3", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hatchet-dev/typescript-sdk": "^1.10.3" + } +} diff --git a/cmd/hatchet-cli/cli/trigger.go b/cmd/hatchet-cli/cli/trigger.go new file mode 100644 index 0000000000..507ff48978 --- /dev/null +++ b/cmd/hatchet-cli/cli/trigger.go @@ -0,0 +1,673 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/worker" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/pm" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client" + "github.com/hatchet-dev/hatchet/pkg/client/rest" + "github.com/hatchet-dev/hatchet/pkg/cmdutils" + profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" +) + +const ( + // ReservedTriggerManual is the reserved keyword for manual workflow triggering + ReservedTriggerManual = "manual" + + // ManualTriggerDescription is the description shown in the trigger selector + ManualTriggerDescription = "Manually trigger a workflow via API" +) + +var triggerCmd = &cobra.Command{ + Use: "trigger [trigger-name]", + Short: "Run a trigger defined in hatchet.yaml or manually trigger a workflow", + Long: `Execute a trigger defined in the triggers section of hatchet.yaml, or use "manual" to trigger any workflow via API. If no trigger name is provided, displays an interactive list of available triggers.`, + Example: ` # Show interactive list of triggers + hatchet trigger + + # Run a specific trigger by name + hatchet trigger simple + + # Manually trigger a workflow interactively + hatchet trigger manual + + # Manually trigger a workflow non-interactively + hatchet trigger manual --workflow my-workflow --json ./input.json + + # Run with a specific profile + hatchet trigger bulk --profile local`, + Run: func(cmd *cobra.Command, args []string) { + var triggerName string + if len(args) > 0 { + triggerName = args[0] + } + profileFlag, _ := cmd.Flags().GetString("profile") + workflowFlag, _ := cmd.Flags().GetString("workflow") + jsonFlag, _ := cmd.Flags().GetString("json") + + executeTrigger(cmd, triggerName, profileFlag, workflowFlag, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(triggerCmd) + + // Add flags for trigger command + triggerCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + triggerCmd.Flags().StringP("workflow", "w", "", "Workflow name for manual triggering (non-interactive mode)") + triggerCmd.Flags().StringP("json", "j", "", "Path to JSON input file for manual triggering (non-interactive mode)") +} + +// executeTrigger is the main entry point for trigger execution +func executeTrigger(cmd *cobra.Command, triggerName string, profileFlag string, workflowFlag string, jsonFlag string) { + // If workflow or json flags are provided, we're in non-interactive manual mode + if workflowFlag != "" || jsonFlag != "" { + if triggerName != "" && triggerName != ReservedTriggerManual { + cli.Logger.Fatal("--workflow and --json flags can only be used with manual triggering") + } + if workflowFlag == "" || jsonFlag == "" { + cli.Logger.Fatal("both --workflow and --json flags are required for non-interactive manual triggering") + } + runManualTrigger(cmd, workflowFlag, jsonFlag, profileFlag, false) + return + } + + // Handle manual trigger directly + if triggerName == ReservedTriggerManual { + runManualTrigger(cmd, "", "", profileFlag, true) + return + } + + // Try to load worker config for non-manual triggers + workerConfig, err := worker.LoadWorkerConfig() + + // If specific trigger name provided, try to run it + if triggerName != "" { + if err != nil || workerConfig == nil { + cli.Logger.Fatalf("could not load hatchet.yaml: %v\nTip: Use 'hatchet trigger manual' to trigger workflows without a config file", err) + } + + // Validate trigger names + if err := validateTriggerNames(workerConfig.Triggers); err != nil { + cli.Logger.Fatal(err.Error()) + } + + // Find the trigger + var selectedTrigger *worker.Trigger + for i := range workerConfig.Triggers { + trigger := &workerConfig.Triggers[i] + triggerDisplayName := trigger.Name + if triggerDisplayName == "" { + triggerDisplayName = trigger.Command + } + if triggerDisplayName == triggerName { + selectedTrigger = trigger + break + } + } + + if selectedTrigger == nil { + cli.Logger.Fatalf("trigger '%s' not found in hatchet.yaml", triggerName) + } + + runConfigTrigger(cmd, selectedTrigger, profileFlag) + return + } + + // No trigger name provided - show selector + var triggers []worker.Trigger + if workerConfig != nil && err == nil { + // Validate trigger names + if err := validateTriggerNames(workerConfig.Triggers); err != nil { + cli.Logger.Fatal(err.Error()) + } + triggers = workerConfig.Triggers + } + + // Show interactive selector (always includes manual option) + selectedTriggerName := selectTriggerForm(triggers) + + if selectedTriggerName == ReservedTriggerManual { + runManualTrigger(cmd, "", "", profileFlag, true) + return + } + + // Find and run the selected config trigger + var selectedTrigger *worker.Trigger + for i := range triggers { + trigger := &triggers[i] + triggerDisplayName := trigger.Name + if triggerDisplayName == "" { + triggerDisplayName = trigger.Command + } + if triggerDisplayName == selectedTriggerName { + selectedTrigger = trigger + break + } + } + + if selectedTrigger == nil { + cli.Logger.Fatal("no trigger selected") + } + + runConfigTrigger(cmd, selectedTrigger, profileFlag) +} + +// validateTriggerNames checks that no trigger uses the reserved "manual" keyword +func validateTriggerNames(triggers []worker.Trigger) error { + for _, trigger := range triggers { + triggerName := trigger.Name + if triggerName == "" { + triggerName = trigger.Command + } + if triggerName == ReservedTriggerManual { + return fmt.Errorf("trigger name '%s' is reserved. Please rename this trigger in your hatchet.yaml file", ReservedTriggerManual) + } + } + return nil +} + +// selectTriggerForm displays an interactive form to select a trigger +func selectTriggerForm(triggers []worker.Trigger) string { + // Build options from triggers + options := make([]huh.Option[string], 0, len(triggers)+1) + + for _, trigger := range triggers { + triggerName := trigger.Name + if triggerName == "" { + triggerName = trigger.Command + } + + label := triggerName + if trigger.Description != "" { + label = fmt.Sprintf("%s - %s", triggerName, trigger.Description) + } + + options = append(options, huh.NewOption(label, triggerName)) + } + + // Always add manual option + options = append(options, huh.NewOption(fmt.Sprintf("%s - %s", ReservedTriggerManual, ManualTriggerDescription), ReservedTriggerManual)) + + var selectedName string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a trigger to run:"). + Options(options...). + Value(&selectedName), + ), + ).WithTheme(styles.HatchetTheme()) + + err := form.Run() + if err != nil { + cli.Logger.Fatalf("could not run trigger selection form: %v", err) + } + + return selectedName +} + +// runConfigTrigger executes a trigger from the worker configuration +func runConfigTrigger(cmd *cobra.Command, trigger *worker.Trigger, profileFlag string) { + // Get profile + var selectedProfile string + if profileFlag != "" { + selectedProfile = profileFlag + } else { + selectedProfile = selectProfileForm(true) + + if selectedProfile == "" { + selectedProfile = handleNoProfiles(cmd) + if selectedProfile == "" { + cli.Logger.Fatal("no profile selected or created") + } + } + } + + profile, err := cli.GetProfile(selectedProfile) + if err != nil { + cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err) + } + + // Display trigger info + triggerName := trigger.Name + if triggerName == "" { + triggerName = trigger.Command + } + + fmt.Println(styles.InfoMessage(fmt.Sprintf("Running trigger: %s", triggerName))) + if trigger.Description != "" { + fmt.Println(styles.Muted.Render(trigger.Description)) + } + fmt.Println(styles.Muted.Render(fmt.Sprintf("Command: %s", trigger.Command))) + fmt.Println() + + // Execute the trigger + ctx, cancel := cmdutils.NewInterruptContext() + defer cancel() + + err = pm.Exec(ctx, trigger.Command, profile) + if err != nil { + cli.Logger.Fatalf("error running trigger '%s': %v", triggerName, err) + } + + fmt.Println() + fmt.Println(styles.SuccessMessage("Trigger completed successfully")) +} + +// runManualTrigger executes manual workflow triggering +func runManualTrigger(cmd *cobra.Command, workflowFlag string, jsonFlag string, profileFlag string, interactive bool) { + // Get profile + var selectedProfile string + if profileFlag != "" { + selectedProfile = profileFlag + } else { + selectedProfile = selectProfileForm(true) + + if selectedProfile == "" { + selectedProfile = handleNoProfiles(cmd) + if selectedProfile == "" { + cli.Logger.Fatal("no profile selected or created") + } + } + } + + profile, err := cli.GetProfile(selectedProfile) + if err != nil { + cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err) + } + + // Initialize Hatchet client + nopLogger := zerolog.Nop() + hatchetClient, err := NewClientFromProfile(profile, &nopLogger) + if err != nil { + cli.Logger.Fatalf("could not create Hatchet client: %v", err) + } + + if interactive { + runManualInteractive(profile, hatchetClient) + } else { + runManualNonInteractive(profile, hatchetClient, workflowFlag, jsonFlag) + } +} + +// WorkflowInfo contains information about a workflow +type WorkflowInfo struct { + ID string + Name string + Version string +} + +// runManualInteractive runs manual workflow triggering in interactive mode +func runManualInteractive(profile *profileconfig.Profile, hatchetClient client.Client) { + ctx := context.Background() + + // Get tenant UUID + tenantID := hatchetClient.TenantId() + tenantUUID, err := uuid.Parse(tenantID) + if err != nil { + cli.Logger.Fatalf("invalid tenant ID: %v", err) + } + + // Fetch workflows + fmt.Println(styles.InfoMessage("Fetching workflows...")) + response, err := hatchetClient.API().WorkflowListWithResponse(ctx, tenantUUID, &rest.WorkflowListParams{}) + if err != nil { + cli.Logger.Fatalf("could not fetch workflows: %v", err) + } + + if response.JSON200 == nil || response.JSON200.Rows == nil || len(*response.JSON200.Rows) == 0 { + cli.Logger.Fatal("no workflows available. Deploy a workflow first.") + } + + // Build workflow list + workflows := make([]WorkflowInfo, 0) + for _, wf := range *response.JSON200.Rows { + version := "latest" + if wf.Versions != nil && len(*wf.Versions) > 0 { + firstVersion := (*wf.Versions)[0] + version = firstVersion.Version + } + workflows = append(workflows, WorkflowInfo{ + ID: wf.Metadata.Id, + Name: wf.Name, + Version: version, + }) + } + + // Sort workflows by name + sort.Slice(workflows, func(i, j int) bool { + return workflows[i].Name < workflows[j].Name + }) + + // Select workflow + selectedWorkflow := selectWorkflowForm(workflows) + + // Edit JSON input + fmt.Println() + fmt.Println(styles.InfoMessage("Opening editor for workflow input...")) + jsonInput, err := editJSONInEditor("{}") + if err != nil { + cli.Logger.Fatalf("error editing JSON: %v", err) + } + + // Validate JSON + if err := validateJSON(jsonInput); err != nil { + cli.Logger.Fatalf("invalid JSON: %v", err) + } + + // Trigger workflow + fmt.Println() + fmt.Println(styles.InfoMessage(fmt.Sprintf("Triggering workflow: %s", selectedWorkflow.Name))) + runID, err := triggerWorkflowWithClient(hatchetClient, selectedWorkflow.Name, jsonInput) + if err != nil { + cli.Logger.Fatalf("error triggering workflow: %v", err) + } + + // Display success message with TUI prompt (interactive mode only) + displaySuccessMessage(runID, selectedWorkflow.Name, profile.Name, hatchetClient) +} + +// runManualNonInteractive runs manual workflow triggering in non-interactive mode +func runManualNonInteractive(profile *profileconfig.Profile, hatchetClient client.Client, workflowName string, jsonPath string) { + ctx := context.Background() + + // Get tenant UUID + tenantID := hatchetClient.TenantId() + tenantUUID, err := uuid.Parse(tenantID) + if err != nil { + cli.Logger.Fatalf("invalid tenant ID: %v", err) + } + + // Fetch workflows + response, err := hatchetClient.API().WorkflowListWithResponse(ctx, tenantUUID, &rest.WorkflowListParams{}) + if err != nil { + cli.Logger.Fatalf("could not fetch workflows: %v", err) + } + + if response.JSON200 == nil || response.JSON200.Rows == nil || len(*response.JSON200.Rows) == 0 { + cli.Logger.Fatal("no workflows available. Deploy a workflow first.") + } + + // Find workflow by name + var selectedWorkflow *WorkflowInfo + for _, wf := range *response.JSON200.Rows { + if wf.Name == workflowName { + version := "latest" + if wf.Versions != nil && len(*wf.Versions) > 0 { + firstVersion := (*wf.Versions)[0] + version = firstVersion.Version + } + selectedWorkflow = &WorkflowInfo{ + ID: wf.Metadata.Id, + Name: wf.Name, + Version: version, + } + break + } + } + + if selectedWorkflow == nil { + cli.Logger.Fatalf("workflow '%s' not found", workflowName) + return // Make linter happy + } + + // Read JSON from file + jsonBytes, err := os.ReadFile(jsonPath) + if err != nil { + cli.Logger.Fatalf("could not read JSON file '%s': %v", jsonPath, err) + } + jsonInput := string(jsonBytes) + + // Validate JSON + if err := validateJSON(jsonInput); err != nil { + cli.Logger.Fatalf("invalid JSON in file '%s': %v", jsonPath, err) + } + + // Trigger workflow + selectedWorkflowName := selectedWorkflow.Name + fmt.Println(styles.InfoMessage(fmt.Sprintf("Triggering workflow: %s", selectedWorkflowName))) + runID, err := triggerWorkflowWithClient(hatchetClient, selectedWorkflowName, jsonInput) + if err != nil { + cli.Logger.Fatalf("error triggering workflow: %v", err) + } + + // Display success message (no TUI prompt in non-interactive mode) + fmt.Println() + fmt.Println(styles.SuccessMessage("Workflow triggered successfully")) + fmt.Println() + fmt.Println(styles.KeyValue("Run ID", runID)) + fmt.Println(styles.KeyValue("Workflow", selectedWorkflowName)) + fmt.Println() + fmt.Println(styles.Muted.Render(fmt.Sprintf("View in TUI: hatchet tui --run %s --profile %s", runID, profile.Name))) +} + +// selectWorkflowForm displays an interactive form to select a workflow +func selectWorkflowForm(workflows []WorkflowInfo) WorkflowInfo { + options := make([]huh.Option[int], 0, len(workflows)) + for i, wf := range workflows { + options = append(options, huh.NewOption(wf.Name, i)) + } + + var selectedIndex int + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Select a workflow to trigger:"). + Options(options...). + Value(&selectedIndex), + ), + ).WithTheme(styles.HatchetTheme()) + + err := form.Run() + if err != nil { + cli.Logger.Fatalf("could not run workflow selection form: %v", err) + } + + return workflows[selectedIndex] +} + +// editJSONInEditor opens an editor for the user to edit JSON input +func editJSONInEditor(initialContent string) (string, error) { + // Create temp file + tmpFile, err := os.CreateTemp("", "hatchet-input-*.json") + if err != nil { + return "", fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + // Write initial content + if _, err := tmpFile.WriteString(initialContent); err != nil { + tmpFile.Close() + return "", fmt.Errorf("could not write to temp file: %w", err) + } + + // Get original modification time + originalStat, err := os.Stat(tmpPath) + if err != nil { + tmpFile.Close() + return "", fmt.Errorf("could not stat temp file: %w", err) + } + originalModTime := originalStat.ModTime() + + tmpFile.Close() + + // Detect editor + editor := os.Getenv("EDITOR") + if editor == "" { + // Try common editors + for _, e := range []string{"vi", "vim", "nano"} { + if _, err := exec.LookPath(e); err == nil { + editor = e + break + } + } + } + + if editor == "" { + return "", fmt.Errorf("no editor found. Set the EDITOR environment variable or install vi, vim, or nano") + } + + // Open editor + cmd := exec.Command(editor, tmpPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Println(styles.Muted.Render("Opening editor... (save and quit to continue, or quit without saving to cancel)")) + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("editor exited with error: %w", err) + } + + // Check if file was modified + newStat, err := os.Stat(tmpPath) + if err != nil { + return "", fmt.Errorf("could not stat temp file after edit: %w", err) + } + + // If file wasn't modified, user likely quit without saving + if newStat.ModTime().Equal(originalModTime) { + // Read the content anyway - it might be OK with the default + content, err := os.ReadFile(tmpPath) + if err != nil { + return "", fmt.Errorf("could not read edited file: %w", err) + } + + // If content is just the initial content and user didn't modify, ask if they want to use it + if string(content) == initialContent { + fmt.Println() + fmt.Println(styles.Muted.Render("Note: File was not modified. Using default input: {}")) + fmt.Println() + } + + return string(content), nil + } + + // Read edited content + content, err := os.ReadFile(tmpPath) + if err != nil { + return "", fmt.Errorf("could not read edited file: %w", err) + } + + return string(content), nil +} + +// validateJSON validates that the input is valid JSON +func validateJSON(content string) error { + var js interface{} + if err := json.Unmarshal([]byte(content), &js); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + return nil +} + +// triggerWorkflowWithClient triggers a workflow using the Hatchet client +func triggerWorkflowWithClient(hatchetClient client.Client, workflowName string, jsonInput string) (string, error) { + // Parse JSON input + var inputData map[string]interface{} + if err := json.Unmarshal([]byte(jsonInput), &inputData); err != nil { + return "", fmt.Errorf("could not parse JSON input: %w", err) + } + + // Trigger workflow using the Admin client with a timeout context + // Create a channel to handle the async workflow trigger + type result struct { + runID string + err error + } + resultChan := make(chan result, 1) + + go func() { + workflow, err := hatchetClient.Admin().RunWorkflow(workflowName, inputData) + if err != nil { + resultChan <- result{err: fmt.Errorf("could not trigger workflow: %w", err)} + return + } + resultChan <- result{runID: workflow.RunId()} + }() + + // Wait for result with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + select { + case res := <-resultChan: + if res.err != nil { + return "", res.err + } + return res.runID, nil + case <-ctx.Done(): + return "", fmt.Errorf("workflow trigger timed out after 30 seconds - this may indicate a connection issue with the Hatchet server") + } +} + +// displaySuccessMessage displays the success message and prompts to launch TUI +func displaySuccessMessage(runID string, workflowName string, profileName string, hatchetClient client.Client) { + fmt.Println() + fmt.Println(styles.SuccessMessage("Workflow triggered successfully")) + fmt.Println() + fmt.Println(styles.KeyValue("Run ID", runID)) + fmt.Println(styles.KeyValue("Workflow", workflowName)) + fmt.Println() + fmt.Println("Press Enter to view in TUI (or Ctrl+C to exit)") + + // Wait for Enter key + reader := bufio.NewReader(os.Stdin) + _, _ = reader.ReadString('\n') + + // Launch TUI with run ID + launchTUIWithRun(runID, profileName, hatchetClient) +} + +// launchTUIWithRun launches the TUI with auto-navigation to the specified run +func launchTUIWithRun(runID string, profileName string, hatchetClient client.Client) { + // Start the TUI with initial run ID by sending NavigateToRunWithDetectionMsg after initialization + model := newTUIModel(profileName, hatchetClient) + + // Create a custom Init function that navigates to the run + p := tea.NewProgram( + tuiModelWithInitialRun{ + tuiModel: model, + initialRunID: runID, + }, + tea.WithAltScreen(), + ) + + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } +} + +// tuiModelWithInitialRun wraps tuiModel to add initial run navigation +type tuiModelWithInitialRun struct { + tuiModel + initialRunID string +} + +func (m tuiModelWithInitialRun) Init() tea.Cmd { + return tea.Batch( + m.tuiModel.Init(), + func() tea.Msg { + return tui.NavigateToRunWithDetectionMsg{RunID: m.initialRunID} + }, + ) +} diff --git a/cmd/hatchet-cli/cli/tui.go b/cmd/hatchet-cli/cli/tui.go index 9dc4b8bb35..7aeec2b105 100644 --- a/cmd/hatchet-cli/cli/tui.go +++ b/cmd/hatchet-cli/cli/tui.go @@ -28,9 +28,13 @@ var tuiCmd = &cobra.Command{ hatchet tui # Start TUI with a specific profile - hatchet tui --profile production`, + hatchet tui --profile production + + # Start TUI and navigate to a specific workflow run + hatchet tui --run 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local`, Run: func(cmd *cobra.Command, args []string) { profileFlag, _ := cmd.Flags().GetString("profile") + runFlag, _ := cmd.Flags().GetString("run") var selectedProfile string @@ -52,17 +56,24 @@ var tuiCmd = &cobra.Command{ // Initialize Hatchet client nopLogger := zerolog.Nop() - hatchetClient, err := client.New( - client.WithToken(profile.Token), - client.WithLogger(&nopLogger), - ) + hatchetClient, err := NewClientFromProfile(profile, &nopLogger) if err != nil { cli.Logger.Fatalf("could not create Hatchet client: %v", err) } - // Start the TUI + // Start the TUI with optional run navigation + var model tea.Model + if runFlag != "" { + model = tuiModelWithInitialRun{ + tuiModel: newTUIModel(selectedProfile, hatchetClient), + initialRunID: runFlag, + } + } else { + model = newTUIModel(selectedProfile, hatchetClient) + } + p := tea.NewProgram( - newTUIModel(selectedProfile, hatchetClient), + model, tea.WithAltScreen(), ) @@ -75,6 +86,7 @@ var tuiCmd = &cobra.Command{ func init() { rootCmd.AddCommand(tuiCmd) tuiCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + tuiCmd.Flags().StringP("run", "r", "", "Navigate to a specific workflow run by ID") } // ViewType represents the type of primary view @@ -697,10 +709,7 @@ func (m tuiModel) switchProfile(profileName string) tea.Cmd { // Initialize new Hatchet client with the new profile's token nopLogger := zerolog.Nop() - hatchetClient, err := client.New( - client.WithToken(profile.Token), - client.WithLogger(&nopLogger), - ) + hatchetClient, err := NewClientFromProfile(profile, &nopLogger) if err != nil { return profileSwitchErrorMsg{err: err} } diff --git a/cmd/hatchet-cli/cli/worker.go b/cmd/hatchet-cli/cli/worker.go index 44024ddbfa..9a1bbb23d7 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "log" "os" @@ -14,6 +15,7 @@ import ( "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/pm" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" "github.com/hatchet-dev/hatchet/pkg/cmdutils" + profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" ) var c *worker.WorkerConfig @@ -73,40 +75,15 @@ var devCmd = &cobra.Command{ }, } -var runCmd = &cobra.Command{ - Use: "run", - Short: "Run a script defined in hatchet.yaml", - Long: `Execute a script defined in the scripts section of hatchet.yaml. If no script name is provided, displays an interactive list of available scripts to choose from.`, - Example: ` # Show interactive list of scripts - hatchet worker run - - # Run a specific script by name - hatchet worker run --script simple - - # Run with a specific profile - hatchet worker run --script bulk --profile local`, - Run: func(cmd *cobra.Command, args []string) { - scriptFlag, _ := cmd.Flags().GetString("script") - profileFlag, _ := cmd.Flags().GetString("profile") - - runScript(cmd, scriptFlag, profileFlag) - }, -} - func init() { rootCmd.AddCommand(workerCmd) workerCmd.AddCommand(devCmd) - workerCmd.AddCommand(runCmd) // Add flags for dev command devCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") devCmd.Flags().Bool("no-reload", false, "Disable automatic reloading on file changes") devCmd.Flags().StringP("run-cmd", "r", "", "Override the run command from hatchet.yaml") - - // Add flags for run command - runCmd.Flags().StringP("script", "s", "", "Name of the script to run (default: prompts for selection)") - runCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") } func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileFlag string) { @@ -136,6 +113,18 @@ func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileF ctx, cancel := cmdutils.NewInterruptContext() defer cancel() + fmt.Println(workerStartingView(selectedProfile, devConfig.Reload)) + + if err := RunWorkerDev(ctx, profile, devConfig, nil); err != nil { + cli.Logger.Fatalf("error running worker: %v", err) + } +} + +// RunWorkerDev runs the worker in dev mode with the given profile and config. +// This function can be called directly from tests without interactive forms. +// If preCmdsCompleteChan is provided, it will be signaled when pre-commands complete. +func RunWorkerDev(ctx context.Context, profile *profileconfig.Profile, devConfig *worker.WorkerDevConfig, preCmdsCompleteChan chan<- struct{}) error { + // Run pre-commands if any if devConfig.PreCmds != nil { for _, preCmdStr := range devConfig.PreCmds { fmt.Println(styles.InfoMessage(fmt.Sprintf("Running pre-command: %s", preCmdStr))) @@ -143,12 +132,15 @@ func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileF err := pm.Exec(ctx, preCmdStr, profile) if err != nil { - cli.Logger.Fatalf("error running pre-command '%s': %v", preCmdStr, err) + return fmt.Errorf("error running pre-command '%s': %w", preCmdStr, err) } } } - fmt.Println(workerStartingView(selectedProfile, devConfig.Reload)) + // Signal that pre-commands are complete + if preCmdsCompleteChan != nil { + preCmdsCompleteChan <- struct{}{} + } proc := pm.NewProcessManager(devConfig.RunCmd, profile) @@ -158,15 +150,17 @@ func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileF <-ctx.Done() <-cleanup } else { - err = proc.StartProcess(ctx) + err := proc.StartProcess(ctx) if err != nil { - cli.Logger.Fatalf("error starting worker: %v", err) + return fmt.Errorf("error starting worker: %w", err) } <-ctx.Done() proc.KillProcess() } + + return nil } // handleNoProfiles prompts the user to either start a local server or add a profile with an API token @@ -287,131 +281,3 @@ func workerStartingView(profile string, reloadEnabled bool) string { return styles.SuccessBox.Render(strings.Join(lines, "\n")) } - -// runScript executes a script from the worker configuration -func runScript(cmd *cobra.Command, scriptFlag string, profileFlag string) { - // Check if scripts are configured - if len(c.Scripts) == 0 { - fmt.Println(styles.H2.Render("No scripts configured")) - fmt.Println() - fmt.Println(styles.Muted.Render("Add scripts to your hatchet.yaml file:")) - fmt.Println() - fmt.Println(`scripts: - - name: "simple" - command: "poetry run simple" - description: "Trigger a simple workflow" - - name: "bulk" - command: "poetry run python -m src.bulk_trigger" - description: "Trigger multiple workflow runs"`) - os.Exit(1) - } - - // Get profile first - var selectedProfile string - if profileFlag != "" { - selectedProfile = profileFlag - } else { - selectedProfile = selectProfileForm(true) - - if selectedProfile == "" { - selectedProfile = handleNoProfiles(cmd) - if selectedProfile == "" { - cli.Logger.Fatal("no profile selected or created") - } - } - } - - profile, err := cli.GetProfile(selectedProfile) - if err != nil { - cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err) - } - - // Then get script - var selectedScript *worker.Script - - // If script flag is provided, find it by name - if scriptFlag != "" { - for i := range c.Scripts { - script := &c.Scripts[i] - scriptName := script.Name - if scriptName == "" { - scriptName = script.Command - } - if scriptName == scriptFlag { - selectedScript = script - break - } - } - - if selectedScript == nil { - cli.Logger.Fatalf("script '%s' not found in hatchet.yaml", scriptFlag) - } - } else { - // Show interactive selection form - selectedScript = selectScriptForm() - if selectedScript == nil { - cli.Logger.Fatal("no script selected") - } - } - - // Display script info - scriptName := selectedScript.Name - if scriptName == "" { - scriptName = selectedScript.Command - } - - fmt.Println(styles.InfoMessage(fmt.Sprintf("Running script: %s", scriptName))) - if selectedScript.Description != "" { - fmt.Println(styles.Muted.Render(selectedScript.Description)) - } - fmt.Println(styles.Muted.Render(fmt.Sprintf("Command: %s", selectedScript.Command))) - fmt.Println() - - // Execute the script - ctx, cancel := cmdutils.NewInterruptContext() - defer cancel() - - err = pm.Exec(ctx, selectedScript.Command, profile) - if err != nil { - cli.Logger.Fatalf("error running script '%s': %v", scriptName, err) - } - - fmt.Println() - fmt.Println(styles.SuccessMessage("Script completed successfully")) -} - -// selectScriptForm displays an interactive form to select a script -func selectScriptForm() *worker.Script { - // Build options from scripts - options := make([]huh.Option[int], 0, len(c.Scripts)) - for i, script := range c.Scripts { - scriptName := script.Name - if scriptName == "" { - scriptName = script.Command - } - - label := scriptName - if script.Description != "" { - label = fmt.Sprintf("%s - %s", scriptName, script.Description) - } - - options = append(options, huh.NewOption(label, i)) - } - - var selectedIndex int - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[int](). - Title("Select a script to run:"). - Options(options...). - Value(&selectedIndex), - ), - ).WithTheme(styles.HatchetTheme()) - - err := form.Run() - if err != nil { - cli.Logger.Fatalf("could not run script selection form: %v", err) - } - - return &c.Scripts[selectedIndex] -} diff --git a/frontend/docs/pages/cli/triggering-workflows.mdx b/frontend/docs/pages/cli/triggering-workflows.mdx index 1761f1132f..83b7e21610 100644 --- a/frontend/docs/pages/cli/triggering-workflows.mdx +++ b/frontend/docs/pages/cli/triggering-workflows.mdx @@ -1,22 +1,22 @@ # Triggering Workflows -You can use the `hatchet worker run` command to trigger workflows locally for testing and development purposes. This command allows you to set up scripts in your `hatchet.yaml` file that define how to run specific workflows. +You can use the `hatchet trigger` command to trigger workflows locally for testing and development purposes. This command allows you to set up triggers in your `hatchet.yaml` file that define how to run specific workflows. ## Example -In your `hatchet.yaml` file, you can define a script for a simple workflow like this: +In your `hatchet.yaml` file, you can define a trigger for a simple workflow like this: ```yaml -scripts: +triggers: - name: "simple" command: "poetry run python src/run.py" description: "Trigger a simple workflow" ``` -Then, you can select this script when running the `hatchet worker run` command: +Then, you can select this trigger when running the `hatchet trigger` command: ```sh -hatchet worker run --script simple +hatchet trigger simple ``` -Or just `hatchet worker run`, which will prompt you to select a script interactively. +Or just `hatchet trigger`, which will prompt you to select a trigger interactively.