From 8cbdbc63ac0d83e2ce8beecbf95bf7ba3e340749 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Fri, 29 May 2026 09:09:39 -0600 Subject: [PATCH 1/8] =?UTF-8?q?Revert=20"Revert=20"Correctly=20use=20local?= =?UTF-8?q?=20template=20for=20tasks=20if=20available=20during=20`d?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ebf3ef240691dabd466e1e05d0cb66daf56596a4. --- cmd/task/compose/cmd.go | 40 +------------------------------------- cmd/task/run/cmd.go | 8 +++++++- internal/task/discovery.go | 29 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/cmd/task/compose/cmd.go b/cmd/task/compose/cmd.go index c2754fdcf..2d72f6434 100644 --- a/cmd/task/compose/cmd.go +++ b/cmd/task/compose/cmd.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" "github.com/datarobot/cli/internal/cli" @@ -37,7 +36,7 @@ var templatePath string func RunE(_ *cobra.Command, _ []string) error { taskfileName, ignoreTaskfile := detectExistingTaskfile() - discovery, err := createDiscovery(taskfileName) + discovery, err := task.NewDiscovery(taskfileName, templatePath) if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) @@ -89,43 +88,6 @@ func RunE(_ *cobra.Command, _ []string) error { return nil } -func createDiscovery(taskfileName string) (*task.Discovery, error) { - // Check for .Taskfile.template in the root directory if no template specified - autoTemplatePath := ".Taskfile.template" - - if templatePath == "" { - if _, err := os.Stat(autoTemplatePath); err == nil { - templatePath = autoTemplatePath - fmt.Printf("Using auto-discovered template: %s\n", autoTemplatePath) - } - } - - // If template is specified or found, use compose mode - if templatePath != "" { - absPath, err := validateTemplatePath(templatePath) - if err != nil { - return nil, fmt.Errorf("invalid template: %w", err) - } - - return task.NewComposeDiscovery(taskfileName, absPath), nil - } - - return task.NewTaskDiscovery(taskfileName), nil -} - -func validateTemplatePath(path string) (string, error) { - absPath, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("resolving template path: %w", err) - } - - if _, err := os.Stat(absPath); os.IsNotExist(err) { - return "", fmt.Errorf("template file not found: %s", absPath) - } - - return absPath, nil -} - // detectExistingTaskfile checks for existing Taskfile.yaml or Taskfile.yml // and returns the name of the existing one, or defaults to Taskfile.yaml func detectExistingTaskfile() (inUse, notInUse string) { diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index f165c6da9..40f84d508 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -97,7 +97,13 @@ Examples: SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { binaryName := "task" - discovery := task.NewTaskDiscovery("Taskfile.gen.yaml") + + discovery, err := task.NewDiscovery("Taskfile.gen.yaml", "") + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + + return cli.ErrSilent + } rootTaskfile, err := discovery.Discover(opts.Dir, 2) if err != nil { diff --git a/internal/task/discovery.go b/internal/task/discovery.go index 7e59d695b..7008016c0 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -112,6 +112,27 @@ func NewComposeDiscovery(rootTaskfileName string, templatePath string) *Discover } } +// NewDiscovery creates the appropriate Discovery for the given taskfile name. +// If templatePath is non-empty it is resolved to an absolute path and used as +// the custom template. If templatePath is empty, Discover will automatically +// check for a ".Taskfile.template" file in the project root at runtime. +func NewDiscovery(taskfileName, templatePath string) (*Discovery, error) { + if templatePath == "" { + return NewTaskDiscovery(taskfileName), nil + } + + absPath, err := filepath.Abs(templatePath) + if err != nil { + return nil, fmt.Errorf("resolving template path: %w", err) + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return nil, fmt.Errorf("template file not found: %s", absPath) + } + + return NewComposeDiscovery(taskfileName, absPath), nil +} + func (d *Discovery) Discover(root string, maxDepth int) (string, error) { // Check if .env file exists in the root directory envPath := filepath.Join(root, ".datarobot") @@ -135,6 +156,14 @@ func (d *Discovery) Discover(root string, maxDepth int) (string, error) { rootTaskfilePath := filepath.Join(root, d.RootTaskfileName) + // Auto-detect .Taskfile.template in the project root when no explicit template is configured + if d.TemplatePath == "" { + candidate := filepath.Join(root, ".Taskfile.template") + if _, statErr := os.Stat(candidate); statErr == nil { + d.TemplatePath = candidate + } + } + composeData, err := d.buildComposeData(root, includes) if err != nil { return "", fmt.Errorf("failed to build compose data: %w", err) From 3c61ae202cccd61d8236ecb0c14b068b90427e70 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Mon, 1 Jun 2026 21:05:39 -0600 Subject: [PATCH 2/8] Switched to prefer root task file to prevent infinite recursion --- cmd/task/run/cmd.go | 34 +++- cmd/task/run/cmd_test.go | 180 ++++++++++++++++++ .../run/testdata/recipe/.Taskfile.template | 122 ++++++++++++ cmd/task/run/testdata/recipe/Taskfile.yml | 141 ++++++++++++++ 4 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 cmd/task/run/cmd_test.go create mode 100644 cmd/task/run/testdata/recipe/.Taskfile.template create mode 100644 cmd/task/run/testdata/recipe/Taskfile.yml diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index 40f84d508..96c16f6a9 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -68,6 +68,23 @@ func splitTaskArgs(args []string) (taskNames []string, taskArgs []string) { return taskNames, taskArgs } +func rootTaskfilePath(dir string) (string, error) { + for _, name := range []string{"Taskfile.yaml", "Taskfile.yml"} { + path := filepath.Join(dir, name) + + _, err := os.Stat(path) + if err == nil { + return path, nil + } + + if !os.IsNotExist(err) { + return "", err + } + } + + return "", os.ErrNotExist +} + func Cmd() *cobra.Command { var opts taskRunOptions @@ -97,15 +114,20 @@ Examples: SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { binaryName := "task" + taskNames, taskArgs := splitTaskArgs(args) - discovery, err := task.NewDiscovery("Taskfile.gen.yaml", "") - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) + rootTaskfile, err := rootTaskfilePath(opts.Dir) + if os.IsNotExist(err) { + discovery, discoveryErr := task.NewDiscovery("Taskfile.gen.yaml", "") + if discoveryErr != nil { + _, _ = fmt.Fprintln(os.Stderr, discoveryErr) - return cli.ErrSilent + return cli.ErrSilent + } + + rootTaskfile, err = discovery.Discover(opts.Dir, 2) } - rootTaskfile, err := discovery.Discover(opts.Dir, 2) if err != nil { _, _ = fmt.Fprintln(os.Stderr, task.FormatDiscoveryError(err)) @@ -135,8 +157,6 @@ Examples: return cli.ErrSilent } - taskNames, taskArgs := splitTaskArgs(args) - if !opts.taskOpts.Silent { log.Printf("Running task(s): %s\n", strings.Join(taskNames, ", ")) } diff --git a/cmd/task/run/cmd_test.go b/cmd/task/run/cmd_test.go new file mode 100644 index 000000000..a2936ebc1 --- /dev/null +++ b/cmd/task/run/cmd_test.go @@ -0,0 +1,180 @@ +package run + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + rootTaskfileYAML = "Taskfile.yaml" + rootTaskfileYML = "Taskfile.yml" + generatedTaskfileYML = "Taskfile.gen.yaml" +) + +func TestCmdPrefersRecipeRootTaskfileWhenPresent(t *testing.T) { + recipeDir := t.TempDir() + writeRecipeFixture(t, recipeDir, true) + + logFile := filepath.Join(t.TempDir(), "task.log") + writeFakeTaskBinary(t, logFile) + + cmd := Cmd() + cmd.SetArgs([]string{"--dir", recipeDir, "start"}) + + require.NoError(t, cmd.Execute()) + + logs := readTaskLog(t, logFile) + require.Contains(t, logs, "-t "+filepath.Join(recipeDir, rootTaskfileYML)+" -C 2 start") + + _, err := os.Stat(filepath.Join(recipeDir, generatedTaskfileYML)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCmdUsesRecipeTemplateWhenRootTaskfileIsMissing(t *testing.T) { + recipeDir := t.TempDir() + writeRecipeFixture(t, recipeDir, false) + + logFile := filepath.Join(t.TempDir(), "task.log") + writeFakeTaskBinary(t, logFile) + + cmd := Cmd() + cmd.SetArgs([]string{"--dir", recipeDir, "dev"}) + + require.NoError(t, cmd.Execute()) + + logs := readTaskLog(t, logFile) + require.Contains(t, logs, "-t "+filepath.Join(recipeDir, generatedTaskfileYML)+" -C 2 dev") + + generated := readTextFile(t, filepath.Join(recipeDir, generatedTaskfileYML)) + require.Contains(t, generated, "build-agents-md") + require.Contains(t, generated, "drdev") +} + +func writeRecipeFixture(t *testing.T, recipeDir string, includeRootTaskfile bool) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Join(recipeDir, ".datarobot", "answers"), 0o755)) + copyTestFixture(t, filepath.Join("recipe", ".Taskfile.template"), filepath.Join(recipeDir, ".Taskfile.template")) + + if includeRootTaskfile { + copyTestFixture(t, filepath.Join("recipe", rootTaskfileYML), filepath.Join(recipeDir, rootTaskfileYML)) + } + + writeComponentTaskfile(t, filepath.Join(recipeDir, "agent"), rootTaskfileYML) + writeComponentTaskfile(t, filepath.Join(recipeDir, "fastapi_server"), rootTaskfileYAML) + writeComponentTaskfile(t, filepath.Join(recipeDir, "infra"), rootTaskfileYAML) +} + +func copyTestFixture(t *testing.T, fixturePath string, destination string) { + t.Helper() + + contents := readTextFile(t, filepath.Join("testdata", fixturePath)) + require.NoError(t, os.WriteFile(destination, []byte(contents), 0o644)) +} + +func writeComponentTaskfile(t *testing.T, componentDir string, filename string) { + t.Helper() + + require.NoError(t, os.MkdirAll(componentDir, 0o755)) + + contents := strings.Join([]string{ + "version: '3'", + "tasks:", + " start:", + " desc: Start component", + " cmds:", + " - echo start", + " lint:", + " desc: Lint component", + " cmds:", + " - echo lint", + " install:", + " desc: Install component", + " cmds:", + " - echo install", + " test:", + " desc: Test component", + " cmds:", + " - echo test", + " dev:", + " desc: Dev component", + " cmds:", + " - echo dev", + " deploy-dev:", + " aliases: [up-dev]", + " desc: Deploy dev component", + " cmds:", + " - echo deploy-dev", + "", + }, "\n") + + require.NoError(t, os.WriteFile(filepath.Join(componentDir, filename), []byte(contents), 0o644)) +} + +func writeFakeTaskBinary(t *testing.T, logFile string) { + t.Helper() + + binDir := t.TempDir() + t.Setenv("TASK_LOG", logFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + if runtime.GOOS == "windows" { + writeFakeWindowsTaskBinary(t, binDir) + + return + } + + writeFakeUnixTaskBinary(t, binDir) +} + +func writeFakeUnixTaskBinary(t *testing.T, binDir string) { + t.Helper() + + script := `#!/bin/sh +if [ "$1" = "--list" ]; then + cat <<'JSON' +{"tasks":[{"name":"start","desc":"Start component"},{"name":"lint","desc":"Lint component"},{"name":"install","desc":"Install component"},{"name":"test","desc":"Test component"},{"name":"dev","desc":"Dev component"},{"name":"deploy-dev","desc":"Deploy dev component","aliases":["up-dev"]}]} +JSON + exit 0 +fi + +printf '%s|%s\n' "$PWD" "$*" >> "$TASK_LOG" +` + + require.NoError(t, os.WriteFile(filepath.Join(binDir, "task"), []byte(script), 0o755)) +} + +func writeFakeWindowsTaskBinary(t *testing.T, binDir string) { + t.Helper() + + script := `@echo off +if "%1"=="--list" ( + echo {"tasks":[{"name":"start","desc":"Start component"},{"name":"lint","desc":"Lint component"},{"name":"install","desc":"Install component"},{"name":"test","desc":"Test component"},{"name":"dev","desc":"Dev component"},{"name":"deploy-dev","desc":"Deploy dev component","aliases":["up-dev"]}]} + exit /b 0 +) +echo %CD%^|%*>>"%TASK_LOG%" +exit /b 0 +` + + require.NoError(t, os.WriteFile(filepath.Join(binDir, "task.bat"), []byte(script), 0o755)) +} + +func readTaskLog(t *testing.T, logFile string) string { + t.Helper() + + return readTextFile(t, logFile) +} + +func readTextFile(t *testing.T, path string) string { + t.Helper() + + contents, err := os.ReadFile(path) + require.NoError(t, err) + + return string(contents) +} diff --git a/cmd/task/run/testdata/recipe/.Taskfile.template b/cmd/task/run/testdata/recipe/.Taskfile.template new file mode 100644 index 000000000..2d1da10a6 --- /dev/null +++ b/cmd/task/run/testdata/recipe/.Taskfile.template @@ -0,0 +1,122 @@ +--- +# File generated by DataRobot CLI, regenerate using `dr task compose` +version: '3' +env: + ENV: testing +dotenv: ['.env', '.env.{{`{{.ENV}}`}}'] + +includes: + common: + taskfile: ./core/task-common.yaml + internal: true + {{- range .Includes }} + {{- if ne .Name "tests" }} + {{ .Name }}: + taskfile: {{ .Taskfile }} + dir: {{ .Dir }} + {{- end }} + {{- end }} + +tasks: + default: + desc: "โ„น๏ธ Show all available tasks (run `task --list-all` to see hidden tasks)" + silent: true + cmds: + - task --list --sort none + + start-non-interactive: + cmds: + - task: start + + start: + desc: "๐Ÿ’ป Prepare local development environment" + cmds: + - dr self update + - task build-agents-md + - dr task compose + - dr dotenv setup --if-needed + - dr dotenv update + - task install + - dr task run start + - echo 'โœ… You are all set. Run `task dev` to start developing, or run `task deploy` to deploy to DataRobot.' + build-agents-md: + cmds: + - cmd: | + cp AGENTS.md.template AGENTS.md + [ -f agent/AGENTS.md ] && cat agent/AGENTS.md >> AGENTS.md + [ -f mcp_server/AGENTS.md ] && cat mcp_server/AGENTS.md >> AGENTS.md + [ -f fastapi_server/AGENTS.md ] && cat fastapi_server/AGENTS.md >> AGENTS.md + [ -f frontend_web/AGENTS.md ] && cat frontend_web/AGENTS.md >> AGENTS.md + [ -f infra/AGENTS.md ] && cat infra/AGENTS.md >> AGENTS.md + true + platforms: [linux, darwin] + - cmd: powershell -Command "Copy-Item 'AGENTS.md.template' 'AGENTS.md'; if (Test-Path 'agent\AGENTS.md') { Get-Content + 'agent\AGENTS.md' | Add-Content 'AGENTS.md' }; if (Test-Path 'mcp_server\AGENTS.md') { Get-Content 'mcp_server\AGENTS.md' + | Add-Content 'AGENTS.md' }; if (Test-Path 'fastapi_server\AGENTS.md') { Get-Content 'fastapi_server\AGENTS.md' + | Add-Content 'AGENTS.md' }; if (Test-Path 'frontend_web\AGENTS.md') { Get-Content 'frontend_web\AGENTS.md' | Add-Content + 'AGENTS.md' }; if (Test-Path 'infra\AGENTS.md') { Get-Content 'infra\AGENTS.md' | Add-Content 'AGENTS.md' }" + platforms: [windows] + + {{- if .HasLint }} + lint: + desc: "๐Ÿงน Run linters" + cmds: + - dr task run lint + {{- end }} + + {{- if .HasInstall }} + install: + desc: "๐Ÿ› ๏ธ Install all dependencies" + cmds: + - | + if [ -n "$VIRTUAL_ENV" ]; then + echo "Installing datarobot_early_access[core] into active virtualenv: $VIRTUAL_ENV" + uv pip install "datarobot_early_access[core]>=3.13.0.2026.2.2.173832" + else + uv tool update-shell + echo "Installing datarobot_early_access[core] as a uv tool" + uv tool install --force "datarobot_early_access[core]>=3.13.0.2026.2.2.173832" + fi + - dr task run install + {{- end }} + + {{- if .HasTest }} + test: + desc: "๐Ÿงช Run tests across all components" + cmds: + - dr task run test + {{- end }} + + auth-check: + silent: true + desc: "๐Ÿ” Check authentication prerequisites" + cmds: + - echo "๐Ÿ” Checking authentication.." + - dr auth check + + dev: + desc: "๐Ÿš€ Run all services together" + deps: + - auth-check + cmds: + - drdev + + deploy: + desc: "๐Ÿš€ Deploy all services" + env: + AGENT_DEPLOY: "1" + {{- if .HasInstall }} + deps: + - install + {{- end }} + cmds: + - task: infra:deploy + + deploy-dev: + desc: "๐Ÿš€ Deploy infrastructure and services to development" + {{- if .HasInstall }} + deps: + - install + {{- end }} + cmds: + - task: infra:deploy-dev \ No newline at end of file diff --git a/cmd/task/run/testdata/recipe/Taskfile.yml b/cmd/task/run/testdata/recipe/Taskfile.yml new file mode 100644 index 000000000..c7a7677db --- /dev/null +++ b/cmd/task/run/testdata/recipe/Taskfile.yml @@ -0,0 +1,141 @@ +--- +# File generated by DataRobot CLI, regenerate using `dr task compose` +version: '3' +env: + ENV: testing + PYTHONWARNINGS: "ignore" +dotenv: ['.env', '.env.{{.ENV}}'] +vars: + AGENT_VERSION: + sh: grep '^_commit:' .datarobot/answers/agent-agent.yml | awk '{print $2}' + +includes: + common: + taskfile: ./core/task-common.yaml + internal: true + agent: + taskfile: ./agent/Taskfile.yml + dir: ./agent + core: + taskfile: ./core/Taskfile.yaml + dir: ./core + docs: + taskfile: ./docs/Taskfile.yaml + dir: ./docs + fastapi_server: + taskfile: ./fastapi_server/Taskfile.yaml + dir: ./fastapi_server + frontend_web: + taskfile: ./frontend_web/Taskfile.yaml + dir: ./frontend_web + infra: + taskfile: ./infra/Taskfile.yaml + dir: ./infra + mcp_server: + taskfile: ./mcp_server/Taskfile.yaml + dir: ./mcp_server + +tasks: + default: + desc: "โ„น๏ธ Show all available tasks (run `task --list-all` to see hidden tasks)" + silent: true + cmds: + - task --list --sort none + + start-non-interactive: + cmds: + - task: start + vars: + COPIER_FLAGS: "-l" + + start: + desc: "๐Ÿ’ป Prepare local development environment" + cmds: + - dr self update + - "echo 'โœจ Choose your agentic framework.'" + - | + PYTHONWARNINGS=ignore uvx copier recopy -a .datarobot/answers/agent-agent.yml \ + -r {{.AGENT_VERSION}} \ + --data base_answers_file=.datarobot/answers/base.yml \ + --data agent_app_name=agent \ + --data agent_development_port=8842 \ + --data include_taskfile_infra=no \ + --data mcp_answers_file=.datarobot/answers/drmcp-mcp_server.yml \ + --data llm_answers_file=.datarobot/answers/llm-llm.yml \ + --data use_agent_memory=none \ + {{.COPIER_FLAGS}} -w --quiet + - task build-agents-md + - dr task compose + - dr dotenv setup --if-needed + - dr dotenv update + - task install + - dr task run start + - echo 'โœ… You are all set. Run `task dev` to start developing, or run `task deploy` to deploy to DataRobot.' + build-agents-md: + cmds: + - cmd: | + cp AGENTS.md.template AGENTS.md + [ -f agent/AGENTS.md ] && cat agent/AGENTS.md >> AGENTS.md + [ -f mcp_server/AGENTS.md ] && cat mcp_server/AGENTS.md >> AGENTS.md + [ -f fastapi_server/AGENTS.md ] && cat fastapi_server/AGENTS.md >> AGENTS.md + [ -f frontend_web/AGENTS.md ] && cat frontend_web/AGENTS.md >> AGENTS.md + [ -f infra/AGENTS.md ] && cat infra/AGENTS.md >> AGENTS.md + true + platforms: [linux, darwin] + - cmd: powershell -Command "Copy-Item 'AGENTS.md.template' 'AGENTS.md'; if (Test-Path 'agent\AGENTS.md') { Get-Content + 'agent\AGENTS.md' | Add-Content 'AGENTS.md' }; if (Test-Path 'mcp_server\AGENTS.md') { Get-Content 'mcp_server\AGENTS.md' + | Add-Content 'AGENTS.md' }; if (Test-Path 'fastapi_server\AGENTS.md') { Get-Content 'fastapi_server\AGENTS.md' + | Add-Content 'AGENTS.md' }; if (Test-Path 'frontend_web\AGENTS.md') { Get-Content 'frontend_web\AGENTS.md' | Add-Content + 'AGENTS.md' }; if (Test-Path 'infra\AGENTS.md') { Get-Content 'infra\AGENTS.md' | Add-Content 'AGENTS.md' }" + platforms: [windows] + lint: + desc: "๐Ÿงน Run linters" + cmds: + - dr task run lint + install: + desc: "๐Ÿ› ๏ธ Install all dependencies" + cmds: + - | + if [ -n "$VIRTUAL_ENV" ]; then + echo "Installing datarobot_early_access[core] into active virtualenv: $VIRTUAL_ENV" + uv pip install "datarobot_early_access[core]>=3.13.0.2026.2.2.173832" + else + uv tool update-shell + echo "Installing datarobot_early_access[core] as a uv tool" + uv tool install --force "datarobot_early_access[core]>=3.13.0.2026.2.2.173832" + fi + - dr task run install + test: + desc: "๐Ÿงช Run tests across all components" + cmds: + - dr task run test + + auth-check: + silent: true + desc: "๐Ÿ” Check authentication prerequisites" + cmds: + - echo "๐Ÿ” Checking authentication.." + - dr auth check + + dev: + desc: "๐Ÿš€ Run all services together" + deps: + - auth-check + cmds: + - drdev + + deploy: + desc: "๐Ÿš€ Deploy all services" + env: + AGENT_DEPLOY: "1" + deps: + - install + cmds: + - task: infra:deploy + + deploy-dev: + desc: "๐Ÿš€ Deploy infrastructure and services to development" + deps: + - install + cmds: + - task: infra:deploy-dev \ No newline at end of file From ac5f8ace85911de1b6a7a819a59b0607925d9470 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Mon, 1 Jun 2026 21:14:50 -0600 Subject: [PATCH 3/8] Added recursion guards --- cmd/task/run/cmd.go | 50 +++++++++++++++++++++++++++++--------- cmd/task/run/cmd_test.go | 28 +++++++++++++++++++-- internal/task/discovery.go | 14 ++++++----- internal/task/runner.go | 5 ++++ 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index 96c16f6a9..0e74868fb 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -33,6 +33,8 @@ type taskRunOptions struct { taskOpts task.RunOpts } +const taskRunFromRootEnv = "DATAROBOT_CLI_TASK_RUN_FROM_ROOT" + // splitTaskArgs separates task names from additional arguments. // Supports: dr run task1 task2 -- -flag1 -flag2 // Also auto-detects flags after task names if no explicit -- separator is present. @@ -69,6 +71,10 @@ func splitTaskArgs(args []string) (taskNames []string, taskArgs []string) { } func rootTaskfilePath(dir string) (string, error) { + if os.Getenv(taskRunFromRootEnv) != "" { + return "", os.ErrNotExist + } + for _, name := range []string{"Taskfile.yaml", "Taskfile.yml"} { path := filepath.Join(dir, name) @@ -85,6 +91,34 @@ func rootTaskfilePath(dir string) (string, error) { return "", os.ErrNotExist } +func taskfileForRun(dir string) (string, bool, error) { + rootTaskfile, err := rootTaskfilePath(dir) + if err == nil { + return rootTaskfile, true, nil + } + + if !os.IsNotExist(err) { + return "", false, err + } + + if os.Getenv(taskRunFromRootEnv) != "" { + discovery := task.NewTaskDiscovery("Taskfile.gen.yaml") + + rootTaskfile, err = discovery.Discover(dir, 2) + + return rootTaskfile, false, err + } + + discovery, err := task.NewDiscovery("Taskfile.gen.yaml", "") + if err != nil { + return "", false, err + } + + rootTaskfile, err = discovery.Discover(dir, 2) + + return rootTaskfile, false, err +} + func Cmd() *cobra.Command { var opts taskRunOptions @@ -116,18 +150,7 @@ Examples: binaryName := "task" taskNames, taskArgs := splitTaskArgs(args) - rootTaskfile, err := rootTaskfilePath(opts.Dir) - if os.IsNotExist(err) { - discovery, discoveryErr := task.NewDiscovery("Taskfile.gen.yaml", "") - if discoveryErr != nil { - _, _ = fmt.Fprintln(os.Stderr, discoveryErr) - - return cli.ErrSilent - } - - rootTaskfile, err = discovery.Discover(opts.Dir, 2) - } - + rootTaskfile, usingRootTaskfile, err := taskfileForRun(opts.Dir) if err != nil { _, _ = fmt.Fprintln(os.Stderr, task.FormatDiscoveryError(err)) @@ -162,6 +185,9 @@ Examples: } opts.taskOpts.TaskArgs = taskArgs + if usingRootTaskfile { + opts.taskOpts.Env = append(opts.taskOpts.Env, taskRunFromRootEnv+"=1") + } err = runner.Run(taskNames, opts.taskOpts) if err != nil { //nolint: nestif diff --git a/cmd/task/run/cmd_test.go b/cmd/task/run/cmd_test.go index a2936ebc1..9b820e27d 100644 --- a/cmd/task/run/cmd_test.go +++ b/cmd/task/run/cmd_test.go @@ -30,11 +30,35 @@ func TestCmdPrefersRecipeRootTaskfileWhenPresent(t *testing.T) { logs := readTaskLog(t, logFile) require.Contains(t, logs, "-t "+filepath.Join(recipeDir, rootTaskfileYML)+" -C 2 start") + require.Contains(t, logs, "|1|") _, err := os.Stat(filepath.Join(recipeDir, generatedTaskfileYML)) require.ErrorIs(t, err, os.ErrNotExist) } +func TestCmdUsesEmbeddedGeneratedTaskfileWhenCalledFromRootTaskfile(t *testing.T) { + recipeDir := t.TempDir() + writeRecipeFixture(t, recipeDir, true) + t.Setenv(taskRunFromRootEnv, "1") + + logFile := filepath.Join(t.TempDir(), "task.log") + writeFakeTaskBinary(t, logFile) + + cmd := Cmd() + cmd.SetArgs([]string{"--dir", recipeDir, "start"}) + + require.NoError(t, cmd.Execute()) + + logs := readTaskLog(t, logFile) + require.Contains(t, logs, "-t "+filepath.Join(recipeDir, generatedTaskfileYML)+" -C 2 start") + require.NotContains(t, logs, "-t "+filepath.Join(recipeDir, rootTaskfileYML)+" -C 2 start") + + generated := readTextFile(t, filepath.Join(recipeDir, generatedTaskfileYML)) + require.Contains(t, generated, "task agent:start") + require.NotContains(t, generated, "dr task run start") + require.NotContains(t, generated, "build-agents-md") +} + func TestCmdUsesRecipeTemplateWhenRootTaskfileIsMissing(t *testing.T) { recipeDir := t.TempDir() writeRecipeFixture(t, recipeDir, false) @@ -143,7 +167,7 @@ JSON exit 0 fi -printf '%s|%s\n' "$PWD" "$*" >> "$TASK_LOG" +printf '%s|%s|%s\n' "$PWD" "$DATAROBOT_CLI_TASK_RUN_FROM_ROOT" "$*" >> "$TASK_LOG" ` require.NoError(t, os.WriteFile(filepath.Join(binDir, "task"), []byte(script), 0o755)) @@ -157,7 +181,7 @@ if "%1"=="--list" ( echo {"tasks":[{"name":"start","desc":"Start component"},{"name":"lint","desc":"Lint component"},{"name":"install","desc":"Install component"},{"name":"test","desc":"Test component"},{"name":"dev","desc":"Dev component"},{"name":"deploy-dev","desc":"Deploy dev component","aliases":["up-dev"]}]} exit /b 0 ) -echo %CD%^|%*>>"%TASK_LOG%" +echo %CD%^|%DATAROBOT_CLI_TASK_RUN_FROM_ROOT%^|%*>>"%TASK_LOG%" exit /b 0 ` diff --git a/internal/task/discovery.go b/internal/task/discovery.go index 7008016c0..b277a7c84 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -95,8 +95,9 @@ func depth(path string) int { } type Discovery struct { - RootTaskfileName string - TemplatePath string + RootTaskfileName string + TemplatePath string + UseProjectTemplate bool } func NewTaskDiscovery(rootTaskfileName string) *Discovery { @@ -107,8 +108,9 @@ func NewTaskDiscovery(rootTaskfileName string) *Discovery { func NewComposeDiscovery(rootTaskfileName string, templatePath string) *Discovery { return &Discovery{ - RootTaskfileName: rootTaskfileName, - TemplatePath: templatePath, + RootTaskfileName: rootTaskfileName, + TemplatePath: templatePath, + UseProjectTemplate: true, } } @@ -118,7 +120,7 @@ func NewComposeDiscovery(rootTaskfileName string, templatePath string) *Discover // check for a ".Taskfile.template" file in the project root at runtime. func NewDiscovery(taskfileName, templatePath string) (*Discovery, error) { if templatePath == "" { - return NewTaskDiscovery(taskfileName), nil + return NewComposeDiscovery(taskfileName, ""), nil } absPath, err := filepath.Abs(templatePath) @@ -157,7 +159,7 @@ func (d *Discovery) Discover(root string, maxDepth int) (string, error) { rootTaskfilePath := filepath.Join(root, d.RootTaskfileName) // Auto-detect .Taskfile.template in the project root when no explicit template is configured - if d.TemplatePath == "" { + if d.UseProjectTemplate && d.TemplatePath == "" { candidate := filepath.Join(root, ".Taskfile.template") if _, statErr := os.Stat(candidate); statErr == nil { d.TemplatePath = candidate diff --git a/internal/task/runner.go b/internal/task/runner.go index 6bcc342cb..b8f0d32dd 100644 --- a/internal/task/runner.go +++ b/internal/task/runner.go @@ -123,6 +123,7 @@ type RunOpts struct { ExitCode bool Concurrency int TaskArgs []string // Additional arguments to pass to the task command + Env []string } func (o *RunOpts) RunArgs() []string { @@ -175,7 +176,11 @@ func (r *Runner) Run(tasks []string, opts RunOpts) error { cmd.Stdout = r.opts.Stdout cmd.Stderr = r.opts.Stderr + cmd.Stdin = r.opts.Stdin + if len(opts.Env) > 0 { + cmd.Env = append(os.Environ(), opts.Env...) + } return cmd.Run() } From 6cdae3e6b8075d5d4ab914cc39fbca8d061f65ac Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Tue, 2 Jun 2026 18:16:44 -0600 Subject: [PATCH 4/8] Update cmd/task/run/cmd_test.go Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- cmd/task/run/cmd_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/task/run/cmd_test.go b/cmd/task/run/cmd_test.go index 9b820e27d..5a829e1ab 100644 --- a/cmd/task/run/cmd_test.go +++ b/cmd/task/run/cmd_test.go @@ -1,3 +1,17 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package run import ( From fe45869b3984af85e30af33158b1b00d691c5166 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Tue, 2 Jun 2026 18:16:54 -0600 Subject: [PATCH 5/8] Update cmd/task/run/testdata/recipe/Taskfile.yml Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- cmd/task/run/testdata/recipe/Taskfile.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/task/run/testdata/recipe/Taskfile.yml b/cmd/task/run/testdata/recipe/Taskfile.yml index c7a7677db..8f294a4c5 100644 --- a/cmd/task/run/testdata/recipe/Taskfile.yml +++ b/cmd/task/run/testdata/recipe/Taskfile.yml @@ -1,3 +1,17 @@ +# Copyright 2026 DataRobot, Inc. and its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + --- # File generated by DataRobot CLI, regenerate using `dr task compose` version: '3' From ff07eafccf8120f7575dc5750d834d4c76bb5c36 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Tue, 2 Jun 2026 18:35:00 -0600 Subject: [PATCH 6/8] Ignore testdata for copyright --- .licenserc.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.licenserc.yaml b/.licenserc.yaml index 76a02f187..3535dd6d7 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -40,6 +40,7 @@ header: - 'main.go' paths-ignore: + - 'cmd/task/run/testdata/**' - 'internal/task/Taskfile.tmpl.yaml' - 'internal/copier/readme/*.md' - 'internal/workload/wapi/wapiignore.tmpl' From 6e7308b4b56199232d77d6d23855434a93ed4d23 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Fri, 5 Jun 2026 11:28:17 -0600 Subject: [PATCH 7/8] Corrected to properly do template detection --- cmd/task/run/cmd.go | 5 +++++ cmd/task/run/cmd_test.go | 16 ++++++++++++++++ internal/repo/detect.go | 9 +++++---- internal/repo/detect_test.go | 26 ++++++++++++++++++++++++++ internal/task/discovery.go | 5 ++--- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index 0e74868fb..86b8946b1 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/datarobot/cli/internal/cli" "github.com/datarobot/cli/internal/log" + "github.com/datarobot/cli/internal/repo" "github.com/datarobot/cli/internal/task" "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" @@ -92,6 +93,10 @@ func rootTaskfilePath(dir string) (string, error) { } func taskfileForRun(dir string) (string, bool, error) { + if !repo.IsTemplateDir(dir) { + return "", false, task.ErrNotInTemplate + } + rootTaskfile, err := rootTaskfilePath(dir) if err == nil { return rootTaskfile, true, nil diff --git a/cmd/task/run/cmd_test.go b/cmd/task/run/cmd_test.go index 5a829e1ab..0d6705b8d 100644 --- a/cmd/task/run/cmd_test.go +++ b/cmd/task/run/cmd_test.go @@ -21,6 +21,7 @@ import ( "strings" "testing" + "github.com/datarobot/cli/internal/cli" "github.com/stretchr/testify/require" ) @@ -93,6 +94,21 @@ func TestCmdUsesRecipeTemplateWhenRootTaskfileIsMissing(t *testing.T) { require.Contains(t, generated, "drdev") } +func TestCmdReturnsErrNotInTemplateWhenDatarobotMissing(t *testing.T) { + dir := t.TempDir() + + // Place a Taskfile.yaml with no .datarobot dir โ€” simulates the cli repo itself + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yaml"), []byte("version: '3'\ntasks: {}\n"), 0o644)) + + cmd := Cmd() + cmd.SetArgs([]string{"--dir", dir, "dev"}) + + err := cmd.Execute() + + // Command prints the message to stderr and returns ErrSilent (already printed) + require.ErrorIs(t, err, cli.ErrSilent) +} + func writeRecipeFixture(t *testing.T, recipeDir string, includeRootTaskfile bool) { t.Helper() diff --git a/internal/repo/detect.go b/internal/repo/detect.go index 121b647e1..6b29f6a99 100644 --- a/internal/repo/detect.go +++ b/internal/repo/detect.go @@ -40,8 +40,7 @@ func FindRepoRoot() (string, error) { } for { - // Check if .datarobot/answers exists in current directory - if detectTemplate(currentDir) { + if IsTemplateDir(currentDir) { return currentDir, nil } @@ -62,8 +61,10 @@ func FindRepoRoot() (string, error) { } } -// detectTemplate checks if .datarobot/answers or .datarobot/cli exists in dir directory -func detectTemplate(dir string) bool { +// IsTemplateDir reports whether dir is the root of a DataRobot template project. +// It checks for .datarobot/answers or a non-trivial .datarobot/cli directory. +// Use this instead of ad-hoc os.Stat(".datarobot") checks. +func IsTemplateDir(dir string) bool { answersDirPresent := fsutil.DirExists(filepath.Join(dir, DataRobotTemplateDetectAnswersPath)) if answersDirPresent { log.Debugf("Directory %s exists, treating %s as template", DataRobotTemplateDetectAnswersPath, dir) diff --git a/internal/repo/detect_test.go b/internal/repo/detect_test.go index 771707d11..48f882a35 100644 --- a/internal/repo/detect_test.go +++ b/internal/repo/detect_test.go @@ -201,3 +201,29 @@ func (suite *DetectTestSuite) TestIsInRepoReturnsFalseWhenOnlyStateYaml() { // Should return false suite.False(repo.IsInRepo()) } + +func (suite *DetectTestSuite) TestIsTemplateDirReturnsTrueWithAnswers() { + suite.createAnswersDir() + suite.True(repo.IsTemplateDir(suite.tempDir)) +} + +func (suite *DetectTestSuite) TestIsTemplateDirReturnsTrueWithCliVersions() { + suite.createCliVersionsYaml() + suite.True(repo.IsTemplateDir(suite.tempDir)) +} + +func (suite *DetectTestSuite) TestIsTemplateDirReturnsFalseWithNoMarkers() { + suite.False(repo.IsTemplateDir(suite.tempDir)) +} + +func (suite *DetectTestSuite) TestIsTemplateDirReturnsFalseWithOnlyStateYaml() { + suite.createCliStateYaml() + suite.False(repo.IsTemplateDir(suite.tempDir)) +} + +func (suite *DetectTestSuite) TestIsTemplateDirReturnsFalseWithBareDatarobotDir() { + // A plain .datarobot/ with no recognised subdirs is not a template + err := os.MkdirAll(filepath.Join(suite.tempDir, ".datarobot"), 0o755) + suite.Require().NoError(err) + suite.False(repo.IsTemplateDir(suite.tempDir)) +} diff --git a/internal/task/discovery.go b/internal/task/discovery.go index b277a7c84..4e2821df3 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -26,6 +26,7 @@ import ( "text/template" "github.com/datarobot/cli/internal/log" + "github.com/datarobot/cli/internal/repo" "github.com/datarobot/cli/tui" "gopkg.in/yaml.v3" ) @@ -136,9 +137,7 @@ func NewDiscovery(taskfileName, templatePath string) (*Discovery, error) { } func (d *Discovery) Discover(root string, maxDepth int) (string, error) { - // Check if .env file exists in the root directory - envPath := filepath.Join(root, ".datarobot") - if _, err := os.Stat(envPath); os.IsNotExist(err) { + if !repo.IsTemplateDir(root) { return "", ErrNotInTemplate } From 7c722edd3c96475bfe788e841de9553eebd27d24 Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Thu, 25 Jun 2026 21:24:11 -0600 Subject: [PATCH 8/8] Corrected paths --- cmd/dotenv/cmd_test.go | 16 ++++++--- cmd/dotenv/model_test.go | 4 +-- cmd/task/run/cmd.go | 48 ++++++--------------------- internal/task/discovery.go | 66 ++++++++++++++++++++++++++------------ 4 files changed, 69 insertions(+), 65 deletions(-) diff --git a/cmd/dotenv/cmd_test.go b/cmd/dotenv/cmd_test.go index 6f3347ee8..f5069346c 100644 --- a/cmd/dotenv/cmd_test.go +++ b/cmd/dotenv/cmd_test.go @@ -38,11 +38,17 @@ func setupTestRepo(t *testing.T) string { err := cmd.Run() require.NoError(t, err, "Failed to initialize git repository") - // Create .datarobot directory - datarobotDir := filepath.Join(repoDir, ".datarobot") + // Create .datarobot/answers directory to make IsTemplateDir return true + answersDir := filepath.Join(repoDir, ".datarobot", "answers") - err = os.MkdirAll(datarobotDir, 0o755) - require.NoError(t, err, "Failed to create .datarobot directory") + err = os.MkdirAll(answersDir, 0o755) + require.NoError(t, err, "Failed to create .datarobot/answers directory") + + // Create .datarobot/cli directory for parakeet.yaml + cliDir := filepath.Join(repoDir, ".datarobot", "cli") + + err = os.MkdirAll(cliDir, 0o755) + require.NoError(t, err, "Failed to create .datarobot/cli directory") // Create parakeet.yaml with basic configuration parakeetYaml := `root: @@ -52,7 +58,7 @@ func setupTestRepo(t *testing.T) string { optional: true help: "A test variable" ` - parakeetPath := filepath.Join(datarobotDir, "parakeet.yaml") + parakeetPath := filepath.Join(cliDir, "parakeet.yaml") err = os.WriteFile(parakeetPath, []byte(parakeetYaml), 0o600) require.NoError(t, err, "Failed to create parakeet.yaml") diff --git a/cmd/dotenv/model_test.go b/cmd/dotenv/model_test.go index 2b5d11115..706bb433a 100644 --- a/cmd/dotenv/model_test.go +++ b/cmd/dotenv/model_test.go @@ -111,11 +111,11 @@ func (suite *DotenvModelTestSuite) SetupTest() { dir, _ := os.MkdirTemp("", "datarobot-config-test") suite.tempDir = dir - datarobotDir := filepath.Join(dir, ".datarobot") + datarobotDir := filepath.Join(dir, ".datarobot", "cli") err := os.MkdirAll(datarobotDir, os.ModePerm) if err != nil { - suite.T().Errorf("Failed to create .datarobot directory: %v", err) + suite.T().Errorf("Failed to create .datarobot/cli directory: %v", err) } parakeetYamlName := filepath.Join(datarobotDir, "parakeet.yaml") diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index 86b8946b1..7ec2221b1 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -23,7 +23,6 @@ import ( "github.com/datarobot/cli/internal/cli" "github.com/datarobot/cli/internal/log" - "github.com/datarobot/cli/internal/repo" "github.com/datarobot/cli/internal/task" "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" @@ -71,45 +70,11 @@ func splitTaskArgs(args []string) (taskNames []string, taskArgs []string) { return taskNames, taskArgs } -func rootTaskfilePath(dir string) (string, error) { - if os.Getenv(taskRunFromRootEnv) != "" { - return "", os.ErrNotExist - } - - for _, name := range []string{"Taskfile.yaml", "Taskfile.yml"} { - path := filepath.Join(dir, name) - - _, err := os.Stat(path) - if err == nil { - return path, nil - } - - if !os.IsNotExist(err) { - return "", err - } - } - - return "", os.ErrNotExist -} - func taskfileForRun(dir string) (string, bool, error) { - if !repo.IsTemplateDir(dir) { - return "", false, task.ErrNotInTemplate - } - - rootTaskfile, err := rootTaskfilePath(dir) - if err == nil { - return rootTaskfile, true, nil - } - - if !os.IsNotExist(err) { - return "", false, err - } - if os.Getenv(taskRunFromRootEnv) != "" { discovery := task.NewTaskDiscovery("Taskfile.gen.yaml") - rootTaskfile, err = discovery.Discover(dir, 2) + rootTaskfile, err := discovery.Discover(dir, 2) return rootTaskfile, false, err } @@ -119,9 +84,16 @@ func taskfileForRun(dir string) (string, bool, error) { return "", false, err } - rootTaskfile, err = discovery.Discover(dir, 2) + discovery.PreferRootTaskfile = true + + rootTaskfile, err := discovery.Discover(dir, 2) + if err != nil { + return "", false, err + } + + usingRootTaskfile := filepath.Base(rootTaskfile) != "Taskfile.gen.yaml" - return rootTaskfile, false, err + return rootTaskfile, usingRootTaskfile, nil } func Cmd() *cobra.Command { diff --git a/internal/task/discovery.go b/internal/task/discovery.go index 4e2821df3..71cad128b 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -26,7 +26,6 @@ import ( "text/template" "github.com/datarobot/cli/internal/log" - "github.com/datarobot/cli/internal/repo" "github.com/datarobot/cli/tui" "gopkg.in/yaml.v3" ) @@ -99,6 +98,7 @@ type Discovery struct { RootTaskfileName string TemplatePath string UseProjectTemplate bool + PreferRootTaskfile bool // if true, return any existing Taskfile.yaml/yml at root instead of generating } func NewTaskDiscovery(rootTaskfileName string) *Discovery { @@ -136,28 +136,26 @@ func NewDiscovery(taskfileName, templatePath string) (*Discovery, error) { return NewComposeDiscovery(taskfileName, absPath), nil } -func (d *Discovery) Discover(root string, maxDepth int) (string, error) { - if !repo.IsTemplateDir(root) { - return "", ErrNotInTemplate - } +// IsTemplateDir reports whether dir is a DataRobot template directory +// (i.e. it contains a ".datarobot" folder). +func IsTemplateDir(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".datarobot")) - includes, err := d.findComponents(root, maxDepth) - if err != nil { - return "", fmt.Errorf("Failed to discover components: %w", err) - } - - if len(includes) == 0 { - return "", ErrNoTaskFilesFound - } + return err == nil +} - // Check if any discovered Taskfiles already have a dotenv directive - if err := d.checkForDotenvConflicts(root, includes); err != nil { - return "", err +func (d *Discovery) existingRootTaskfile(root string) string { + for _, name := range []string{"Taskfile.yaml", "Taskfile.yml"} { + path := filepath.Join(root, name) + if _, err := os.Stat(path); err == nil { + return path + } } - rootTaskfilePath := filepath.Join(root, d.RootTaskfileName) + return "" +} - // Auto-detect .Taskfile.template in the project root when no explicit template is configured +func (d *Discovery) generateTaskfile(root string, includes []componentInclude) (string, error) { if d.UseProjectTemplate && d.TemplatePath == "" { candidate := filepath.Join(root, ".Taskfile.template") if _, statErr := os.Stat(candidate); statErr == nil { @@ -170,14 +168,42 @@ func (d *Discovery) Discover(root string, maxDepth int) (string, error) { return "", fmt.Errorf("failed to build compose data: %w", err) } - err = d.genRootTaskfile(rootTaskfilePath, composeData) - if err != nil { + rootTaskfilePath := filepath.Join(root, d.RootTaskfileName) + + if err = d.genRootTaskfile(rootTaskfilePath, composeData); err != nil { return "", fmt.Errorf("Failed to create the root Taskfile: %w", err) } return rootTaskfilePath, nil } +func (d *Discovery) Discover(root string, maxDepth int) (string, error) { + if !IsTemplateDir(root) { + return "", ErrNotInTemplate + } + + if d.PreferRootTaskfile { + if path := d.existingRootTaskfile(root); path != "" { + return path, nil + } + } + + includes, err := d.findComponents(root, maxDepth) + if err != nil { + return "", fmt.Errorf("Failed to discover components: %w", err) + } + + if len(includes) == 0 { + return "", ErrNoTaskFilesFound + } + + if err := d.checkForDotenvConflicts(root, includes); err != nil { + return "", err + } + + return d.generateTaskfile(root, includes) +} + // FormatDiscoveryError formats a discovery error into a user-friendly message string. // Commands should call this to get the message, print it themselves if needed, // and return cli.ErrSilent (or the returned error directly).