mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver (#3186)
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did not: it resolved the project as Path.cwd() + a .specify/ check and never read the override. So setup-plan.sh respected it while `specify integration install` ignored it, and you still had to cd into the member project. Route project resolution through a shared _resolve_init_dir_override() that applies the shell resolver's validation rules (relative to cwd, must exist and contain .specify/, hard error, no fallback, same error strings). It's wired into _require_specify_project() — the chokepoint for every project-scoped subcommand (integration/extension/workflow/preset/...) — and the `workflow run <file>` standalone path, which re-applies its symlinked-.specify guard on the override branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule doesn't apply. The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the logical path; they agree for non-symlinked paths (documented in the resolver). Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray export can't perturb the now-env-reading resolver. Docs note the CLI applies the same rules. Discussion: github/spec-kit#2834 (Disclosure: I used an AI coding agent to audit the call sites and resolver, draft the change, and run an adversarial code review; reviewed by me.) * fix(cli): honor SPECIFY_INIT_DIR for bundle commands Assisted-by: Codex (model: GPT-5, autonomous) * fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path find_project_root refuses a symlinked .specify (following it could read/write outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added for bundle commands returned early and skipped that guard: _resolve_init_dir_override validates .specify with is_dir(), which follows symlinks. So `specify bundle` accepted via the override a layout the cwd path rejects. Re-check the override result with the same guard, plus a regression test. (Disclosure: found via an AI code review and fixed with an AI coding agent; reviewed by me.) * fix(cli): keep SPECIFY_INIT_DIR strict for bundles Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior. Assisted-by: Codex (model: GPT-5, autonomous) * docs(core): note symlinked .specify handling differs across CLI surfaces A symlinked .specify is followed by integration/extension/workflow (matching the shell resolver) but refused by bundle and workflow run <file> (write confinement). Document the asymmetry so it reads as intentional. (Disclosure: AI-assisted; reviewed by me.) * docs(core): reframe symlinked .specify note around the override invariant Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project is, not how a surface treats symlinks. Each surface keeps its cwd-path stance (write surfaces refuse a symlinked .specify, read/config surfaces follow it), so the split is one policy relocated, not an inconsistency. * docs: address Copilot review on resolver docstrings - _project.py: the error messages "mirror" the shell wording rather than "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`). - find_project_root: document that honoring SPECIFY_INIT_DIR when start is None can raise typer.Exit / BundlerError, so the Path | None signature isn't surprising to direct callers. * docs(bundler): note require_project_root inherits the override raise behavior find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR override (start=None); require_project_root inherits that, so document it alongside its own BundlerError-on-missing-project. * docs: clarify symlinked project root behavior Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Address SPECIFY_INIT_DIR review feedback Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Route workflow JSON errors to stderr Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
|
||||
|
||||
## Check Installed Tools
|
||||
|
||||
```bash
|
||||
|
||||
@@ -46,6 +46,7 @@ from ._console import (
|
||||
BannerGroup,
|
||||
StepTracker,
|
||||
console,
|
||||
err_console,
|
||||
get_key as get_key,
|
||||
select_with_arrows as select_with_arrows,
|
||||
show_banner,
|
||||
@@ -507,20 +508,35 @@ _register_extension_cmds(app)
|
||||
from .integrations._commands import register as _register_integration_cmds # noqa: E402
|
||||
_register_integration_cmds(app)
|
||||
|
||||
# Re-exported from integrations/_helpers.py to preserve the public import surface.
|
||||
# Re-export selected helpers to preserve the public import surface.
|
||||
from .integrations._helpers import ( # noqa: E402
|
||||
_clear_init_options_for_integration as _clear_init_options_for_integration,
|
||||
_update_init_options_for_integration as _update_init_options_for_integration,
|
||||
)
|
||||
from ._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: E402
|
||||
|
||||
|
||||
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
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
err_console.print("[red]Error:[/red] Not a Spec Kit project (no .specify/ directory)")
|
||||
err_console.print(
|
||||
"Run this command from a Spec Kit project root or set SPECIFY_INIT_DIR to one."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
|
||||
53
src/specify_cli/_project.py
Normal file
53
src/specify_cli/_project.py
Normal file
@@ -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 err_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():
|
||||
err_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():
|
||||
err_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
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,8 @@ import typer
|
||||
import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._console import console, err_console
|
||||
from .._project import _resolve_init_dir_override
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
name="workflow",
|
||||
@@ -74,10 +75,10 @@ def _reject_unsafe_dir(path: Path, label: str) -> None:
|
||||
creates the directory — only an existing-but-wrong target is rejected.
|
||||
"""
|
||||
if path.is_symlink():
|
||||
console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
|
||||
err_console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
|
||||
raise typer.Exit(1)
|
||||
if path.exists() and not path.is_dir():
|
||||
console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
|
||||
err_console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -320,9 +321,11 @@ 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.
|
||||
override = _resolve_init_dir_override()
|
||||
project_root = override if override is not None else Path.cwd()
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
else:
|
||||
project_root = _require_specify_project()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1386,14 +1386,14 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_catalog_list_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_primary_integration_commands_require_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
@@ -1413,7 +1413,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
assert "Not a Spec Kit project" in result.output, failure_context
|
||||
|
||||
def test_integration_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad"
|
||||
@@ -1428,7 +1428,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad-feature-commands"
|
||||
@@ -1479,7 +1479,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
assert "Not a Spec Kit project" in result.output, failure_context
|
||||
|
||||
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
|
||||
project = self._make_project(tmp_path)
|
||||
|
||||
@@ -590,7 +590,7 @@ class TestIntegrationUpgrade:
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_upgrade_no_integration_installed(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestIntegrationList:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_list_shows_installed(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -167,7 +167,7 @@ class TestIntegrationStatus:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_status_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
@@ -988,7 +988,7 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_install_unknown_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
@@ -1384,7 +1384,7 @@ class TestIntegrationUninstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_uninstall_no_integration(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
@@ -1687,7 +1687,7 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_switch_unknown_target(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
|
||||
294
tests/test_init_dir_cli.py
Normal file
294
tests/test_init_dir_cli.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""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 <file>` 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 <root>/<name>/.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 <project>/.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_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""Invalid override errors must not contaminate JSON stdout."""
|
||||
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", "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
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 <file>` ────────────────────────────────────────────
|
||||
|
||||
|
||||
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 <file>` 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
|
||||
|
||||
|
||||
def test_override_rejects_symlinked_specify_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""`workflow run --json <file>` must keep this hard error off stdout."""
|
||||
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-json-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "Refusing to use symlinked .specify path" in result.stderr
|
||||
@@ -108,7 +108,7 @@ class TestWorkflowRunWithoutProject:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user