mirror of
https://github.com/github/spec-kit.git
synced 2026-07-05 05:21:48 +08:00
* 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)
54 lines
2.3 KiB
Python
54 lines
2.3 KiB
Python
"""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
|