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' 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/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..7ec2221b1 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. @@ -68,6 +70,32 @@ func splitTaskArgs(args []string) (taskNames []string, taskArgs []string) { return taskNames, taskArgs } +func taskfileForRun(dir string) (string, bool, error) { + 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 + } + + discovery.PreferRootTaskfile = true + + rootTaskfile, err := discovery.Discover(dir, 2) + if err != nil { + return "", false, err + } + + usingRootTaskfile := filepath.Base(rootTaskfile) != "Taskfile.gen.yaml" + + return rootTaskfile, usingRootTaskfile, nil +} + func Cmd() *cobra.Command { var opts taskRunOptions @@ -97,9 +125,9 @@ Examples: SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { binaryName := "task" - discovery := task.NewTaskDiscovery("Taskfile.gen.yaml") + taskNames, taskArgs := splitTaskArgs(args) - rootTaskfile, err := discovery.Discover(opts.Dir, 2) + rootTaskfile, usingRootTaskfile, err := taskfileForRun(opts.Dir) if err != nil { _, _ = fmt.Fprintln(os.Stderr, task.FormatDiscoveryError(err)) @@ -129,13 +157,14 @@ Examples: return cli.ErrSilent } - taskNames, taskArgs := splitTaskArgs(args) - if !opts.taskOpts.Silent { log.Printf("Running task(s): %s\n", strings.Join(taskNames, ", ")) } 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 new file mode 100644 index 000000000..0d6705b8d --- /dev/null +++ b/cmd/task/run/cmd_test.go @@ -0,0 +1,234 @@ +// 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 ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/datarobot/cli/internal/cli" + "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") + 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) + + 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 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() + + 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|%s\n' "$PWD" "$DATAROBOT_CLI_TASK_RUN_FROM_ROOT" "$*" >> "$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%^|%DATAROBOT_CLI_TASK_RUN_FROM_ROOT%^|%*>>"%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..8f294a4c5 --- /dev/null +++ b/cmd/task/run/testdata/recipe/Taskfile.yml @@ -0,0 +1,155 @@ +# 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' +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 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 7e59d695b..71cad128b 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -95,8 +95,10 @@ func depth(path string) int { } type Discovery struct { - RootTaskfileName string - TemplatePath string + 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 { @@ -107,47 +109,101 @@ func NewTaskDiscovery(rootTaskfileName string) *Discovery { func NewComposeDiscovery(rootTaskfileName string, templatePath string) *Discovery { return &Discovery{ - RootTaskfileName: rootTaskfileName, - TemplatePath: templatePath, + RootTaskfileName: rootTaskfileName, + TemplatePath: templatePath, + UseProjectTemplate: true, } } -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) { - return "", ErrNotInTemplate +// 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 NewComposeDiscovery(taskfileName, ""), nil } - includes, err := d.findComponents(root, maxDepth) + absPath, err := filepath.Abs(templatePath) if err != nil { - return "", fmt.Errorf("Failed to discover components: %w", err) + return nil, fmt.Errorf("resolving template path: %w", err) } - if len(includes) == 0 { - return "", ErrNoTaskFilesFound + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return nil, fmt.Errorf("template file not found: %s", absPath) } - // Check if any discovered Taskfiles already have a dotenv directive - if err := d.checkForDotenvConflicts(root, includes); err != nil { - return "", err + return NewComposeDiscovery(taskfileName, absPath), nil +} + +// 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")) + + return err == nil +} + +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 "" +} + +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 { + d.TemplatePath = candidate + } + } composeData, err := d.buildComposeData(root, includes) if err != nil { 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). 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() }