From 469a55fb87c5f2406618d82d863c6f85e1efc956 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 12:44:09 -0500 Subject: [PATCH 01/18] update quickstarts and add e2e tests for quickstarts --- .github/workflows/test-templates.yml | 115 +++++++++ .pre-commit-config.yaml | 2 +- cmd/hatchet-cli/.goreleaser.yml | 32 +-- .../cli/internal/templater/templater.go | 48 +++- cmd/hatchet-cli/cli/quickstart.go | 124 ++++++++-- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 223 ++++++++++++++++++ .../cli/templates/python/README.md | 49 ---- .../cli/templates/python/hatchet.yaml | 12 - .../templates/python/pip/POST_QUICKSTART.md | 11 + .../cli/templates/python/pip/requirements.txt | 1 + .../python/{ => poetry}/POST_QUICKSTART.md | 0 .../templates/python/{ => poetry}/poetry.lock | 0 .../python/{ => poetry}/pyproject.toml | 0 .../cli/templates/python/shared/.gitignore | 45 ++++ .../cli/templates/python/shared/README.md | 97 ++++++++ .../templates/python/{ => shared}/__init__.py | 0 .../cli/templates/python/shared/hatchet.yaml | 26 ++ .../python/{ => shared}/src/__init__.py | 0 .../python/{ => shared}/src/hatchet_client.py | 0 .../templates/python/{ => shared}/src/run.py | 0 .../python/{ => shared}/src/worker.py | 0 .../{ => shared}/src/workflows/__init__.py | 0 .../src/workflows/first_workflow.py | 0 .../templates/python/uv/POST_QUICKSTART.md | 9 + .../cli/templates/python/uv/pyproject.toml | 14 ++ .../cli/templates/typescript/Dockerfile | 14 -- .../cli/templates/typescript/bun/package.json | 21 ++ .../cli/templates/typescript/hatchet.yaml | 7 - .../cli/templates/typescript/npm/package.json | 21 ++ .../typescript/{ => pnpm}/package.json | 0 .../typescript/{ => pnpm}/pnpm-lock.yaml | 0 .../templates/typescript/shared/.gitignore | 35 +++ .../templates/typescript/shared/Dockerfile | 45 ++++ .../typescript/{ => shared}/README.md | 38 ++- .../templates/typescript/shared/hatchet.yaml | 18 ++ .../{ => shared}/src/hatchet-client.ts | 0 .../typescript/{ => shared}/src/run.ts | 0 .../typescript/{ => shared}/src/worker.ts | 0 .../src/workflows/first-workflow.ts | 0 .../templates/typescript/shared/tsconfig.json | 113 +++++++++ .../templates/typescript/yarn/package.json | 21 ++ cmd/hatchet-cli/cli/worker.go | 23 +- 42 files changed, 1029 insertions(+), 135 deletions(-) create mode 100644 .github/workflows/test-templates.yml create mode 100644 cmd/hatchet-cli/cli/quickstart_e2e_test.go delete mode 100644 cmd/hatchet-cli/cli/templates/python/README.md delete mode 100644 cmd/hatchet-cli/cli/templates/python/hatchet.yaml create mode 100644 cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md create mode 100644 cmd/hatchet-cli/cli/templates/python/pip/requirements.txt rename cmd/hatchet-cli/cli/templates/python/{ => poetry}/POST_QUICKSTART.md (100%) rename cmd/hatchet-cli/cli/templates/python/{ => poetry}/poetry.lock (100%) rename cmd/hatchet-cli/cli/templates/python/{ => poetry}/pyproject.toml (100%) create mode 100644 cmd/hatchet-cli/cli/templates/python/shared/.gitignore create mode 100644 cmd/hatchet-cli/cli/templates/python/shared/README.md rename cmd/hatchet-cli/cli/templates/python/{ => shared}/__init__.py (100%) create mode 100644 cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/__init__.py (100%) rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/hatchet_client.py (100%) rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/run.py (100%) rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/worker.py (100%) rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/workflows/__init__.py (100%) rename cmd/hatchet-cli/cli/templates/python/{ => shared}/src/workflows/first_workflow.py (100%) create mode 100644 cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md create mode 100644 cmd/hatchet-cli/cli/templates/python/uv/pyproject.toml delete mode 100644 cmd/hatchet-cli/cli/templates/typescript/Dockerfile create mode 100644 cmd/hatchet-cli/cli/templates/typescript/bun/package.json delete mode 100644 cmd/hatchet-cli/cli/templates/typescript/hatchet.yaml create mode 100644 cmd/hatchet-cli/cli/templates/typescript/npm/package.json rename cmd/hatchet-cli/cli/templates/typescript/{ => pnpm}/package.json (100%) rename cmd/hatchet-cli/cli/templates/typescript/{ => pnpm}/pnpm-lock.yaml (100%) create mode 100644 cmd/hatchet-cli/cli/templates/typescript/shared/.gitignore create mode 100644 cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile rename cmd/hatchet-cli/cli/templates/typescript/{ => shared}/README.md (53%) create mode 100644 cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml rename cmd/hatchet-cli/cli/templates/typescript/{ => shared}/src/hatchet-client.ts (100%) rename cmd/hatchet-cli/cli/templates/typescript/{ => shared}/src/run.ts (100%) rename cmd/hatchet-cli/cli/templates/typescript/{ => shared}/src/worker.ts (100%) rename cmd/hatchet-cli/cli/templates/typescript/{ => shared}/src/workflows/first-workflow.ts (100%) create mode 100644 cmd/hatchet-cli/cli/templates/typescript/shared/tsconfig.json create mode 100644 cmd/hatchet-cli/cli/templates/typescript/yarn/package.json diff --git a/.github/workflows/test-templates.yml b/.github/workflows/test-templates.yml new file mode 100644 index 0000000000..08fb450243 --- /dev/null +++ b/.github/workflows/test-templates.yml @@ -0,0 +1,115 @@ +name: Template 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: + name: Test Template Generation + runs-on: ubuntu-latest + timeout-minutes: 45 + + 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 in background..." + nohup hatchet server start --profile local --dashboard-port 8080 > hatchet-server.log 2>&1 & + echo $! > hatchet-server.pid + + # Wait for server to be ready (up to 2 minutes) + echo "Waiting for Hatchet server to be ready..." + timeout=120 + elapsed=0 + until curl -s http://localhost:8080/api/v1/health > /dev/null 2>&1; do + if [ $elapsed -ge $timeout ]; then + echo "Timeout waiting for server to start" + cat hatchet-server.log + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) + echo "Waiting... ($elapsed seconds)" + done + + 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 40m + env: + # Ensure tests don't try to interact with stdin + CI: true + + - name: Show server logs on failure + if: failure() + run: | + echo "=== Hatchet Server Logs ===" + cat hatchet-server.log || echo "No server logs found" + + - name: Cleanup + if: always() + run: | + if [ -f hatchet-server.pid ]; then + kill $(cat hatchet-server.pid) || true + fi + # Cleanup any docker containers started by hatchet server + docker ps -q --filter "label=hatchet" | xargs -r docker stop || 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/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/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/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..5f2c89ecf0 --- /dev/null +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -0,0 +1,223 @@ +//go:build e2e_cli + +package cli + +import ( + "context" + "fmt" + "os" + "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) + } + + 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 + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + // Start the worker process using the CLI implementation in a goroutine + errChan := make(chan error, 1) + 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); err != nil { + errChan <- fmt.Errorf("worker process failed: %w", err) + return + } + errChan <- nil + }() + + // Wait for 15 seconds, then cancel the context + time.Sleep(15 * 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 for 15+ seconds") + 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", + } + + 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 +} 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..988e2da963 --- /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 worker run --script 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 100% rename from cmd/hatchet-cli/cli/templates/python/POST_QUICKSTART.md rename to cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md 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/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..71eb026f92 --- /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 +scripts: + - 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..45b360fd61 --- /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 worker run --script 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..80aaa44eba --- /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 yarn.lock ./ +RUN yarn install --frozen-lockfile + +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 bun.lockb ./ +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..a171ea0288 --- /dev/null +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml @@ -0,0 +1,18 @@ +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 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/worker.go b/cmd/hatchet-cli/cli/worker.go index 44024ddbfa..304a38c079 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 @@ -136,6 +138,17 @@ 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); 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. +func RunWorkerDev(ctx context.Context, profile *profileconfig.Profile, devConfig *worker.WorkerDevConfig) 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,13 +156,11 @@ 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)) - proc := pm.NewProcessManager(devConfig.RunCmd, profile) if devConfig.Reload { @@ -158,15 +169,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 From 9d08da4a03046216ebd429939ae1304c56bd18c0 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 12:50:01 -0500 Subject: [PATCH 02/18] remove healthcheck --- .github/workflows/test-templates.yml | 39 +++++++++------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test-templates.yml b/.github/workflows/test-templates.yml index 08fb450243..bab09b8b96 100644 --- a/.github/workflows/test-templates.yml +++ b/.github/workflows/test-templates.yml @@ -3,19 +3,19 @@ name: Template 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' + - "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' + - "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: @@ -30,17 +30,17 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: "1.25" - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install Poetry run: pipx install poetry @@ -70,21 +70,6 @@ jobs: nohup hatchet server start --profile local --dashboard-port 8080 > hatchet-server.log 2>&1 & echo $! > hatchet-server.pid - # Wait for server to be ready (up to 2 minutes) - echo "Waiting for Hatchet server to be ready..." - timeout=120 - elapsed=0 - until curl -s http://localhost:8080/api/v1/health > /dev/null 2>&1; do - if [ $elapsed -ge $timeout ]; then - echo "Timeout waiting for server to start" - cat hatchet-server.log - exit 1 - fi - sleep 2 - elapsed=$((elapsed + 2)) - echo "Waiting... ($elapsed seconds)" - done - echo "Hatchet server is ready!" - name: Verify local profile was created From a78e24342e73839c56dd0eb0deddb7622309b3f9 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 12:57:01 -0500 Subject: [PATCH 03/18] simplify workflow --- .github/workflows/test-templates.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-templates.yml b/.github/workflows/test-templates.yml index bab09b8b96..da5cb5fe5b 100644 --- a/.github/workflows/test-templates.yml +++ b/.github/workflows/test-templates.yml @@ -66,9 +66,8 @@ jobs: - name: Start Hatchet server run: | - echo "Starting Hatchet server in background..." - nohup hatchet server start --profile local --dashboard-port 8080 > hatchet-server.log 2>&1 & - echo $! > hatchet-server.pid + echo "Starting Hatchet server (this will wait until healthy)..." + hatchet server start --profile local --dashboard-port 8080 echo "Hatchet server is ready!" @@ -87,14 +86,21 @@ jobs: - name: Show server logs on failure if: failure() run: | - echo "=== Hatchet Server Logs ===" - cat hatchet-server.log || echo "No server logs found" + 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: | - if [ -f hatchet-server.pid ]; then - kill $(cat hatchet-server.pid) || true - fi - # Cleanup any docker containers started by hatchet server - docker ps -q --filter "label=hatchet" | xargs -r docker stop || true + # 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 From 8daba6b4dbf3ae7e50fdd023e61f2662f1cb10cc Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 13:04:39 -0500 Subject: [PATCH 04/18] fix: non-tty environments for image pull --- .../cli/internal/drivers/docker/image_pull_progress.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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..a1dc9cd316 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 @@ -175,7 +175,12 @@ 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: still consume the reader + // to ensure the image pull completes before returning + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + // Just consume the stream, no need to parse + } fmt.Fprintf(os.Stderr, "Pulled image %s\n", imageName) } } From 956371b5b32b8487939f460922ede29e308cde95 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 13:49:22 -0500 Subject: [PATCH 05/18] increase timeout --- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go index 5f2c89ecf0..675776d54e 100644 --- a/cmd/hatchet-cli/cli/quickstart_e2e_test.go +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -101,7 +101,8 @@ func testTemplate(t *testing.T, language, packageManager string) { func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *profileconfig.Profile) error { // Create a context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + // Timeout needs to cover: dependency installation (1-3 min) + worker run time (15 sec) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() // Start the worker process using the CLI implementation in a goroutine From 9d30b7da9ca3cc004f3d9274491a73d7470bf67a Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 14:05:34 -0500 Subject: [PATCH 06/18] increase timeout for preCmds, keep timeout for cmd --- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 27 +++++++++++++++++----- cmd/hatchet-cli/cli/worker.go | 10 ++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go index 675776d54e..d5ebaf484f 100644 --- a/cmd/hatchet-cli/cli/quickstart_e2e_test.go +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -100,13 +100,15 @@ func testTemplate(t *testing.T, language, packageManager string) { } func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *profileconfig.Profile) error { - // Create a context with timeout - // Timeout needs to cover: dependency installation (1-3 min) + worker run time (15 sec) - ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + // Create a context with timeout (safety net) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // Start the worker process using the CLI implementation in a goroutine + // 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 @@ -114,14 +116,27 @@ func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *pro testDevConfig := workerConfig.Dev testDevConfig.Reload = false - if err := RunWorkerDev(ctx, profile, &testDevConfig); err != nil { + if err := RunWorkerDev(ctx, profile, &testDevConfig, preCmdsComplete); err != nil { errChan <- fmt.Errorf("worker process failed: %w", err) return } errChan <- nil }() - // Wait for 15 seconds, then cancel the context + // 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") + } + + // Now wait 15 seconds for the worker to run time.Sleep(15 * time.Second) cancel() diff --git a/cmd/hatchet-cli/cli/worker.go b/cmd/hatchet-cli/cli/worker.go index 304a38c079..3c196e36fa 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -140,14 +140,15 @@ func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileF fmt.Println(workerStartingView(selectedProfile, devConfig.Reload)) - if err := RunWorkerDev(ctx, profile, devConfig); err != nil { + 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. -func RunWorkerDev(ctx context.Context, profile *profileconfig.Profile, devConfig *worker.WorkerDevConfig) error { +// 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 { @@ -161,6 +162,11 @@ func RunWorkerDev(ctx context.Context, profile *profileconfig.Profile, devConfig } } + // Signal that pre-commands are complete + if preCmdsCompleteChan != nil { + preCmdsCompleteChan <- struct{}{} + } + proc := pm.NewProcessManager(devConfig.RunCmd, profile) if devConfig.Reload { From 8b743a4c105fb22707b9e6c4c44bcb1d5f9b57ad Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 14:09:11 -0500 Subject: [PATCH 07/18] update tls probe to auto-detect tls --- cmd/hatchet-cli/cli/profile.go | 110 +++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 25 deletions(-) diff --git a/cmd/hatchet-cli/cli/profile.go b/cmd/hatchet-cli/cli/profile.go index 4aa507beae..1b3ea61e95 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,94 @@ 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 } - 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) { + // Server sent non-TLS data, so it doesn't use TLS + // Verify the endpoint is actually reachable with a plain TCP connection + plainConn, plainErr := dialer.DialContext(ctx, "tcp", hostPort) + if plainErr == nil { + plainConn.Close() + return "none", nil + } + return "", fmt.Errorf("endpoint not reachable: %w", plainErr) + } + + // Check for EOF, which commonly occurs when connecting to a non-TLS server with TLS + if errors.Is(err, io.EOF) { + // Verify with a plain TCP connection + plainConn, plainErr := dialer.DialContext(ctx, "tcp", hostPort) + if plainErr == nil { + plainConn.Close() + return "none", nil + } + return "", fmt.Errorf("endpoint not reachable: %w", plainErr) + } + + // 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 { From 96766082a0ef5e5187575bd781128d1e17deb082 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 14:33:33 -0500 Subject: [PATCH 08/18] fix image pull race condition --- .../drivers/docker/image_pull_progress.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 a1dc9cd316..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,13 +179,13 @@ func displayImagePullProgress(reader io.Reader, imageName string) { // Run the program (this blocks until done) if _, err := p.Run(); err != nil { - // Fallback for non-TTY environments: still consume the reader - // to ensure the image pull completes before returning - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - // Just consume the stream, no need to parse - } + // 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 } } From 83dac20437c14b443fc3d887eab8dcd64a62edf7 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 14:55:21 -0500 Subject: [PATCH 09/18] dockerfiles and builds --- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 40 ++++++++++++++++ cmd/hatchet-cli/cli/templates/go/Dockerfile | 24 ++++++++++ .../cli/templates/python/shared/Dockerfile | 46 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 cmd/hatchet-cli/cli/templates/go/Dockerfile create mode 100644 cmd/hatchet-cli/cli/templates/python/shared/Dockerfile diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go index d5ebaf484f..75a34352ad 100644 --- a/cmd/hatchet-cli/cli/quickstart_e2e_test.go +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "testing" "time" @@ -96,6 +97,12 @@ func testTemplate(t *testing.T, language, packageManager string) { 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) } @@ -162,6 +169,7 @@ func verifyProjectStructure(t *testing.T, projectDir, language, packageManager s commonFiles := []string{ "README.md", "hatchet.yaml", + "Dockerfile", } for _, file := range commonFiles { @@ -237,3 +245,35 @@ func verifyProjectStructure(t *testing.T, projectDir, language, packageManager s 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 +} 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/python/shared/Dockerfile b/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile new file mode 100644 index 0000000000..ddc9682fb5 --- /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 --no-dev + +# 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}} From 7630daec62a44d7c0036372cab4f48945c1f9fad Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 15:55:33 -0500 Subject: [PATCH 10/18] fix: dockerfile for bun/yarn --- cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile b/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile index 80aaa44eba..7b62540e0d 100644 --- a/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/Dockerfile @@ -24,8 +24,8 @@ RUN pnpm run build CMD ["pnpm", "start"] {{- else if eq .PackageManager "yarn"}} -COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile +COPY package.json ./ +RUN yarn install COPY src ./src COPY tsconfig.json ./ @@ -34,7 +34,7 @@ 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 bun.lockb ./ +COPY package.json ./ RUN bun install COPY src ./src From a98c435f7d4c98a6a200aeacdf8b8c43b13a1058 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 15:59:04 -0500 Subject: [PATCH 11/18] rename scripts to triggers, add manual trigger option --- cmd/hatchet-cli/cli/client.go | 30 + .../cli/internal/config/worker/config.go | 6 +- .../templates/python/pip/POST_QUICKSTART.md | 2 +- .../python/poetry/POST_QUICKSTART.md | 2 +- .../cli/templates/python/shared/hatchet.yaml | 2 +- .../templates/python/uv/POST_QUICKSTART.md | 2 +- cmd/hatchet-cli/cli/trigger.go | 673 ++++++++++++++++++ cmd/hatchet-cli/cli/tui.go | 31 +- cmd/hatchet-cli/cli/worker.go | 153 ---- .../docs/pages/cli/triggering-workflows.mdx | 12 +- 10 files changed, 736 insertions(+), 177 deletions(-) create mode 100644 cmd/hatchet-cli/cli/client.go create mode 100644 cmd/hatchet-cli/cli/trigger.go 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/templates/python/pip/POST_QUICKSTART.md b/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md index 988e2da963..cb73cd95ba 100644 --- a/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md +++ b/cmd/hatchet-cli/cli/templates/python/pip/POST_QUICKSTART.md @@ -1,6 +1,6 @@ Trigger a workflow (in another terminal): ```sh -hatchet worker run --script simple +hatchet trigger simple ``` **Notes:** diff --git a/cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md b/cmd/hatchet-cli/cli/templates/python/poetry/POST_QUICKSTART.md index a33938dd3c..67f6e04072 100644 --- a/cmd/hatchet-cli/cli/templates/python/poetry/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/shared/hatchet.yaml b/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml index 71eb026f92..bdebcb4156 100644 --- a/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml +++ b/cmd/hatchet-cli/cli/templates/python/shared/hatchet.yaml @@ -14,7 +14,7 @@ dev: - "!**/__pycache__/**" - "!**/.venv/**" reload: true -scripts: +triggers: - name: "simple" {{- if eq .PackageManager "poetry"}} command: "poetry run python src/run.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 index 45b360fd61..56f49ef89b 100644 --- a/cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md +++ b/cmd/hatchet-cli/cli/templates/python/uv/POST_QUICKSTART.md @@ -1,6 +1,6 @@ Trigger a workflow (in another terminal): ```sh -hatchet worker run --script simple +hatchet trigger simple ``` **Notes:** 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 3c196e36fa..9a1bbb23d7 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -75,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) { @@ -306,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. From dd2371f0bc6901949ce5306bdf3bef367a2a0b8d Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:05:02 -0500 Subject: [PATCH 12/18] fix: poetry dockerfile --- cmd/hatchet-cli/cli/templates/python/shared/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile b/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile index ddc9682fb5..60cc8878ed 100644 --- a/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile +++ b/cmd/hatchet-cli/cli/templates/python/shared/Dockerfile @@ -10,7 +10,7 @@ RUN pip install poetry COPY pyproject.toml poetry.lock ./ # Install dependencies -RUN poetry config virtualenvs.create false && poetry install --no-root --no-dev +RUN poetry config virtualenvs.create false && poetry install --no-root --only main # Copy source code COPY src ./src From 52a850403b43827e1269abd3b49057fadb00611e Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:29:34 -0500 Subject: [PATCH 13/18] fix: self-review comments --- .../{test-templates.yml => cli-e2e-tests.yml} | 5 ++--- cmd/hatchet-cli/cli/profile.go | 20 ++++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) rename .github/workflows/{test-templates.yml => cli-e2e-tests.yml} (97%) diff --git a/.github/workflows/test-templates.yml b/.github/workflows/cli-e2e-tests.yml similarity index 97% rename from .github/workflows/test-templates.yml rename to .github/workflows/cli-e2e-tests.yml index da5cb5fe5b..6025c883f6 100644 --- a/.github/workflows/test-templates.yml +++ b/.github/workflows/cli-e2e-tests.yml @@ -19,9 +19,8 @@ on: jobs: test-templates: - name: Test Template Generation runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 15 steps: - name: Checkout code @@ -78,7 +77,7 @@ jobs: - name: Run template E2E tests run: | cd cmd/hatchet-cli/cli - go test -tags e2e_cli -run TestQuickstartTemplates -v -timeout 40m + go test -tags e2e_cli -run TestQuickstartTemplates -v -timeout 15m env: # Ensure tests don't try to interact with stdin CI: true diff --git a/cmd/hatchet-cli/cli/profile.go b/cmd/hatchet-cli/cli/profile.go index 1b3ea61e95..66f102513a 100644 --- a/cmd/hatchet-cli/cli/profile.go +++ b/cmd/hatchet-cli/cli/profile.go @@ -550,11 +550,7 @@ func probeTLSEndpoint(hostPort string) (string, error) { return "tls", nil } - // Check if the error is a RecordHeaderError - this means the server sent non-TLS data - var recordHeaderErr tls.RecordHeaderError - if errors.As(err, &recordHeaderErr) { - // Server sent non-TLS data, so it doesn't use TLS - // Verify the endpoint is actually reachable with a plain TCP connection + dialNoTLS := func() (string, error) { plainConn, plainErr := dialer.DialContext(ctx, "tcp", hostPort) if plainErr == nil { plainConn.Close() @@ -563,15 +559,15 @@ func probeTLSEndpoint(hostPort string) (string, error) { return "", fmt.Errorf("endpoint not reachable: %w", plainErr) } + // 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) { - // Verify with a plain TCP connection - plainConn, plainErr := dialer.DialContext(ctx, "tcp", hostPort) - if plainErr == nil { - plainConn.Close() - return "none", nil - } - return "", fmt.Errorf("endpoint not reachable: %w", plainErr) + return dialNoTLS() } // If it's neither a RecordHeaderError nor EOF, it's likely a connection error From 0e6bbf356aada4e7140cb809a63e8ab83d1a4187 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:40:24 -0500 Subject: [PATCH 14/18] update readme to include CLI --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 47bc6b99d7..3e78ba9333 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,19 @@ ### 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 out the experience. 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. ### When should I use Hatchet? @@ -650,13 +662,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. From 068a204d099e2bb5cc92ae192e29fa436e5c15c1 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:43:47 -0500 Subject: [PATCH 15/18] minor readme fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e78ba9333..332b77342e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ hatchet --version hatchet server start ``` -You can also sign up on [Hatchet Cloud](https://cloud.onhatchet.run) to try out the experience. 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. +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. ### When should I use Hatchet? From 12fecffc6f81242f8bb1c9e0e3df707c77c9969b Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:47:17 -0500 Subject: [PATCH 16/18] minor rename --- .github/workflows/cli-e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli-e2e-tests.yml b/.github/workflows/cli-e2e-tests.yml index 6025c883f6..2cd27f39e9 100644 --- a/.github/workflows/cli-e2e-tests.yml +++ b/.github/workflows/cli-e2e-tests.yml @@ -1,4 +1,4 @@ -name: Template E2E Tests +name: cli-e2e-tests on: pull_request: From b15bb40cf732d386355b77e02a789575ff1b7146 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 16:48:09 -0500 Subject: [PATCH 17/18] docs link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 332b77342e..41fc43e5d1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ 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? Background tasks are critical for offloading work from your main web application. Usually background tasks are sent through a FIFO (first-in-first-out) queue, which helps guard against traffic spikes (queues can absorb a lot of load) and ensures that tasks are retried when your task handlers error out. Most stacks begin with a library-based queue backed by Redis or RabbitMQ (like Celery or BullMQ). But as your tasks become more complex, these queues become difficult to debug, monitor and start to fail in unexpected ways. From 5894c16f7c571ed237947cfa3b3ee293797e2190 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 15 Jan 2026 17:12:30 -0500 Subject: [PATCH 18/18] add triggers to e2e tests, simple trigger for go + typescript --- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 53 +++++++++++++++++-- cmd/hatchet-cli/cli/templates/go/hatchet.yaml | 5 ++ .../templates/typescript/shared/hatchet.yaml | 13 +++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go index 75a34352ad..76fe3820c6 100644 --- a/cmd/hatchet-cli/cli/quickstart_e2e_test.go +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -143,8 +143,33 @@ func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *pro return fmt.Errorf("timeout waiting for pre-commands to complete") } - // Now wait 15 seconds for the worker to run - time.Sleep(15 * time.Second) + // 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 @@ -158,7 +183,7 @@ func testWorkerDev(t *testing.T, workerConfig *worker.WorkerConfig, profile *pro t.Log("Worker process cleanup timeout - continuing anyway") } - t.Log("Worker ran successfully for 15+ seconds") + t.Log("Worker ran successfully and workflow was triggered") return nil } @@ -277,3 +302,25 @@ func testDockerfileBuild(t *testing.T, projectDir, language, packageManager stri 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/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/typescript/shared/hatchet.yaml b/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml index a171ea0288..09b53dd189 100644 --- a/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml +++ b/cmd/hatchet-cli/cli/templates/typescript/shared/hatchet.yaml @@ -16,3 +16,16 @@ dev: - "**/*.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}}