diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c036a1884..f005bc3e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ +- feat(cli): the `specify` CLI now honors `SPECIFY_INIT_DIR` for every project-scoped subcommand (`integration`, `extension`, `workflow`, `preset`, …) and `workflow run `, applying the same validation rules as the shell resolver, so they can target a member project from a monorepo root without `cd` (#3186) + ## [0.11.9] - 2026-06-26 ### Changed diff --git a/docs/guides/monorepo.md b/docs/guides/monorepo.md index b143699256..48abd1372c 100644 --- a/docs/guides/monorepo.md +++ b/docs/guides/monorepo.md @@ -77,6 +77,18 @@ feature non-interactively. See the [`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for the full contract and the two-axes model. +The `specify` CLI's project-scoped subcommands honor the same variable, so they +target a member project from the root without `cd` too: + +```bash +export SPECIFY_INIT_DIR=apps/web +specify workflow list # lists apps/web's workflows +specify integration status # reports apps/web's integration +``` + +The validation rules are the same: the path must exist and contain `.specify/`, +with no fallback to the current directory. + ## How `SPECIFY_INIT_DIR` reaches your agent `SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke diff --git a/docs/reference/core.md b/docs/reference/core.md index 0b6ad5b14e..871c5b59ac 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -50,12 +50,14 @@ specify init my-project --integration copilot --preset compliance | Variable | Description | | ----------------- | ------------------------------------------------------------------------ | -| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. | +| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). | | `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | > **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. +> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a surface treats symlinks: each surface keeps its existing cwd-path stance. Surfaces that traverse and write (`bundle`, `workflow run `) refuse a symlinked `.specify/` to preserve write confinement; read/config surfaces (`integration`, `extension`, `workflow`) follow it. + ## Check Installed Tools ```bash diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6713549d35..d0ce750e82 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -590,12 +590,25 @@ def version( # Re-exported from integrations/_helpers.py to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, + _resolve_init_dir_override as _resolve_init_dir_override, _update_init_options_for_integration as _update_init_options_for_integration, ) def _require_specify_project() -> Path: - """Return the current project root if it is a spec-kit project, else exit.""" + """Return the project root if it is a spec-kit project, else exit. + + Honors the ``SPECIFY_INIT_DIR`` override (same validation rules as the shell + scripts) so a member project can be targeted from a monorepo root without + ``cd``. This is the resolution chokepoint for *every* project-scoped + subcommand — ``integration``, ``extension``, ``workflow``, ``preset``, and the + rest that operate on an existing ``.specify/`` project — so the override + applies to all of them uniformly. When the override is unset, the project is + the current directory, as before. + """ + override = _resolve_init_dir_override() + if override is not None: + return override project_root = Path.cwd() if (project_root / ".specify").is_dir(): return project_root @@ -819,12 +832,18 @@ def workflow_run( 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() + # When running a YAML file directly, use cwd as project root without + # requiring a .specify/ project directory — unless SPECIFY_INIT_DIR + # explicitly names a project, in which case the strict override applies. + # Either way, refuse a symlinked .specify (a planted-symlink guard): the + # override resolver follows symlinks via is_dir(), so re-check here so the + # override path is as strict as the cwd path. + override = _resolve_init_dir_override() + project_root = override if override is not None else 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") + where = " in current directory" if override is None else f": {specify_dir}" + console.print(f"[red]Error:[/red] Refusing to use symlinked .specify path{where}") 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") diff --git a/src/specify_cli/_project.py b/src/specify_cli/_project.py new file mode 100644 index 0000000000..512c7dfc52 --- /dev/null +++ b/src/specify_cli/_project.py @@ -0,0 +1,53 @@ +"""Shared project-resolution helpers for the Specify CLI.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer + +from ._console import console + + +def _resolve_init_dir_override() -> Path | None: + """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. + + Applies the same validation rules as the shell resolver + (``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names + the project root — the directory *containing* ``.specify/`` — and is strict. + Relative paths resolve against the current directory; the path must exist and + contain ``.specify/``, otherwise this hard-errors with no fallback to cwd + (which would silently operate on the wrong project's files). The error + messages mirror the shell resolver's wording (rendered here as a Rich + ``Error:`` line, plain ``ERROR:`` in the shell) so the two surfaces read + consistently. + + Returns the validated absolute project root, or ``None`` when the variable is + unset/empty, in which case callers keep their existing cwd-based behavior. + + Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path), + whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree + for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to + different strings across the surfaces. The canonical form is the safer choice + here (a stable project identity), so this is a deliberate, documented variance, + not a parity guarantee on the resolved string. + """ + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if not raw: + return None + # Relative values resolve against cwd; an absolute value stands alone (Path's + # `/` drops the left operand when the right is absolute). resolve() also + # collapses a trailing slash and canonicalizes symlinks. + init_root = (Path.cwd() / raw).resolve() + if not init_root.is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" + ) + raise typer.Exit(1) + if not (init_root / ".specify").is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" + ) + raise typer.Exit(1) + return init_root diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 66b8a1b27b..6b9e9642f7 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -3,6 +3,7 @@ from pathlib import Path +from ..._project import _resolve_init_dir_override from .. import BundlerError from .yamlio import ensure_within, load_json @@ -15,7 +16,26 @@ def find_project_root(start: Path | None = None) -> Path | None: A symlinked ``.specify`` is not accepted as a project root: following it could read/write outside the intended tree, and other CLI surfaces refuse it for the same reason. + + When *start* is ``None`` the ``SPECIFY_INIT_DIR`` override is honored first + (see :func:`specify_cli._project._resolve_init_dir_override`). With an + explicit override this may **raise** rather than return: a set-but-invalid + value raises ``typer.Exit`` and a symlinked ``.specify`` raises + ``BundlerError``. That is deliberate — returning ``None`` would let + ``bundle init``/``install`` silently fall back to the current directory. """ + if start is None: + override = _resolve_init_dir_override() + if override is not None: + # An explicit override is strict: do not return None here, because + # bundle install treats None as "init the current directory". + if (override / ".specify").is_symlink(): + raise BundlerError( + "SPECIFY_INIT_DIR is not a safe Spec Kit project " + f"(symlinked .specify/ directory is not allowed): {override}" + ) + return override + current = Path(start or Path.cwd()).resolve() for candidate in (current, *current.parents): marker = candidate / ".specify" @@ -25,7 +45,13 @@ def find_project_root(start: Path | None = None) -> Path | None: def require_project_root(start: Path | None = None) -> Path: - """Return the Spec Kit project root or raise an actionable error.""" + """Return the Spec Kit project root or raise an actionable error. + + Inherits :func:`find_project_root`'s override behavior: when *start* is + ``None``, a set-but-invalid ``SPECIFY_INIT_DIR`` raises ``typer.Exit`` and a + symlinked ``.specify`` raises ``BundlerError`` before this returns. A missing + project (no override) raises ``BundlerError``. + """ root = find_project_root(start) if root is None: raise BundlerError( diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index f8a696a866..955228b599 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -9,6 +9,7 @@ from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console +from .._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: F401 from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, diff --git a/src/specify_cli/integrations/_scaffold_commands.py b/src/specify_cli/integrations/_scaffold_commands.py index f5b4ad3acf..4a5d392dca 100644 --- a/src/specify_cli/integrations/_scaffold_commands.py +++ b/src/specify_cli/integrations/_scaffold_commands.py @@ -32,6 +32,8 @@ def integration_scaffold( """Create a minimal built-in integration package and test skeleton.""" from ..integration_scaffold import scaffold_integration + # scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root), + # not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here. project_root = Path.cwd() try: result = scaffold_integration(project_root, key, integration_type.value) diff --git a/tests/conftest.py b/tests/conftest.py index 4ef643e121..94fb8c31b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,6 +83,20 @@ def _isolate_auth_config(monkeypatch): monkeypatch.setattr(_auth_http, "_config_cache", None) +@pytest.fixture(autouse=True) +def _strip_specify_env(monkeypatch): + """Drop any inherited SPECIFY_* vars for every test. + + The Python CLI's project resolver (`_require_specify_project`) now honors + SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a + developer or CI runner with any SPECIFY_* var exported would silently + retarget (or hard-error) the many command/script tests that resolve a + project. Stripping them here keeps resolution tests deterministic; a test + that wants an override sets it explicitly via monkeypatch afterwards.""" + for key in [k for k in os.environ if k.startswith("SPECIFY_")]: + monkeypatch.delenv(key, raising=False) + + @pytest.fixture def clean_environ(monkeypatch): """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" diff --git a/tests/integration/test_bundler_security_paths.py b/tests/integration/test_bundler_security_paths.py index 85c64919cf..0c01fe6406 100644 --- a/tests/integration/test_bundler_security_paths.py +++ b/tests/integration/test_bundler_security_paths.py @@ -171,3 +171,22 @@ def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): pytest.skip("symlinks not supported on this platform") # A symlinked .specify must not be accepted as a project root. assert find_project_root(project) is None + + +def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch): + """The SPECIFY_INIT_DIR override path refuses a symlinked .specify too, + matching the cwd loop path (regression: the override returned early and + skipped the symlink guard).""" + from specify_cli.bundler.lib.project import find_project_root + + real = tmp_path / "real-specify" + real.mkdir() + project = tmp_path / "project" + project.mkdir() + try: + (project / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + monkeypatch.setenv("SPECIFY_INIT_DIR", str(project)) + with pytest.raises(BundlerError, match="symlinked \\.specify"): + find_project_root(None) diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py new file mode 100644 index 0000000000..1cd8c8e336 --- /dev/null +++ b/tests/test_init_dir_cli.py @@ -0,0 +1,259 @@ +"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`). + +PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor +SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project +from a monorepo root. This extends the same validation rules to the Python CLI's +project resolution — `_require_specify_project()` (the chokepoint for every +project-scoped subcommand) and the `workflow run ` standalone-YAML path — +so those can target a member project without `cd` too. + +The contract mirrors `tests/test_init_dir.py` (the shell side): the value names +the project root (the directory *containing* `.specify/`), relative paths +resolve against cwd, and an invalid value hard-errors with no silent fallback to +cwd. See proposals/monorepo-support and github/spec-kit discussion #2834. + +SPECIFY_* vars are stripped from the environment for every test by the autouse +`_strip_specify_env` fixture in conftest.py; tests that want an override set it +explicitly via monkeypatch. +""" + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app + +runner = CliRunner() + + +def _make_project(root, name): + """Create //.specify (the minimal Spec Kit project marker).""" + proj = root / name + (proj / ".specify").mkdir(parents=True) + return proj + + +def _workflow_yaml(wf_id): + """A minimal valid standalone workflow YAML with a single no-op shell step.""" + return yaml.dump( + { + "schema_version": "1.0", + "workflow": { + "id": wf_id, + "name": wf_id, + "version": "1.0.0", + "description": f"standalone workflow {wf_id}", + }, + "steps": [{"id": "noop", "type": "shell", "run": "echo done"}], + } + ) + + +# ── chokepoint: _require_specify_project() via `workflow list` ─────────────── +# `workflow list` is the lightest subcommand routed through the chokepoint: it +# resolves the project, then reads /.specify/workflows/. An empty +# project prints "No workflows installed"; a failed resolution prints the error +# and exits non-zero. + + +def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch): + """A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a + project — without the override this would error 'Not a spec-kit project'.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + web = _make_project(tmp_path, "web") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SPECIFY_INIT_DIR", "web") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + assert web.exists() + + +def test_override_trailing_slash_tolerated(tmp_path, monkeypatch): + _make_project(tmp_path, "web") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SPECIFY_INIT_DIR", "web/") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_override_redirects_bundle_commands(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0, result.output + assert "No bundles installed" in result.output + + +def test_unset_override_uses_cwd(tmp_path, monkeypatch): + """With SPECIFY_INIT_DIR unset, the project is the current directory.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_empty_override_treated_as_unset(tmp_path, monkeypatch): + """An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as + '.' — which from a deep non-project cwd would otherwise diverge.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", "") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch): + """A non-existent path hard-errors even from inside a valid project, proving + there is no silent fallback to the cwd project.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No workflows installed" not in result.output # no fallback to cwd + + +def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): + """Bundle commands also honor the strict override contract.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No bundles installed" not in result.output + + +def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch): + """A symlinked override .specify must not make bundle init fall back to cwd.""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "init", "--offline"]) + assert result.exit_code != 0 + assert "symlinked .specify" in result.output + assert not (elsewhere / ".specify").exists() + + +def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): + """A path that exists but lacks .specify/ hard-errors, no fallback.""" + cwd_proj = _make_project(tmp_path, "cwd") + nodot = tmp_path / "nodot" + nodot.mkdir() + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "not a Spec Kit project" in result.output + assert "No workflows installed" not in result.output + + +def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch): + """A path that is a file (not a directory) hard-errors with the + existing-directory message.""" + cwd_proj = _make_project(tmp_path, "cwd") + a_file = tmp_path / "afile" + a_file.write_text("x") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + + +# ── bypass: `workflow run ` ──────────────────────────────────────────── + + +def test_override_redirects_workflow_run_file(tmp_path, monkeypatch): + """Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the + project root: run artifacts land under the target, not cwd.""" + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert (web / ".specify" / "workflows" / "runs").is_dir() + assert not (elsewhere / ".specify").exists() # cwd was not used as the project + + +def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch): + """An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to + cwd's standalone-YAML behavior.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + + +def test_override_rejects_symlinked_specify(tmp_path, monkeypatch): + """`workflow run ` refuses a symlinked .specify under the override + target, matching the guard the cwd path applies (the override resolver's + is_dir() check follows symlinks, so this is re-checked on the override path).""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)]) + assert result.exit_code != 0 + assert "Refusing to use symlinked .specify path" in result.output