diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index e7e921e1e9..175b577c81 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -20,7 +20,7 @@ Example: specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full ``` -> **Note:** All workflow commands require a project already initialized with `specify init`. +> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run `, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs//`. ## Resume a Workflow diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d1f71e7e07..c406b51e37 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2727,12 +2727,28 @@ def workflow_run( """Run a workflow from an installed ID or local YAML path.""" from .workflows.engine import WorkflowEngine - project_root = _require_specify_project() + source_path = Path(source).expanduser() + is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file() + + if is_file_source: + # When running a YAML file directly, use cwd as project root + # without requiring a .specify/ project directory. + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if specify_dir.is_symlink(): + console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory") + raise typer.Exit(1) + if specify_dir.exists() and not specify_dir.is_dir(): + console.print("[red]Error:[/red] .specify path exists but is not a directory") + raise typer.Exit(1) + else: + project_root = _require_specify_project() + engine = WorkflowEngine(project_root) engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") try: - definition = engine.load_workflow(source) + definition = engine.load_workflow(source_path if is_file_source else source) except FileNotFoundError: console.print(f"[red]Error:[/red] Workflow not found: {source}") raise typer.Exit(1) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 1755b26183..1e6acb676c 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -403,10 +403,10 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition: ValueError: If the workflow YAML is invalid. """ - path = Path(source) + path = Path(source).expanduser() # Try as a direct file path first - if path.suffix in (".yml", ".yaml") and path.exists(): + if path.suffix.lower() in (".yml", ".yaml") and path.is_file(): return WorkflowDefinition.from_yaml(path) # Try as an installed workflow ID diff --git a/tests/test_workflow_run_without_project.py b/tests/test_workflow_run_without_project.py new file mode 100644 index 0000000000..05235493fa --- /dev/null +++ b/tests/test_workflow_run_without_project.py @@ -0,0 +1,238 @@ +"""Tests for running workflow YAML files without a project.""" + +import os + +import pytest +import yaml + + +class TestWorkflowRunWithoutProject: + """Tests that specify workflow run works with YAML files without .specify/ dir.""" + + def test_workflow_run_yaml_without_project(self, tmp_path): + """Running a .yml file should work without a .specify/ directory.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + # Create a minimal workflow YAML with a shell step + workflow_file = tmp_path / "test-workflow.yml" + workflow_content = { + "schema_version": "1.0", + "workflow": { + "id": "standalone-test", + "name": "Standalone Test", + "version": "1.0.0", + "description": "A workflow that runs without a project", + }, + "steps": [ + { + "id": "create-marker", + "type": "shell", + "run": "echo done > marker.txt", + }, + ], + } + workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", str(workflow_file), + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"workflow run failed: {result.output}" + assert "completed" in result.output + assert (tmp_path / "marker.txt").exists() + assert (tmp_path / ".specify" / "workflows" / "runs").is_dir() + + def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch): + """Running ~/file.YML should work without a .specify/ directory.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + home_dir = tmp_path / "home" + home_dir.mkdir() + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("USERPROFILE", str(home_dir)) + + workflow_file = home_dir / "test-workflow.YML" + workflow_content = { + "schema_version": "1.0", + "workflow": { + "id": "standalone-test-uppercase", + "name": "Standalone Test Uppercase", + "version": "1.0.0", + "description": "A workflow that runs from ~/ with an uppercase suffix", + }, + "steps": [ + { + "id": "create-marker", + "type": "shell", + "run": "echo done > marker.txt", + }, + ], + } + workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", "~/test-workflow.YML", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"workflow run failed: {result.output}" + assert "Status: completed" in result.output + assert (tmp_path / "marker.txt").exists() + + def test_workflow_run_id_still_requires_project(self, tmp_path): + """Running a workflow by ID should still require a .specify/ directory.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", "some-workflow-id", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_workflow_run_missing_yaml_file(self, tmp_path): + """Running a non-existent .yml file should still require a project.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", "nonexistent.yml", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + # non-existent .yml files fall through to project check or file-not-found + assert result.exit_code != 0 + + def test_workflow_run_failing_yaml_without_project(self, tmp_path): + """A failing workflow YAML should report failure status.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + workflow_file = tmp_path / "fail-workflow.yml" + workflow_content = { + "schema_version": "1.0", + "workflow": { + "id": "fail-test", + "name": "Fail Test", + "version": "1.0.0", + "description": "A workflow that fails", + }, + "steps": [ + { + "id": "fail-step", + "type": "shell", + "run": "exit 1", + }, + ], + } + workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", str(workflow_file), + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}" + assert "Status: failed" in result.output + + def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path): + """Running local YAML should fail when .specify is a symlink.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + workflow_file = tmp_path / "test-workflow.yml" + workflow_content = { + "schema_version": "1.0", + "workflow": { + "id": "symlink-test", + "name": "Symlink Test", + "version": "1.0.0", + "description": "A workflow for symlink guard testing", + }, + "steps": [{"id": "noop", "type": "shell", "run": "echo done"}], + } + workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8") + + target_dir = tmp_path / "real-specify-dir" + target_dir.mkdir() + try: + (tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", str(workflow_file), + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "Refusing to use symlinked .specify path in current directory" in result.output + + def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path): + """Running local YAML should fail when .specify is not a directory.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + + workflow_file = tmp_path / "test-workflow.yml" + workflow_content = { + "schema_version": "1.0", + "workflow": { + "id": "nondir-test", + "name": "Non-directory Test", + "version": "1.0.0", + "description": "A workflow for non-directory guard testing", + }, + "steps": [{"id": "noop", "type": "shell", "run": "echo done"}], + } + workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8") + (tmp_path / ".specify").write_text("not a directory", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "workflow", "run", str(workflow_file), + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert ".specify path exists but is not a directory" in result.output