diff --git a/design/cli.md b/design/cli.md index ff1ae26782..9ee3c91a90 100644 --- a/design/cli.md +++ b/design/cli.md @@ -148,11 +148,20 @@ extensions/ The nested package's `__init__.py` owns its Typer application and registration. Shared helpers for that nested surface can live in `_helpers.py`. -Creating a nested CLI package does not transfer same-named domain behavior into -that package. If an existing domain module collides with a new nested command -namespace, keep the implementation in the parent domain package (or a focused -domain module there). Preserve an established import path through thin -compatibility exports from the nested package when required. +A nested CLI hierarchy may also be the root of a bounded subdomain when its +concept depends on the parent domain but owns a distinct resource and lifecycle +that the parent commands do not cover. In that case, keep the subdomain's +non-command modules and its `command_*.py` adapters together in the nested +package. Storage, validation, composition, or distribution behavior specific to +that resource are signals that the namespace is a domain root, not merely a CLI +group. + +Creating a nested CLI package solely to group commands does not transfer +same-named parent-domain behavior into that package. If an existing domain +module merely collides with a new nested command namespace, keep the +implementation in the parent domain package (or a focused domain module there). +Preserve an established import path through thin compatibility exports from the +nested package when required. Do not add a nested `_commands.py` merely for symmetry. Create one only when the nested group develops substantial shared command infrastructure that no diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index eee6dce00b..cfcc51e352 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -592,12 +592,11 @@ def _require_specify_project() -> Path: # Re-exported at the package root because bundler primitives import these # handlers via ``from specify_cli import workflow_*`` (and tests monkeypatch # ``specify_cli.workflow_add``). Keep these names resolvable from the root. -from .workflows._commands import ( # noqa: E402,F401 - workflow_add, - workflow_remove, - workflow_step_add, - workflow_step_remove, -) +from .workflows.command_add import workflow_add # noqa: E402,F401 +from .workflows.command_remove import workflow_remove # noqa: E402,F401 +from .workflows.step.command_add import workflow_step_add # noqa: E402,F401 +from .workflows.step.command_remove import workflow_step_remove # noqa: E402,F401 + def main(): # On Windows the default stdout/stderr code page (e.g. cp1252) cannot encode diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py index 1e608ca168..2bb3de56a5 100644 --- a/src/specify_cli/workflows/__init__.py +++ b/src/specify_cli/workflows/__init__.py @@ -44,18 +44,18 @@ def get_step_type(type_key: str) -> StepBase | None: def _register_builtin_steps() -> None: """Register all built-in step types.""" - from .steps.command import CommandStep - from .steps.do_while import DoWhileStep - from .steps.fan_in import FanInStep - from .steps.fan_out import FanOutStep - from .steps.gate import GateStep - from .steps.if_then import IfThenStep - from .steps.init import InitStep - from .steps.prompt import PromptStep - from .steps.shell import ShellStep - from .steps.slot import SlotStep - from .steps.switch import SwitchStep - from .steps.while_loop import WhileStep + from .step.command import CommandStep + from .step.do_while import DoWhileStep + from .step.fan_in import FanInStep + from .step.fan_out import FanOutStep + from .step.gate import GateStep + from .step.if_then import IfThenStep + from .step.init import InitStep + from .step.prompt import PromptStep + from .step.shell import ShellStep + from .step.slot import SlotStep + from .step.switch import SwitchStep + from .step.while_loop import WhileStep _register_step(CommandStep()) _register_step(DoWhileStep()) diff --git a/src/specify_cli/workflows/_command_resume_state.py b/src/specify_cli/workflows/_command_resume_state.py new file mode 100644 index 0000000000..935bb17e94 --- /dev/null +++ b/src/specify_cli/workflows/_command_resume_state.py @@ -0,0 +1,49 @@ +"""Resume-private installed-workflow owner state resolution.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _path_has_symlink_component(path: Path) -> bool: + """Return whether any component of an absolute path is a symlink.""" + absolute = Path(os.path.abspath(path)) + current = Path(absolute.anchor) + for part in absolute.parts[1:]: + current /= part + if current.is_symlink(): + return True + return False + + +def _resolve_run_owner_root( + installed_registry_root: str | None, project_root: Path +) -> Path: + """Determine which project's registry gates resuming a run. + + ``installed_registry_root`` is only ever persisted when the run's + installed workflow genuinely belongs to a *different* project than the + one whose ``runs/`` directory holds this run's own state (a direct + external workflow-file invocation) -- see ``workflow_run``. The common + case (an installed workflow run from its own project) stores ``None``, + so a later project rename/move is transparently picked up here by + falling back to the *current* ``project_root`` instead of a stale + absolute path baked in at run start. + + A persisted cross-project root that no longer exists cannot be safely + rediscovered and must fail closed instead of consulting the unrelated + project that happens to store the run state. + """ + if installed_registry_root: + candidate = Path(installed_registry_root) + if ( + candidate.is_absolute() + and not _path_has_symlink_component(candidate) + and candidate.is_dir() + ): + return candidate + raise ValueError( + "Installed workflow owner is unavailable; cannot safely resume" + ) + return project_root diff --git a/src/specify_cli/workflows/_command_run_ownership.py b/src/specify_cli/workflows/_command_run_ownership.py new file mode 100644 index 0000000000..322a05f2b4 --- /dev/null +++ b/src/specify_cli/workflows/_command_run_ownership.py @@ -0,0 +1,187 @@ +"""Run-private installed-workflow ownership resolution.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from . import _commands as cli + + +def _same_existing_path(left: Path, right: Path) -> bool: + """Return whether two existing paths identify the same filesystem entry.""" + try: + return os.path.samefile(left, right) + except OSError: + return left == right + + +def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None: + """Find the *nearest* (innermost) ``.specify/workflows/`` owner in + *parts*, scanning from the end of the path. + + Scanning from the end (rather than stopping at the first match from the + start) matters for a project nested beneath an unrelated outer path that + happens to reuse the same ``.specify``/``workflows`` segment names: the + first-from-start match would pick the outer directory and the wrong + workflow ID, silently missing the real (inner) owner's disabled check. + + Returns the index of the owning ``.specify`` segment, or ``None`` if no + owner segment is present. + """ + for i in range(len(parts) - 3, -1, -1): + if ( + parts[i].casefold() == ".specify" + and parts[i + 1].casefold() == "workflows" + ): + return i + return None + + +def _expand_first_symlink_target(path: Path) -> Path | None: + """Expand one symlink component while preserving the remaining path.""" + parts = path.parts + current = Path(path.anchor) if path.is_absolute() else Path() + start = 1 if path.is_absolute() else 0 + for index in range(start, len(parts)): + current = current / parts[index] + if not current.is_symlink(): + continue + try: + target = Path(os.readlink(current)) + except OSError: + return None + if not target.is_absolute(): + target = current.parent / target + expanded = target.joinpath(*parts[index + 1 :]) + return Path(os.path.normpath(str(expanded.absolute()))) + return None + + +def _resolve_installed_workflow_ownership( + source_path: Path, err +) -> tuple[Path | None, str | None]: + """Map a direct ``workflow.yml`` *source_path* back to the installed + workflow (``registry_root``, ``registered_id``) it belongs to, if any. + + A registered path can point at installed storage three ways, all of + which must receive the same registry disabled-check: + + 1. Lexically: the path's own (symlink-preserving) segments identify + ``.specify/workflows/`` -- collapsing ``..``/``.`` but + never resolving symlinks, so a symlinked ``workflow.yml`` leaf (or + symlinked ```` directory) inside the owned tree is caught by the + inward-symlink refusal below rather than silently followed. + 2. Via an intermediate alias target whose lexical path identifies + ``.specify/workflows/`` before a symlinked storage ancestor is + resolved away. + 3. Via an outward-pointing alias whose fully resolved target lands + inside legitimate installed storage, even though the raw invocation + path has no ownership segments. + + Returns ``(None, None)`` when neither applies -- a genuinely standalone + external workflow file, which is allowed to run unchecked. + """ + def ownership_for(candidate: Path) -> tuple[Path, str] | None: + parts = candidate.parts + i = _scan_for_workflow_owner(parts) + if i is None: + return None + registry_root = ( + Path(*parts[:i]) if i else Path(candidate.anchor or ".") + ) + candidate_specify = Path(*parts[: i + 1]) + candidate_workflows = Path(*parts[: i + 2]) + candidate_id_dir = Path(*parts[: i + 3]) + canonical_specify = registry_root / ".specify" + canonical_workflows = canonical_specify / "workflows" + # The path-derived registry_root here may differ from the cwd's + # project_root already checked by _reject_unsafe_workflow_storage + # (e.g. this path points into another project entirely, or this + # project's own .specify is itself a symlink to an + # attacker-controlled tree) -- check it explicitly rather than + # trusting that cwd-scoped guard, and don't rely on + # WorkflowRegistry's own symlinked-parent handling as the safety + # signal here: it fails closed by raising OSError at construction + # time (see catalog.py's _load), but that surfaces as an opaque + # exception rather than this guard's clean, specific CLI error for + # the actual owning project root. + cli._reject_unsafe_dir(canonical_specify, ".specify") + cli._reject_unsafe_dir(canonical_workflows, ".specify/workflows") + cli._reject_unsafe_dir(candidate_specify, ".specify") + cli._reject_unsafe_dir(candidate_workflows, ".specify/workflows") + try: + if not os.path.samefile(candidate_specify, canonical_specify): + return None + if not os.path.samefile( + candidate_workflows, canonical_workflows + ): + return None + except OSError: + return None + registry = cli._open_workflow_registry(registry_root, err) + registered_id = None + for workflow_id in registry.list(): + if ( + not isinstance(workflow_id, str) + or workflow_id in cli._RESERVED_WORKFLOW_IDS + or not cli._WORKFLOW_ID_PATTERN.fullmatch(workflow_id) + ): + continue + try: + if os.path.samefile( + candidate_id_dir, + canonical_workflows / workflow_id, + ): + registered_id = workflow_id + break + except OSError: + continue + if registered_id is None: + return None + # A legitimately installed workflow's own directory tree never + # contains a symlink (workflow add/remove both refuse one at + # install time); one appearing here means the file actually loaded + # below would not be the file this ownership match is based on, so + # refuse rather than silently mismatch. + for k in range(i + 2, len(parts) + 1): + if Path(*parts[:k]).is_symlink(): + err.print( + "[red]Error:[/red] Refusing to run: " + f".specify/workflows/{cli._escape_markup(registered_id)} " + "contains a symlinked path component" + ) + raise cli.typer.Exit(1) + return registry_root, registered_id + + lexical = Path(os.path.normpath(str(source_path.absolute()))) + ownership = ownership_for(lexical) + if ownership is not None: + return ownership + + # Inspect each intermediate symlink target before fully resolving it. + # Full resolution can erase .specify/workflows ownership segments when + # one of those storage directories is itself a symlink. + candidate = lexical + seen = {candidate} + for _ in range(40): + expanded = _expand_first_symlink_target(candidate) + if expanded is None or expanded in seen: + break + ownership = ownership_for(expanded) + if ownership is not None: + return ownership + seen.add(expanded) + candidate = expanded + + # A fully resolved target may still land in legitimate installed + # storage through an unrelated-looking alias. + try: + resolved = source_path.resolve(strict=False) + except (OSError, RuntimeError): + return None, None + if resolved == lexical: + # Nothing on this path is a symlink; already covered above. + return None, None + ownership = ownership_for(resolved) + return ownership if ownership is not None else (None, None) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 7691066714..940fa61635 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1,9 +1,7 @@ -"""specify workflow * command handlers — app objects and register(). +"""Shared infrastructure and registration for ``specify workflow`` commands. -Moved out of __init__.py (PR-8/8). Handlers reference `_require_specify_project` -(kept in the package root) through the thin shim below, which re-fetches from -the parent package at call time so test monkeypatching of -`specify_cli._require_specify_project` keeps working. +Decorated handlers live in command modules matching the CLI surface. Thin +forwarders preserve established direct-import and monkeypatch boundaries. """ from __future__ import annotations @@ -12,7 +10,7 @@ import os import re import sys -from pathlib import Path, PurePosixPath +from pathlib import Path, PurePosixPath as PurePosixPath from typing import Any import typer @@ -23,14 +21,14 @@ from .._download_security import ( archive_format_from_content_type, archive_format_from_name, - archive_suffix, + archive_suffix as archive_suffix, detect_archive_format, is_https_or_localhost_http, is_safe_download_redirect, read_response_limited, safe_extract_archive, ) -from .._project import _resolve_init_dir_override +from .._project import _resolve_init_dir_override as _resolve_init_dir_override from ..shared_infra import verify_archive_sha256 workflow_app = typer.Typer( @@ -39,34 +37,6 @@ add_completion=False, ) -workflow_catalog_app = typer.Typer( - name="catalog", - help="Manage workflow catalogs", - add_completion=False, -) -workflow_app.add_typer(workflow_catalog_app, name="catalog") - -workflow_step_app = typer.Typer( - name="step", - help="Manage workflow step types", - add_completion=False, -) -workflow_app.add_typer(workflow_step_app, name="step") - -workflow_step_catalog_app = typer.Typer( - name="catalog", - help="Manage step catalogs", - add_completion=False, -) -workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog") - -workflow_overlay_app = typer.Typer( - name="overlay", - help="Manage workflow overlays", - add_completion=False, -) -workflow_app.add_typer(workflow_overlay_app, name="overlay") - def _error_console(json_output: bool): """Console for error text: stderr under ``--json`` so the JSON stdout @@ -115,55 +85,13 @@ def _require_enabled_workflow( return metadata is not None -def _path_has_symlink_component(path: Path) -> bool: - """Return whether any component of an absolute path is a symlink.""" - absolute = Path(os.path.abspath(path)) - current = Path(absolute.anchor) - for part in absolute.parts[1:]: - current /= part - if current.is_symlink(): - return True - return False - - -def _same_existing_path(left: Path, right: Path) -> bool: - """Return whether two existing paths identify the same filesystem entry.""" - try: - return os.path.samefile(left, right) - except OSError: - return left == right - - def _resolve_run_owner_root( installed_registry_root: str | None, project_root: Path ) -> Path: - """Determine which project's registry gates resuming a run. - - ``installed_registry_root`` is only ever persisted when the run's - installed workflow genuinely belongs to a *different* project than the - one whose ``runs/`` directory holds this run's own state (a direct - external workflow-file invocation) -- see ``workflow_run``. The common - case (an installed workflow run from its own project) stores ``None``, - so a later project rename/move is transparently picked up here by - falling back to the *current* ``project_root`` instead of a stale - absolute path baked in at run start. - - A persisted cross-project root that no longer exists cannot be safely - rediscovered and must fail closed instead of consulting the unrelated - project that happens to store the run state. - """ - if installed_registry_root: - candidate = Path(installed_registry_root) - if ( - candidate.is_absolute() - and not _path_has_symlink_component(candidate) - and candidate.is_dir() - ): - return candidate - raise ValueError( - "Installed workflow owner is unavailable; cannot safely resume" - ) - return project_root + """Forward to the resume-private owner-state resolver.""" + from ._command_resume_state import _resolve_run_owner_root as resolver + + return resolver(installed_registry_root, project_root) def _parse_input_values( @@ -216,175 +144,14 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None: ) -def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None: - """Find the *nearest* (innermost) ``.specify/workflows/`` owner in - *parts*, scanning from the end of the path. - - Scanning from the end (rather than stopping at the first match from the - start) matters for a project nested beneath an unrelated outer path that - happens to reuse the same ``.specify``/``workflows`` segment names: the - first-from-start match would pick the outer directory and the wrong - workflow ID, silently missing the real (inner) owner's disabled check. - - Returns the index of the owning ``.specify`` segment, or ``None`` if no - owner segment is present. - """ - for i in range(len(parts) - 3, -1, -1): - if ( - parts[i].casefold() == ".specify" - and parts[i + 1].casefold() == "workflows" - ): - return i - return None - - -def _expand_first_symlink_target(path: Path) -> Path | None: - """Expand one symlink component while preserving the remaining path.""" - parts = path.parts - current = Path(path.anchor) if path.is_absolute() else Path() - start = 1 if path.is_absolute() else 0 - for index in range(start, len(parts)): - current = current / parts[index] - if not current.is_symlink(): - continue - try: - target = Path(os.readlink(current)) - except OSError: - return None - if not target.is_absolute(): - target = current.parent / target - expanded = target.joinpath(*parts[index + 1 :]) - return Path(os.path.normpath(str(expanded.absolute()))) - return None - - def _resolve_installed_workflow_ownership( source_path: Path, err ) -> tuple[Path | None, str | None]: - """Map a direct ``workflow.yml`` *source_path* back to the installed - workflow (``registry_root``, ``registered_id``) it belongs to, if any. - - A registered path can point at installed storage three ways, all of - which must receive the same registry disabled-check: - - 1. Lexically: the path's own (symlink-preserving) segments identify - ``.specify/workflows/`` -- collapsing ``..``/``.`` but - never resolving symlinks, so a symlinked ``workflow.yml`` leaf (or - symlinked ```` directory) inside the owned tree is caught by the - inward-symlink refusal below rather than silently followed. - 2. Via an intermediate alias target whose lexical path identifies - ``.specify/workflows/`` before a symlinked storage ancestor is - resolved away. - 3. Via an outward-pointing alias whose fully resolved target lands - inside legitimate installed storage, even though the raw invocation - path has no ownership segments. - - Returns ``(None, None)`` when neither applies -- a genuinely standalone - external workflow file, which is allowed to run unchecked. - """ - def ownership_for(candidate: Path) -> tuple[Path, str] | None: - parts = candidate.parts - i = _scan_for_workflow_owner(parts) - if i is None: - return None - registry_root = ( - Path(*parts[:i]) if i else Path(candidate.anchor or ".") - ) - candidate_specify = Path(*parts[: i + 1]) - candidate_workflows = Path(*parts[: i + 2]) - candidate_id_dir = Path(*parts[: i + 3]) - canonical_specify = registry_root / ".specify" - canonical_workflows = canonical_specify / "workflows" - # The path-derived registry_root here may differ from the cwd's - # project_root already checked by _reject_unsafe_workflow_storage - # (e.g. this path points into another project entirely, or this - # project's own .specify is itself a symlink to an - # attacker-controlled tree) -- check it explicitly rather than - # trusting that cwd-scoped guard, and don't rely on - # WorkflowRegistry's own symlinked-parent handling as the safety - # signal here: it fails closed by raising OSError at construction - # time (see catalog.py's _load), but that surfaces as an opaque - # exception rather than this guard's clean, specific CLI error for - # the actual owning project root. - _reject_unsafe_dir(canonical_specify, ".specify") - _reject_unsafe_dir(canonical_workflows, ".specify/workflows") - _reject_unsafe_dir(candidate_specify, ".specify") - _reject_unsafe_dir(candidate_workflows, ".specify/workflows") - try: - if not os.path.samefile(candidate_specify, canonical_specify): - return None - if not os.path.samefile( - candidate_workflows, canonical_workflows - ): - return None - except OSError: - return None - registry = _open_workflow_registry(registry_root, err) - registered_id = None - for workflow_id in registry.list(): - if ( - not isinstance(workflow_id, str) - or workflow_id in _RESERVED_WORKFLOW_IDS - or not _WORKFLOW_ID_PATTERN.fullmatch(workflow_id) - ): - continue - try: - if os.path.samefile( - candidate_id_dir, - canonical_workflows / workflow_id, - ): - registered_id = workflow_id - break - except OSError: - continue - if registered_id is None: - return None - # A legitimately installed workflow's own directory tree never - # contains a symlink (workflow add/remove both refuse one at - # install time); one appearing here means the file actually loaded - # below would not be the file this ownership match is based on, so - # refuse rather than silently mismatch. - for k in range(i + 2, len(parts) + 1): - if Path(*parts[:k]).is_symlink(): - err.print( - "[red]Error:[/red] Refusing to run: " - f".specify/workflows/{_escape_markup(registered_id)} " - "contains a symlinked path component" - ) - raise typer.Exit(1) - return registry_root, registered_id - - lexical = Path(os.path.normpath(str(source_path.absolute()))) - ownership = ownership_for(lexical) - if ownership is not None: - return ownership - - # Inspect each intermediate symlink target before fully resolving it. - # Full resolution can erase .specify/workflows ownership segments when - # one of those storage directories is itself a symlink. - candidate = lexical - seen = {candidate} - for _ in range(40): - expanded = _expand_first_symlink_target(candidate) - if expanded is None or expanded in seen: - break - ownership = ownership_for(expanded) - if ownership is not None: - return ownership - seen.add(expanded) - candidate = expanded - - # A fully resolved target may still land in legitimate installed - # storage through an unrelated-looking alias. - try: - resolved = source_path.resolve(strict=False) - except (OSError, RuntimeError): - return None, None - if resolved == lexical: - # Nothing on this path is a symlink; already covered above. - return None, None - ownership = ownership_for(resolved) - return ownership if ownership is not None else (None, None) + """Forward to the run-private installed-workflow ownership resolver.""" + from ._command_run_ownership import ( + _resolve_installed_workflow_ownership as resolver, + ) + return resolver(source_path, err) _WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") @@ -408,12 +175,6 @@ def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None: # a ceiling any legitimate workflow definition should ever approach. _MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB _DOWNLOAD_CHUNK_SIZE = 65536 -# Custom step packages contain executable Python, metadata, and optional helper -# files downloaded one-by-one rather than as an archive. Mirror the archive -# ceilings so a catalog cannot turn individually valid files into an unbounded -# aggregate download. -_MAX_STEP_PACKAGE_FILES = 512 -_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes: @@ -955,11 +716,6 @@ def _validate_local_workflow_package(package_dir: Path) -> None: raise ValueError(f"Workflow package contains unsupported file: {path}") -def _workflow_package_has_companions(package_dir: Path) -> bool: - """Return whether a directory contains anything beyond workflow.yml.""" - return any(path.name != "workflow.yml" for path in package_dir.iterdir()) - - def _install_workflow_package( project_root: Path, workflows_dir: Path, @@ -1305,2597 +1061,473 @@ def _stdout_to_stderr_when(active: bool): os.close(saved_stdout_fd) -@workflow_app.command("run") -def workflow_run( - source: str = typer.Argument(..., help="Workflow ID or YAML file path"), - input_values: list[str] | None = typer.Option( - None, "--input", "-i", help="Input values as key=value pairs" - ), - json_output: bool = typer.Option( - False, - "--json", - help="Emit the run outcome as a single JSON object instead of formatted text.", - ), -): - """Run a workflow from an installed ID or local YAML path.""" - from . import load_custom_steps - from .engine import WorkflowEngine - - 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 — 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() - - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) - if not json_output: - # Escape the literal bracket (\[) so Rich renders `[]` instead - # of parsing it as a style tag named after the step id -- which it - # silently swallows (losing the only identifying content on the line), - # applies as formatting when the id happens to be a real style such as - # `bold`, or raises MarkupError when the id forms a closing tag (`/`), - # failing the whole run. Escape the interpolated values too, since both - # come from workflow YAML. Mirrors the `\[]` step-graph precedent - # in workflow_info below. - engine.on_step_start = lambda sid, label: console.print( - f" \u25b8 \\[{_escape_markup(str(sid))}] " - f"{_escape_markup(str(label))} \u2026" - ) - - err = _error_console(json_output) - - registered_id: str | None = None - registry_root = project_root - if not is_file_source: - # Reject path-equivalent spellings ("align-wf/", "align-wf/.") that - # would miss the registry lookup yet still load the installed file, - # bypassing the disabled check below. - if source in _RESERVED_WORKFLOW_IDS or not _WORKFLOW_ID_PATTERN.fullmatch(source): - err.print( - f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(source))}" - ) - raise typer.Exit(1) - registered_id = source - else: - # A direct YAML path may still point at an installed workflow's own - # file (lexically, or via a symlinked alias pointing into installed - # storage); map it back to its owning project and ID so the - # disabled check below can't be silently bypassed. - owner_root, owner_id = _resolve_installed_workflow_ownership(source_path, err) - if owner_id is not None: - registry_root = owner_root - registered_id = owner_id - - if registered_id is not None: - _require_enabled_workflow(registry_root, registered_id, err) +def _install_workflow_from_catalog( + project_root: Path, + workflows_dir: Path, + workflow_id: str, + expected_version: str | None = None, + expected_installed_version: str | None = None, +) -> None: + """Download, validate, and register a catalog workflow. - try: - definition = engine.load_workflow(source_path if is_file_source else source) - except FileNotFoundError: - err.print(f"[red]Error:[/red] Workflow not found: {source}") - raise typer.Exit(1) - except ValueError as exc: - err.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}") - raise typer.Exit(1) + Shared by ``workflow add`` and ``workflow update``. Raises ``typer.Exit`` + on any failure; the registry entry is only written on full success. + ``expected_version``, when given, rejects a downloaded workflow whose + version does not match the catalog version that triggered the install. + ``expected_installed_version``, when given by ``workflow update``, aborts + if another process changes the installed source or version before commit. + """ + from .catalog import WorkflowCatalog, WorkflowCatalogError + from .engine import WorkflowDefinition - # Validate - errors = engine.validate(definition) - if errors: - err.print("[red]Workflow validation failed:[/red]") - for verr in errors: - err.print(f" • {_escape_markup(str(verr))}") - raise typer.Exit(1) + def versions_match(actual: object, expected: str) -> bool: + from packaging import version as pkg_version - # Parse inputs - inputs = _parse_input_values(input_values, json_output=json_output) + try: + return pkg_version.Version(str(actual)) == pkg_version.Version( + expected + ) + except pkg_version.InvalidVersion: + return str(actual) == expected - if not json_output: - console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") - console.print(f"[dim]Version: {definition.version}[/dim]\n") + safe_wf_id = _escape_markup(workflow_id) + catalog = WorkflowCatalog(project_root) try: - with _stdout_to_stderr_when(json_output): - state = engine.execute( - definition, - inputs, - installed_workflow_id=registered_id, - # Only persist an explicit root when the installed workflow - # genuinely belongs to a *different* project than the one - # whose runs/ directory holds this run's own state (a - # direct external workflow-file invocation) -- the common - # case (an installed workflow run from its own project) - # leaves this None so resume re-derives the owning root - # from wherever the project currently is, transparently - # surviving a project rename/move instead of baking in a - # stale absolute path at run start. - installed_registry_root=( - registry_root.resolve(strict=True) - if registered_id - and not _same_existing_path(registry_root, project_root) - else None - ), - ) - except ValueError as exc: - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - except Exception as exc: - err.print(f"[red]Workflow failed:[/red] {_escape_markup(str(exc))}") + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") raise typer.Exit(1) - if json_output: - _emit_workflow_json(_workflow_run_payload(state)) - raise typer.Exit(_run_outcome_exit_code(state.status.value)) - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") - console.print(f"[dim]Run ID: {state.run_id}[/dim]") - - err_msg = _failed_step_error(state) - if err_msg: - console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}") - - if state.status.value == "paused": - console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") - - raise typer.Exit(_run_outcome_exit_code(state.status.value)) - - -@workflow_app.command("resume") -def workflow_resume( - run_id: str = typer.Argument(..., help="Run ID to resume"), - input_values: list[str] | None = typer.Option( - None, "--input", "-i", help="Updated input values as key=value pairs" - ), - json_output: bool = typer.Option( - False, - "--json", - help="Emit the resume outcome as a single JSON object instead of formatted text.", - ), -): - """Resume a paused or failed workflow run.""" - from . import load_custom_steps - from .engine import RunState, WorkflowEngine - - project_root = _require_specify_project() - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) - if not json_output: - # Escape the literal bracket (\[) so Rich renders `[]` instead - # of parsing it as a style tag named after the step id -- which it - # silently swallows (losing the only identifying content on the line), - # applies as formatting when the id happens to be a real style such as - # `bold`, or raises MarkupError when the id forms a closing tag (`/`), - # failing the whole run. Escape the interpolated values too, since both - # come from workflow YAML. Mirrors the `\[]` step-graph precedent - # in workflow_info below. - engine.on_step_start = lambda sid, label: console.print( - f" \u25b8 \\[{_escape_markup(str(sid))}] " - f"{_escape_markup(str(label))} \u2026" - ) - - inputs = _parse_input_values(input_values, json_output=json_output) - err = _error_console(json_output) + if not info: + console.print(f"[red]Error:[/red] Workflow '{safe_wf_id}' not found in catalog") + raise typer.Exit(1) - # Pre-load the persisted run state so a run started from an installed - # workflow that has since been disabled cannot resume unchecked -- - # engine.resume() replays the run directly from disk with no registry - # awareness at all, which would otherwise bypass the same disabled - # guard `workflow run` enforces. Runs without installed_workflow_id - # (a direct/non-installed source, or a run persisted before this field - # existed) are unaffected and resume exactly as before. - try: - pre_state = RunState.load(run_id, project_root) - except FileNotFoundError: - err.print(f"[red]Error:[/red] Run not found: {run_id}") + if not info.get("_install_allowed", True): + console.print(f"[yellow]Warning:[/yellow] Workflow '{safe_wf_id}' is from a discovery-only catalog") + console.print("Direct installation is not enabled for this catalog source.") raise typer.Exit(1) - except ValueError as exc: - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{safe_wf_id}' does not have an install URL in the catalog") raise typer.Exit(1) - except OSError as exc: - err.print(f"[red]Resume failed:[/red] {_escape_markup(str(exc))}") + if not isinstance(workflow_url, str): + # Untrusted catalog payload; a non-string would crash urlparse below. + console.print( + f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL." + ) raise typer.Exit(1) - if pre_state.installed_workflow_id is not None: - try: - owner_root = _resolve_run_owner_root( - pre_state.installed_registry_root, project_root - ) - except ValueError as exc: - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - _require_enabled_workflow( - owner_root, pre_state.installed_workflow_id, err - ) - elif not pre_state.installed_origin_tracked: - if _require_enabled_workflow( - project_root, pre_state.workflow_id, err - ): - pre_state.installed_workflow_id = pre_state.workflow_id - pre_state.installed_origin_tracked = True - try: - pre_state.save() - except OSError as exc: - err.print(f"[red]Resume failed:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from urllib.parse import urlparse try: - with _stdout_to_stderr_when(json_output): - state = engine.resume(run_id, inputs or None) - except FileNotFoundError: - err.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) - except ValueError as exc: - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + parsed_url = urlparse(workflow_url) + parsed_url.port + except ValueError: + console.print( + f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL." + ) raise typer.Exit(1) - except Exception as exc: - err.print(f"[red]Resume failed:[/red] {_escape_markup(str(exc))}") + if not is_https_or_localhost_http(workflow_url): + console.print( + f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." + ) raise typer.Exit(1) - if json_output: - _emit_workflow_json(_workflow_run_payload(state)) - raise typer.Exit(_run_outcome_exit_code(state.status.value)) - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") - - err_msg = _failed_step_error(state) - if err_msg: - console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}") - - raise typer.Exit(_run_outcome_exit_code(state.status.value)) - - -@workflow_app.command("status") -def workflow_status( - run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), - json_output: bool = typer.Option( - False, - "--json", - help="Emit run status as a single JSON object instead of formatted text.", - ), -): - """Show workflow run status.""" - from .engine import WorkflowEngine - - project_root = _require_specify_project() - engine = WorkflowEngine(project_root) - - if run_id: - # Route errors to stderr under --json so the stdout JSON stream stays - # parseable (mirrors `workflow run`/`workflow resume`); both handlers - # fire before the json_output branch below. - err = _error_console(json_output) - try: - from .engine import RunState - state = RunState.load(run_id, project_root) - except FileNotFoundError: - err.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) - except ValueError as exc: - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - except OSError as exc: - # An unreadable state.json (bad permissions, a directory in its - # place, I/O error) must fail as cleanly as the malformed-JSON - # case above -- `workflow resume` already handles OSError here. - err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - - if json_output: - # Build on the shared run/resume payload so the common fields - # (including current_step_index) stay identical across commands. - payload = { - **_workflow_run_payload(state), - "created_at": state.created_at, - "updated_at": state.updated_at, - "steps": { - sid: sd.get("status", "unknown") - for sid, sd in state.step_results.items() - }, - } - _emit_workflow_json(payload) - return - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - "running": "blue", - "created": "dim", - } - color = status_colors.get(state.status.value, "white") - - console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") - console.print(f" Workflow: {state.workflow_id}") - console.print(f" Status: [{color}]{state.status.value}[/{color}]") - console.print(f" Created: {state.created_at}") - console.print(f" Updated: {state.updated_at}") - - if state.current_step_id: - console.print(f" Current: {state.current_step_id}") - - err_msg = _failed_step_error(state) - if err_msg: - console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]") - - if state.step_results: - console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") - for step_id, step_data in state.step_results.items(): - s = step_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") - console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") - else: - runs = engine.list_runs() - - if json_output: - payload = { - "runs": [ - { - "run_id": r["run_id"], - "workflow_id": r.get("workflow_id"), - "status": r.get("status", "unknown"), - "updated_at": r.get("updated_at"), - } - for r in runs - ] - } - _emit_workflow_json(payload) - return - - if not runs: - console.print("[yellow]No workflow runs found.[/yellow]") - return - - console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") - for run_data in runs: - s = run_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") - console.print( - f" [{sc}]●[/{sc}] {run_data['run_id']} " - f"{run_data.get('workflow_id', '?')} " - f"[{sc}]{s}[/{sc}] " - f"[dim]{run_data.get('updated_at', '?')}[/dim]" - ) - - -@workflow_app.command("list") -def workflow_list(): - """List installed workflows.""" - project_root = _require_specify_project() - registry = _open_workflow_registry(project_root) - installed = registry.list() + # Reject path traversal, symlinked , and a symlinked workflow.yml leaf + # before any mkdir/download writes beneath the install directory. + workflow_dir = _safe_workflow_id_dir(workflows_dir, workflow_id) + workflow_file = workflow_dir / "workflow.yml" - if not installed: - console.print("[yellow]No workflows installed.[/yellow]") - console.print("\nInstall a workflow with:") - console.print(" [cyan]specify workflow add [/cyan]") - return + # Captured before any mkdir/download writes so every failure branch below + # can tell a fresh install from a reinstall-over-an-existing-one, + # mirroring _validate_and_install_local's existed-before-aware cleanup. + existed_before = workflow_dir.is_dir() - console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") - for wf_id, wf_data in installed.items(): - safe_id = _escape_markup(wf_id) - if not isinstance(wf_data, dict): - console.print(f" [yellow]Warning:[/yellow] Skipping corrupted registry entry '{safe_id}'.\n") - continue - marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" - name = _escape_markup(str(wf_data.get("name", wf_id))) - version = _escape_markup(str(wf_data.get("version", "?"))) - console.print(f" [bold]{name}[/bold] ({safe_id}) v{version}{marker}") - desc = wf_data.get("description", "") - if desc: - console.print(f" {_escape_markup(str(desc))}") - console.print() - - -def _cleanup_download_tmp_path(tmp_path: Path | None) -> None: - """Best-effort unlink of a partially-downloaded workflow temp file. - - A cleanup ``OSError`` here must never replace/mask whatever error or - interrupt is already propagating -- warn about it and keep going. - """ - if tmp_path is None: - return try: - tmp_path.unlink(missing_ok=True) - except OSError as cleanup_exc: + staged_file = _stage_workflow_file( + workflow_dir, + use_project_file_mode=not workflow_file.exists(), + ) + except OSError as exc: console.print( - "[yellow]Warning:[/yellow] Could not remove temporary " - f"workflow download file: {_escape_markup(str(cleanup_exc))} " - f"(path: {_escape_markup(str(tmp_path))})" + f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: " + f"{_escape_markup(str(exc))}" ) + raise typer.Exit(1) + original_workflow_url = workflow_url + downloaded_archive_format = None + archive_content_type = None + try: + from specify_cli.authentication.http import open_url as _open_url + from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts + from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset -@workflow_app.command("add") -def workflow_add( - source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), - dev: bool = typer.Option(False, "--dev", help="Install from a local workflow YAML file or directory"), - from_url: str | None = typer.Option(None, "--from", help="Install from a custom URL"), -): - """Install a workflow from catalog, URL, or local path.""" - from . import load_custom_steps - from .engine import WorkflowDefinition - - project_root = _require_specify_project() - load_custom_steps(project_root) - _open_workflow_registry(project_root) - workflows_dir = project_root / ".specify" / "workflows" - # With --from, source names the expected workflow ID: validate it up - # front so a URL/path/typo fails without a network fetch. - if from_url is not None and not dev: - _validate_workflow_id_or_exit(source) - # Reject a symlinked .specify / .specify/workflows before any write so an - # install can't escape the project root (covers the local, URL, and - # catalog branches below — all write beneath workflows_dir). - _reject_unsafe_dir(project_root / ".specify", ".specify") - _reject_unsafe_dir(workflows_dir, ".specify/workflows") + _wf_cat_extra_headers = None + _resolved_workflow_url = _resolve_gh_asset( + workflow_url, + _open_url, + timeout=30, + github_hosts=_github_provider_hosts(), + redirect_validator=_reject_insecure_download_redirect, + ) + if _resolved_workflow_url: + workflow_url = _resolved_workflow_url + _wf_cat_extra_headers = {"Accept": "application/octet-stream"} - def _validate_and_install_local( - yaml_path: Path, source_label: str, expected_id: str | None = None - ) -> None: - """Validate and install a workflow from a local YAML file.""" - try: - with yaml_path.open("rb") as source_file: - source_mode = os.fstat(source_file.fileno()).st_mode & 0o7777 - source_content = source_file.read() - definition = WorkflowDefinition.from_string( - source_content.decode("utf-8") + with _open_url( + workflow_url, + timeout=30, + extra_headers=_wf_cat_extra_headers, + redirect_validator=_reject_insecure_download_redirect, + ) as response: + # Validate final URL after redirects + final_url = response.geturl() + if not is_https_or_localhost_http(final_url): + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print( + f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}" + ) + raise typer.Exit(1) + archive_content_type = ( + response.getheader("Content-Type") + if hasattr(response, "getheader") + else None ) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to read workflow YAML: " - f"{_escape_markup(str(exc))}" + downloaded_archive_format = ( + archive_format_from_name(final_url) + or archive_format_from_name(original_workflow_url) + or archive_format_from_content_type(archive_content_type) ) - raise typer.Exit(1) - except (UnicodeDecodeError, ValueError, yaml.YAMLError) as exc: - console.print(f"[red]Error:[/red] Invalid workflow YAML: {_escape_markup(str(exc))}") - raise typer.Exit(1) - # Non-string ids (e.g. unquoted ``id: 123`` or ``id: 0``) fall through - # to validate_workflow below, which reports a typed error instead of - # crashing on ``.strip()`` here. Only None/empty/whitespace-only ids - # are rejected as missing. - if ( - definition.id is None - or definition.id == "" - or (isinstance(definition.id, str) and not definition.id.strip()) - ): - console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") - raise typer.Exit(1) - - from .engine import validate_workflow - errors = validate_workflow(definition) - if errors: - console.print("[red]Error:[/red] Workflow validation failed:") - for err in errors: - console.print(f" \u2022 {_escape_markup(str(err))}") - raise typer.Exit(1) - - if expected_id is not None and definition.id != expected_id: - console.print( - f"[red]Error:[/red] Workflow ID in YAML ({_escape_markup(repr(definition.id))}) " - f"does not match the requested workflow ID ({_escape_markup(repr(expected_id))})." - ) - raise typer.Exit(1) - - dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id) - dest_file = dest_dir / "workflow.yml" - existed_before = dest_dir.is_dir() - - try: - staged_file = _stage_workflow_file(dest_dir) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to install workflow " - f"'{_escape_markup(definition.id)}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - try: - # Write the exact bytes parsed above so a concurrent source edit - # cannot desynchronize installed content from validated metadata. - staged_file.write_bytes(source_content) - staged_file.set_mode(source_mode) - except OSError as exc: - _safe_discard_staged_workflow_file(staged_file, dest_dir, existed_before) - console.print( - f"[red]Error:[/red] Failed to install workflow " - f"'{_escape_markup(definition.id)}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - try: - transaction = _workflow_install_transaction(project_root) - with transaction: - transaction_existed_before = existed_before or dest_file.exists() - transaction_registry = _open_workflow_registry(project_root) - # Commit the staged copy onto dest_file via an atomic swap. A - # prior file is renamed aside so registry failure can restore it. - try: - backup_file = _commit_workflow_file( - staged_file, dest_file, transaction_existed_before - ) - except OSError as exc: - _safe_discard_staged_workflow_file( - staged_file, dest_dir, existed_before - ) - console.print( - f"[red]Error:[/red] Failed to install workflow " - f"'{_escape_markup(definition.id)}': " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - try: - entry = { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": source_label, - } - existing = transaction_registry.get(definition.id) - if isinstance(existing, dict) and not existing.get( - "enabled", True - ): - entry["enabled"] = False - transaction_registry.add(definition.id, entry) - except (OSError, TypeError, ValueError) as exc: - _safe_rollback_committed_workflow_file( - dest_file, - dest_dir, - transaction_existed_before, - backup_file, - ) - console.print( - f"[red]Error:[/red] Failed to update workflow registry for " - f"'{_escape_markup(definition.id)}': " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - # Registry update succeeded while the transaction lock is held. - _discard_committed_backup_file(backup_file) - except typer.Exit: - _safe_discard_staged_workflow_file( - staged_file, dest_dir, existed_before - ) - raise - except OSError as exc: - _safe_discard_staged_workflow_file(staged_file, dest_dir, existed_before) - console.print( - f"[red]Error:[/red] Failed to lock workflow install " - f"'{_escape_markup(definition.id)}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - console.print( - f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' " - f"({_escape_markup(definition.id)}) installed" - ) - - # Explicit local install (mirrors `extension add --dev`). --dev takes - # precedence over --from so a URL that would be ignored is never fetched. - if dev: - dev_path = Path(source).expanduser() - if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"): - _validate_and_install_local(dev_path, str(dev_path)) - return - if dev_path.is_file() and archive_format_from_name(str(dev_path)) is not None: - import tempfile - - with tempfile.TemporaryDirectory( - prefix="speckit-workflow-archive-" - ) as tmpdir: - extracted_root = Path(tmpdir) - try: - safe_extract_archive(dev_path, extracted_root) - package_root = _workflow_package_root(extracted_root) - except ValueError as exc: - console.print( - f"[red]Error:[/red] Invalid workflow archive: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - _install_workflow_package( - project_root, - workflows_dir, - package_root, - str(dev_path), - ) - return - if dev_path.is_dir(): - dev_wf_file = dev_path / "workflow.yml" - if not dev_wf_file.is_file(): - console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}") - raise typer.Exit(1) - if _workflow_package_has_companions(dev_path): - _install_workflow_package( - project_root, - workflows_dir, - dev_path, - str(dev_path), + # Written to the staging file, never workflow_file directly, so a + # reinstall's prior working copy is never touched until the + # atomic commit below runs. + if downloaded_archive_format is not None: + downloaded_content = read_response_limited( + response, + error_type=ValueError, + label=f"workflow '{workflow_id}' archive download", ) + elif _workflow_yaml_is_declared(final_url, archive_content_type): + downloaded_content = _read_response_within_limit(response) else: - _validate_and_install_local(dev_wf_file, str(dev_path)) - return - console.print( - "[red]Error:[/red] --dev source must be a workflow YAML file, " - "supported archive, or directory containing workflow.yml: " - f"{_escape_markup(source)}" - ) + downloaded_content = read_response_limited( + response, + error_type=ValueError, + label=f"workflow '{workflow_id}' download", + ) + downloaded_archive_format = _sniff_workflow_archive_format( + downloaded_content + ) + if downloaded_archive_format is None: + _enforce_workflow_yaml_size(downloaded_content) + staged_file.write_bytes(downloaded_content) + except typer.Exit: + raise + except Exception as exc: + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}") raise typer.Exit(1) - # Try as URL (http/https) — either the positional source is a URL, or an - # explicit --from URL names where to fetch it (mirrors `extension add --from`). - download_url = ( - from_url - if from_url is not None - else (source if source.startswith(("http://", "https://")) else None) - ) - if download_url is not None: - from urllib.parse import urlparse - from specify_cli.authentication.http import open_url as _open_url - + if downloaded_archive_format is not None: try: - urlparse(download_url).port - except ValueError: - console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}") - raise typer.Exit(1) - if not is_https_or_localhost_http(download_url): - console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") - raise typer.Exit(1) - - if from_url is not None: - from rich.panel import Panel - - safe_url = _escape_markup(from_url) - console.print() - console.print( - Panel( - "[bold]You are installing a workflow from an external URL " - "that is not\nlisted in any of your configured workflow " - "catalogs.[/bold]\n\n" - f"URL: {safe_url}\n\n" - "Only install workflows from sources you trust.", - title="[bold yellow]⚠ Untrusted Source[/bold yellow]", - border_style="yellow", - padding=(1, 2), - ) + verify_archive_sha256( + downloaded_content, + info.get("sha256"), + workflow_id, + ValueError, ) - console.print() - if not typer.confirm("Continue with installation?", default=False): - console.print("Cancelled") - raise typer.Exit(0) - - from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset - from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts - - _wf_url_extra_headers = None - _resolved_wf_url = _resolve_gh_asset( - download_url, - _open_url, - timeout=30, - github_hosts=_github_provider_hosts(), - redirect_validator=_reject_insecure_download_redirect, - ) - if _resolved_wf_url: - download_url = _resolved_wf_url - _wf_url_extra_headers = {"Accept": "application/octet-stream"} - - import tempfile - tmp_path: Path | None = None - downloaded_archive_format = None - try: - with _open_url( - download_url, - timeout=30, - extra_headers=_wf_url_extra_headers, - redirect_validator=_reject_insecure_download_redirect, - ) as resp: - final_url = resp.geturl() - if not is_https_or_localhost_http(final_url): - console.print( - f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}" - ) - raise typer.Exit(1) - content_type = ( - resp.getheader("Content-Type") - if hasattr(resp, "getheader") - else None - ) - downloaded_archive_format = ( - archive_format_from_name(final_url) - or archive_format_from_name(download_url) - or archive_format_from_content_type(content_type) - ) - declared_yaml = _workflow_yaml_is_declared(final_url, content_type) - suffix = ( - archive_suffix(downloaded_archive_format) - if downloaded_archive_format is not None - else ".yml" if declared_yaml else ".download" - ) - with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: - # Assign tmp_path immediately: NamedTemporaryFile(delete=False) - # creates the file on disk right away, before any bytes are - # written, so a failure in the size-limited read below must - # still be able to find and remove it. - tmp_path = Path(tmp.name) - if downloaded_archive_format is not None: - downloaded_content = read_response_limited( - resp, - error_type=ValueError, - label="workflow archive download", - ) - elif declared_yaml: - downloaded_content = _read_response_within_limit(resp) - else: - downloaded_content = read_response_limited( - resp, - error_type=ValueError, - label="workflow download", - ) - downloaded_archive_format = ( - _sniff_workflow_archive_format(downloaded_content) - ) - if downloaded_archive_format is None: - _enforce_workflow_yaml_size(downloaded_content) - tmp.write(downloaded_content) - except typer.Exit: - _cleanup_download_tmp_path(tmp_path) - raise - except Exception as exc: - # A cleanup failure here must never replace/mask the - # original download error below with a raw, unhandled - # OSError -- warn about it and keep going, exactly like the - # later post-install finally cleanup does. - _cleanup_download_tmp_path(tmp_path) - console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}") - raise typer.Exit(1) - except BaseException: - # Covers KeyboardInterrupt and other non-Exception exits: the - # temp file is already created on disk (delete=False) by this - # point, so an interrupt during the size-limited read must still - # unlink it rather than leaking it to the system temp directory. - _cleanup_download_tmp_path(tmp_path) - raise - try: - if downloaded_archive_format is None: - _validate_and_install_local( - tmp_path, - download_url, - expected_id=source if from_url else None, - ) - else: - with tempfile.TemporaryDirectory( - prefix="speckit-workflow-archive-" - ) as extract_dir: - extracted_root = Path(extract_dir) - try: - safe_extract_archive( - tmp_path, - extracted_root, - source_name=final_url, - content_type=content_type, - ) - package_root = _workflow_package_root(extracted_root) - except ValueError as exc: - console.print( - f"[red]Error:[/red] Invalid workflow archive: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - _install_workflow_package( - project_root, - workflows_dir, - package_root, - download_url, - expected_id=source if from_url else None, - ) - finally: - # Best-effort: _validate_and_install_local may already have - # committed the file + registry entry (success) or already - # raised its own clean typer.Exit (failure) by this point -- - # either way, a cleanup OSError here must never mask that - # outcome or surface as its own unhandled failure. Warn instead, - # same as the committed-backup cleanup above. - try: - tmp_path.unlink(missing_ok=True) - except OSError as exc: - console.print( - "[yellow]Warning:[/yellow] Could not remove temporary " - f"workflow download file: {_escape_markup(str(exc))} " - f"(path: {_escape_markup(str(tmp_path))})" - ) - return - - # Try as a local file/directory - source_path = Path(source) - if source_path.exists(): - if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"): - _validate_and_install_local(source_path, str(source_path)) - return - elif ( - source_path.is_file() - and archive_format_from_name(str(source_path)) is not None - ): import tempfile + from io import BytesIO with tempfile.TemporaryDirectory( prefix="speckit-workflow-archive-" - ) as tmpdir: - extracted_root = Path(tmpdir) - try: - safe_extract_archive(source_path, extracted_root) - package_root = _workflow_package_root(extracted_root) - except ValueError as exc: - console.print( - f"[red]Error:[/red] Invalid workflow archive: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - _install_workflow_package( - project_root, - workflows_dir, - package_root, - str(source_path), + ) as extract_dir: + extracted_root = Path(extract_dir) + safe_extract_archive( + staged_file.path, + extracted_root, + archive_file=BytesIO(downloaded_content), + source_name=original_workflow_url, + content_type=archive_content_type, + ) + package_root = _workflow_package_root(extracted_root) + _safe_discard_staged_workflow_file( + staged_file, + workflow_dir, + existed_before, ) - return - elif source_path.is_dir(): - wf_file = source_path / "workflow.yml" - if not wf_file.is_file(): - console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}") - raise typer.Exit(1) - if _workflow_package_has_companions(source_path): _install_workflow_package( project_root, workflows_dir, - source_path, - str(source_path), + package_root, + workflow_url, + expected_id=workflow_id, + expected_version=expected_version, + expected_installed_version=expected_installed_version, + catalog_info={**info, "url": workflow_url}, ) - else: - _validate_and_install_local(wf_file, str(source_path)) - return - - # Try from catalog - _install_workflow_from_catalog(project_root, workflows_dir, source) - - -def _install_workflow_from_catalog( - project_root: Path, - workflows_dir: Path, - workflow_id: str, - expected_version: str | None = None, - expected_installed_version: str | None = None, -) -> None: - """Download, validate, and register a catalog workflow. - - Shared by ``workflow add`` and ``workflow update``. Raises ``typer.Exit`` - on any failure; the registry entry is only written on full success. - ``expected_version``, when given, rejects a downloaded workflow whose - version does not match the catalog version that triggered the install. - ``expected_installed_version``, when given by ``workflow update``, aborts - if another process changes the installed source or version before commit. - """ - from .catalog import WorkflowCatalog, WorkflowCatalogError - from .engine import WorkflowDefinition - - def versions_match(actual: object, expected: str) -> bool: - from packaging import version as pkg_version - - try: - return pkg_version.Version(str(actual)) == pkg_version.Version( - expected + except typer.Exit: + raise + except (OSError, ValueError) as exc: + _safe_discard_staged_workflow_file( + staged_file, + workflow_dir, + existed_before, ) - except pkg_version.InvalidVersion: - return str(actual) == expected - - safe_wf_id = _escape_markup(workflow_id) + console.print( + f"[red]Error:[/red] Invalid workflow archive: " + f"{_escape_markup(str(exc))}" + ) + raise typer.Exit(1) + return - catalog = WorkflowCatalog(project_root) + # Validate the downloaded workflow (still staged, not yet committed) + # before registering. try: - info = catalog.get_workflow_info(workflow_id) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - - if not info: - console.print(f"[red]Error:[/red] Workflow '{safe_wf_id}' not found in catalog") + definition = WorkflowDefinition.from_string( + downloaded_content.decode("utf-8") + ) + except (UnicodeDecodeError, ValueError, yaml.YAMLError) as exc: + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {_escape_markup(str(exc))}") raise typer.Exit(1) - if not info.get("_install_allowed", True): - console.print(f"[yellow]Warning:[/yellow] Workflow '{safe_wf_id}' is from a discovery-only catalog") - console.print("Direct installation is not enabled for this catalog source.") - raise typer.Exit(1) - - workflow_url = info.get("url") - if not workflow_url: - console.print(f"[red]Error:[/red] Workflow '{safe_wf_id}' does not have an install URL in the catalog") - raise typer.Exit(1) - if not isinstance(workflow_url, str): - # Untrusted catalog payload; a non-string would crash urlparse below. - console.print( - f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL." - ) - raise typer.Exit(1) - - # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) - from urllib.parse import urlparse - - try: - parsed_url = urlparse(workflow_url) - parsed_url.port - except ValueError: - console.print( - f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL." - ) - raise typer.Exit(1) - if not is_https_or_localhost_http(workflow_url): - console.print( - f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. " - "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." - ) - raise typer.Exit(1) - - # Reject path traversal, symlinked , and a symlinked workflow.yml leaf - # before any mkdir/download writes beneath the install directory. - workflow_dir = _safe_workflow_id_dir(workflows_dir, workflow_id) - workflow_file = workflow_dir / "workflow.yml" - - # Captured before any mkdir/download writes so every failure branch below - # can tell a fresh install from a reinstall-over-an-existing-one, - # mirroring _validate_and_install_local's existed-before-aware cleanup. - existed_before = workflow_dir.is_dir() - - try: - staged_file = _stage_workflow_file( - workflow_dir, - use_project_file_mode=not workflow_file.exists(), - ) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - original_workflow_url = workflow_url - downloaded_archive_format = None - archive_content_type = None - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts - from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset - - _wf_cat_extra_headers = None - _resolved_workflow_url = _resolve_gh_asset( - workflow_url, - _open_url, - timeout=30, - github_hosts=_github_provider_hosts(), - redirect_validator=_reject_insecure_download_redirect, - ) - if _resolved_workflow_url: - workflow_url = _resolved_workflow_url - _wf_cat_extra_headers = {"Accept": "application/octet-stream"} - - with _open_url( - workflow_url, - timeout=30, - extra_headers=_wf_cat_extra_headers, - redirect_validator=_reject_insecure_download_redirect, - ) as response: - # Validate final URL after redirects - final_url = response.geturl() - if not is_https_or_localhost_http(final_url): - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print( - f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}" - ) - raise typer.Exit(1) - archive_content_type = ( - response.getheader("Content-Type") - if hasattr(response, "getheader") - else None - ) - downloaded_archive_format = ( - archive_format_from_name(final_url) - or archive_format_from_name(original_workflow_url) - or archive_format_from_content_type(archive_content_type) - ) - # Written to the staging file, never workflow_file directly, so a - # reinstall's prior working copy is never touched until the - # atomic commit below runs. - if downloaded_archive_format is not None: - downloaded_content = read_response_limited( - response, - error_type=ValueError, - label=f"workflow '{workflow_id}' archive download", - ) - elif _workflow_yaml_is_declared(final_url, archive_content_type): - downloaded_content = _read_response_within_limit(response) - else: - downloaded_content = read_response_limited( - response, - error_type=ValueError, - label=f"workflow '{workflow_id}' download", - ) - downloaded_archive_format = _sniff_workflow_archive_format( - downloaded_content - ) - if downloaded_archive_format is None: - _enforce_workflow_yaml_size(downloaded_content) - staged_file.write_bytes(downloaded_content) - except typer.Exit: - raise - except Exception as exc: - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}") - raise typer.Exit(1) - - if downloaded_archive_format is not None: - try: - verify_archive_sha256( - downloaded_content, - info.get("sha256"), - workflow_id, - ValueError, - ) - import tempfile - from io import BytesIO - - with tempfile.TemporaryDirectory( - prefix="speckit-workflow-archive-" - ) as extract_dir: - extracted_root = Path(extract_dir) - safe_extract_archive( - staged_file.path, - extracted_root, - archive_file=BytesIO(downloaded_content), - source_name=original_workflow_url, - content_type=archive_content_type, - ) - package_root = _workflow_package_root(extracted_root) - _safe_discard_staged_workflow_file( - staged_file, - workflow_dir, - existed_before, - ) - _install_workflow_package( - project_root, - workflows_dir, - package_root, - workflow_url, - expected_id=workflow_id, - expected_version=expected_version, - expected_installed_version=expected_installed_version, - catalog_info={**info, "url": workflow_url}, - ) - except typer.Exit: - raise - except (OSError, ValueError) as exc: - _safe_discard_staged_workflow_file( - staged_file, - workflow_dir, - existed_before, - ) - console.print( - f"[red]Error:[/red] Invalid workflow archive: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - return - - # Validate the downloaded workflow (still staged, not yet committed) - # before registering. - try: - definition = WorkflowDefinition.from_string( - downloaded_content.decode("utf-8") - ) - except (UnicodeDecodeError, ValueError, yaml.YAMLError) as exc: - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {_escape_markup(str(exc))}") - raise typer.Exit(1) - - from .engine import validate_workflow - errors = validate_workflow(definition) - if errors: - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print("[red]Error:[/red] Downloaded workflow validation failed:") - for err in errors: - console.print(f" \u2022 {_escape_markup(str(err))}") + from .engine import validate_workflow + errors = validate_workflow(definition) + if errors: + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" \u2022 {_escape_markup(str(err))}") raise typer.Exit(1) # Enforce that the workflow's internal ID matches the catalog key if definition.id and definition.id != workflow_id: - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print( - f"[red]Error:[/red] Workflow ID in YAML ({_escape_markup(repr(definition.id))}) " - f"does not match catalog key ({_escape_markup(repr(workflow_id))}). " - f"The catalog entry may be misconfigured." - ) - raise typer.Exit(1) - - # A stale or misconfigured URL can serve a different version than the - # catalog advertised; without this check `update` would report success - # while leaving the old version installed (or even downgrading). - if expected_version is not None: - if not versions_match(definition.version, expected_version): - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print( - f"[red]Error:[/red] Downloaded workflow version ({_escape_markup(str(definition.version))}) " - f"does not match the catalog version ({_escape_markup(expected_version)}). " - f"The catalog entry may be stale or misconfigured." - ) - raise typer.Exit(1) - - try: - transaction = _workflow_install_transaction(project_root) - with transaction: - transaction_existed_before = ( - existed_before or workflow_file.exists() - ) - transaction_registry = _open_workflow_registry(project_root) - if expected_installed_version is not None: - current = transaction_registry.get(workflow_id) - if ( - not isinstance(current, dict) - or current.get("source") != "catalog" - or not versions_match( - current.get("version"), expected_installed_version - ) - ): - console.print( - f"[yellow]Warning:[/yellow] Workflow '{safe_wf_id}' " - "changed during update; rerun the command to use its " - "current source and version." - ) - raise typer.Exit(1) - # Commit the staged download onto workflow_file via an atomic - # swap. A prior file is renamed aside for registry rollback. - try: - backup_file = _commit_workflow_file( - staged_file, workflow_file, transaction_existed_before - ) - except OSError as exc: - _safe_discard_staged_workflow_file( - staged_file, workflow_dir, existed_before - ) - console.print( - f"[red]Error:[/red] Failed to install workflow " - f"'{safe_wf_id}' from catalog: {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - entry = { - "name": definition.name or info.get("name", workflow_id), - "version": definition.version or info.get("version", "0.0.0"), - "description": definition.description - or info.get("description", ""), - "source": "catalog", - "catalog_name": info.get("_catalog_name", ""), - "url": workflow_url, - } - # Preserve a prior disabled state across updates/reinstalls. - existing = transaction_registry.get(workflow_id) - if isinstance(existing, dict) and not existing.get( - "enabled", True - ): - entry["enabled"] = False - try: - transaction_registry.add(workflow_id, entry) - except (OSError, TypeError, ValueError) as exc: - _safe_rollback_committed_workflow_file( - workflow_file, - workflow_dir, - transaction_existed_before, - backup_file, - ) - console.print( - f"[red]Error:[/red] Failed to update workflow registry for " - f"'{_escape_markup(workflow_id)}': " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - # Registry update succeeded while the transaction lock is held. - _discard_committed_backup_file(backup_file) - except typer.Exit: - _safe_discard_staged_workflow_file( - staged_file, workflow_dir, existed_before - ) - raise - except OSError as exc: - _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) - console.print( - f"[red]Error:[/red] Failed to lock workflow install " - f"'{safe_wf_id}': " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - console.print( - f"[green]✓[/green] Workflow '{_escape_markup(str(info.get('name', workflow_id)))}' " - "installed from catalog" - ) - - -def _remove_workflow_locked( - project_root: Path, workflows_dir: Path, workflow_id: str -) -> Path | None: - """Stage a workflow directory and persist removal while locked.""" - registry = _open_workflow_registry(project_root) - safe_id = _escape_markup(workflow_id) - if not registry.is_installed(workflow_id): - console.print( - f"[red]Error:[/red] Workflow '{safe_id}' is not installed" - ) - raise typer.Exit(1) - - workflow_dir_unresolved = workflows_dir / workflow_id - if workflow_dir_unresolved.is_symlink(): - console.print( - f"[red]Error:[/red] Refusing to remove symlinked " - f".specify/workflows/{safe_id}" - ) - raise typer.Exit(1) - - workflow_dir = workflow_dir_unresolved.resolve() - try: - rel_parts = workflow_dir.relative_to(workflows_dir.resolve()).parts - except ValueError: - console.print( - f"[red]Error:[/red] Invalid workflow ID: " - f"{_escape_markup(repr(workflow_id))}" - ) - raise typer.Exit(1) - if rel_parts != (workflow_id,): - console.print( - f"[red]Error:[/red] Invalid workflow ID: " - f"{_escape_markup(repr(workflow_id))}" - ) - raise typer.Exit(1) - - if workflow_dir.exists() and not workflow_dir.is_dir(): - console.print( - f"[red]Error:[/red] .specify/workflows/{safe_id} exists " - "but is not a directory" - ) - raise typer.Exit(1) - - import tempfile - - staged_dir: Path | None = None - if workflow_dir.exists(): - try: - reserved = Path( - tempfile.mkdtemp( - prefix=f".{workflow_id}.removing-", dir=workflows_dir - ) - ) - reserved.rmdir() - os.rename(workflow_dir, reserved) - staged_dir = reserved - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to stage workflow directory " - f"{_escape_markup(str(workflow_dir))} for removal: " - f"{_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - try: - registry.remove(workflow_id) - except (OSError, TypeError, ValueError) as exc: - if staged_dir is not None: - try: - os.rename(staged_dir, workflow_dir) - except OSError as restore_exc: - console.print( - f"[yellow]Warning:[/yellow] Failed to restore workflow " - "directory after registry update failure; it remains " - f"staged at {_escape_markup(str(staged_dir))}: " - f"{_escape_markup(str(restore_exc))}" - ) - console.print( - f"[red]Error:[/red] Failed to update workflow registry for " - f"'{safe_id}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - return staged_dir - - -@workflow_app.command("remove") -def workflow_remove( - workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), -): - """Uninstall a workflow.""" - project_root = _require_specify_project() - workflows_dir = project_root / ".specify" / "workflows" - _validate_workflow_id_or_exit(workflow_id) - safe_id = _escape_markup(workflow_id) - import shutil - try: - with _workflow_install_transaction(project_root): - staged_dir = _remove_workflow_locked( - project_root, workflows_dir, workflow_id - ) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to lock workflow removal " - f"'{safe_id}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") - - # The registry has already durably committed the removal at this point, - # so it must stand regardless of what happens below: deleting the staged - # directory is now just cleanup, not a data-integrity concern, and a - # failure here is reported as a warning (not an error) to avoid - # contradicting the registry state that already succeeded. - if staged_dir is not None: - try: - shutil.rmtree(staged_dir) - except OSError as exc: - console.print( - f"[yellow]Warning:[/yellow] Workflow '{safe_id}' was removed, but its " - f"staged directory could not be deleted: {_escape_markup(str(exc))}. " - f"Remove it manually: {_escape_markup(str(staged_dir))}" - ) - - -@workflow_app.command("update") -def workflow_update( - workflow_id: str | None = typer.Argument(None, help="Workflow ID to update (default: all)"), -): - """Update installed workflow(s) to the latest catalog version.""" - from packaging import version as pkg_version - - from .catalog import WorkflowCatalog, WorkflowCatalogError - - project_root = _require_specify_project() - registry = _open_workflow_registry(project_root) - workflows_dir = project_root / ".specify" / "workflows" - _reject_unsafe_dir(project_root / ".specify", ".specify") - _reject_unsafe_dir(workflows_dir, ".specify/workflows") - - installed = registry.list() - if workflow_id: - if not registry.is_installed(workflow_id): - console.print(f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is not installed") - raise typer.Exit(1) - targets = [workflow_id] - else: - targets = list(installed) - - if not targets: - console.print("[yellow]No workflows installed[/yellow]") - raise typer.Exit(0) - - catalog = WorkflowCatalog(project_root) - console.print("🔄 Checking for updates...\n") - - updates_available: list[dict[str, str]] = [] - checked = 0 - for wf_id in targets: - safe_id = _escape_markup(str(wf_id)) - metadata = installed.get(wf_id) - if not isinstance(metadata, dict): - console.print(f"⚠ {safe_id}: Registry entry is corrupted (skipping)") - continue - if metadata.get("source") != "catalog": - console.print(f"⚠ {safe_id}: Not installed from a catalog — re-add to update (skipping)") - continue - try: - installed_version = pkg_version.Version(str(metadata.get("version"))) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {safe_id}: Invalid installed version '{_escape_markup(str(metadata.get('version')))}' in registry (skipping)" - ) - continue - try: - info = catalog.get_workflow_info(wf_id) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - if not info: - console.print(f"⚠ {safe_id}: Not found in catalog (skipping)") - continue - if not info.get("_install_allowed", True): - console.print( - f"⚠ {safe_id}: Updates not allowed from '{_escape_markup(str(info.get('_catalog_name', 'catalog')))}' (skipping)" - ) - continue - try: - catalog_version = pkg_version.Version(str(info.get("version"))) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {safe_id}: Invalid catalog version '{_escape_markup(str(info.get('version')))}' (skipping)" - ) - continue - if catalog_version > installed_version: - checked += 1 - updates_available.append( - {"id": wf_id, "installed": str(installed_version), "available": str(catalog_version)} - ) - else: - checked += 1 - console.print(f"✓ {safe_id}: Up to date (v{installed_version})") - - if not updates_available: - if not checked: - console.print("\n[yellow]No workflows were eligible for update[/yellow]") - elif checked == len(targets): - console.print("\n[green]All workflows are up to date![/green]") - else: - console.print( - f"\n[green]All checked workflows are up to date[/green] " - f"[yellow]({len(targets) - checked} skipped)[/yellow]" - ) - raise typer.Exit(0) - - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {_escape_markup(update['id'])}: {update['installed']} → {update['available']}" - ) - console.print() - if not typer.confirm("Update these workflows?"): - console.print("Cancelled") - raise typer.Exit(0) - - console.print() - failed: list[str] = [] - for update in updates_available: - # _install_workflow_from_catalog is fully transactional (staged - # download, atomic commit, rename-based rollback on registry - # failure): it never leaves a partially-written workflow.yml, so - # this loop only needs to record success/failure, not perform its - # own backup/restore. - try: - _install_workflow_from_catalog( - project_root, workflows_dir, update["id"], - expected_version=update["available"], - expected_installed_version=update["installed"], - ) - except (typer.Exit, OSError) as exc: - if isinstance(exc, OSError): - console.print( - f"[red]Error:[/red] Filesystem error updating " - f"'{_escape_markup(update['id'])}': {_escape_markup(str(exc))}" - ) - failed.append(update["id"]) - - if failed: - console.print( - f"\n[red]Failed to update:[/red] {', '.join(_escape_markup(f) for f in failed)}" - ) - raise typer.Exit(1) - - -def _set_workflow_enabled(workflow_id: str, enabled: bool) -> None: - """Update enabled state from a fresh registry snapshot while locked.""" - project_root = _require_specify_project() - safe_id = _escape_markup(workflow_id) - try: - with _workflow_install_transaction(project_root): - registry = _open_workflow_registry(project_root) - metadata = registry.get(workflow_id) - if metadata is None: - console.print( - f"[red]Error:[/red] Workflow '{safe_id}' is not installed" - ) - raise typer.Exit(1) - if not isinstance(metadata, dict): - console.print( - f"[red]Error:[/red] Registry entry for '{safe_id}' " - "is corrupted" - ) - raise typer.Exit(1) - current = bool(metadata.get("enabled", True)) - state = "enabled" if enabled else "disabled" - if current is enabled: - console.print( - f"[yellow]Workflow '{safe_id}' is already {state}[/yellow]" - ) - raise typer.Exit(0) - try: - registry.add(workflow_id, {**metadata, "enabled": enabled}) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to update workflow registry " - f"for '{safe_id}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to lock workflow registry for " - f"'{safe_id}': {_escape_markup(str(exc))}" - ) - raise typer.Exit(1) - state = "enabled" if enabled else "disabled" - console.print(f"[green]✓[/green] Workflow '{safe_id}' {state}") - - -@workflow_app.command("enable") -def workflow_enable( - workflow_id: str = typer.Argument(..., help="Workflow ID to enable"), -): - """Enable a disabled workflow.""" - _set_workflow_enabled(workflow_id, True) - - -@workflow_app.command("disable") -def workflow_disable( - workflow_id: str = typer.Argument(..., help="Workflow ID to disable"), -): - """Disable a workflow without removing it.""" - _set_workflow_enabled(workflow_id, False) - console.print(f"To re-enable: specify workflow enable {_escape_markup(workflow_id)}") - - -@workflow_app.command("search") -def workflow_search( - query: str | None = typer.Argument(None, help="Search query"), - tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), - author: str | None = typer.Option(None, "--author", help="Filter by author"), -): - """Search workflow catalogs.""" - from .catalog import WorkflowCatalog, WorkflowCatalogError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No workflows found.[/yellow]") - return - - console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") - for wf in results: - name = _escape_markup(str(wf.get("name", wf.get("id", "?")))) - wf_id = _escape_markup(str(wf.get("id", "?"))) - version = _escape_markup(str(wf.get("version", "?"))) - console.print(f" [bold]{name}[/bold] ({wf_id}) v{version}") - desc = wf.get("description", "") - if desc: - console.print(f" {_escape_markup(str(desc))}") - tags = wf.get("tags", []) - if isinstance(tags, list) and tags: - safe_tags = _escape_markup(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags: {safe_tags}[/dim]") - console.print() - - -@workflow_app.command("info") -def workflow_info( - workflow_id: str = typer.Argument(..., help="Workflow ID"), -): - """Show workflow details and step graph.""" - from .catalog import WorkflowCatalog, WorkflowCatalogError - from .engine import WorkflowEngine - - project_root = _require_specify_project() - - # Check installed first - registry = _open_workflow_registry(project_root) - installed = registry.get(workflow_id) - - engine = WorkflowEngine(project_root) - - definition = None - try: - definition = engine.load_workflow(workflow_id) - except FileNotFoundError: - # Local workflow definition not found on disk; fall back to - # catalog/registry lookup below. - pass - except ValueError as exc: - console.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}") - raise typer.Exit(1) - - if definition: - # Escape every user-controlled field: workflow.yml values (name, - # version, author, description, integration, input names/types) are not - # trusted, and console.print has Rich markup enabled, so an unescaped - # `[...]` in any of them is parsed as a style tag and silently swallowed - # (same defect fixed for the step graph below; the sibling workflow_list - # already escapes all of these). - console.print( - f"\n[bold cyan]{_escape_markup(str(definition.name))}[/bold cyan] " - f"({_escape_markup(str(definition.id))})" - ) - console.print(f" Version: {_escape_markup(str(definition.version))}") - if definition.author: - console.print(f" Author: {_escape_markup(str(definition.author))}") - if definition.description: - console.print(f" Description: {_escape_markup(str(definition.description))}") - if definition.default_integration: - console.print( - f" Integration: {_escape_markup(str(definition.default_integration))}" - ) - if installed: - console.print(" [green]Installed[/green]") - - if definition.inputs: - console.print("\n [bold]Inputs:[/bold]") - for name, inp in definition.inputs.items(): - if isinstance(inp, dict): - req = "required" if inp.get("required") else "optional" - console.print( - f" {_escape_markup(str(name))} " - f"({_escape_markup(str(inp.get('type', 'string')))}) — {req}" - ) - - if definition.steps: - console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") - for step in definition.steps: - stype = step.get("type", "command") - # Escape the literal bracket (\[) so Rich renders `[]` - # instead of parsing it as a style tag named after the step - # type (which it silently swallows); escape id/type too, as - # the sibling workflow_list does. Mirrors the `\[disabled]` - # precedent above. - console.print( - f" → {_escape_markup(str(step.get('id', '?')))} " - f"\\[{_escape_markup(str(stype))}]" - ) - return - - # Try catalog - catalog = WorkflowCatalog(project_root) - try: - info = catalog.get_workflow_info(workflow_id) - except WorkflowCatalogError: - info = None - - if info: - # Catalog-derived fields are untrusted; escape them so bracketed content - # is rendered literally rather than parsed (and swallowed) as Rich markup. - console.print( - f"\n[bold cyan]{_escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] " - f"({_escape_markup(str(workflow_id))})" - ) - console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}") - if info.get("description"): - console.print(f" Description: {_escape_markup(str(info['description']))}") - info_tags = info.get("tags", []) - if isinstance(info_tags, list) and info_tags: - safe_tags = _escape_markup(", ".join(str(t) for t in info_tags)) - console.print(f" Tags: {safe_tags}") - console.print(" [yellow]Not installed[/yellow]") - else: - console.print( - f"[red]Error:[/red] Workflow '{_escape_markup(str(workflow_id))}' not found" - ) - raise typer.Exit(1) - - -@workflow_catalog_app.command("list") -def workflow_catalog_list(): - """List configured workflow catalog sources.""" - from .catalog import WorkflowCatalog, WorkflowCatalogError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - - try: - configs = catalog.get_catalog_configs() - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") - for i, cfg in enumerate(configs): - install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" - console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}") - console.print(f" {_escape_markup(str(cfg['url']))}") - if cfg.get("description"): - console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]") - console.print() - - -@workflow_catalog_app.command("add") -def workflow_catalog_add( - url: str = typer.Argument(..., help="Catalog URL to add"), - name: str | None = typer.Option(None, "--name", help="Catalog name"), -): - """Add a workflow catalog source.""" - from .catalog import WorkflowCatalog, WorkflowValidationError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - catalog.add_catalog(url, name) - except WorkflowValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source added: {url}") - - -@workflow_catalog_app.command("remove") -def workflow_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), -): - """Remove a workflow catalog source by index.""" - from .catalog import WorkflowCatalog, WorkflowValidationError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - removed_name = catalog.remove_catalog(index) - except WorkflowValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") - - -# ===== Workflow Step Commands ===== - -@workflow_step_app.command("list") -def workflow_step_list(): - """List installed step types (built-in and custom).""" - from . import STEP_REGISTRY - from .catalog import StepRegistry - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Read installed custom steps from registry only — no dynamic imports - installed: dict = {} - if specify_dir.exists(): - registry = StepRegistry(project_root) - installed = registry.list() - - console.print("\n[bold cyan]Installed Step Types:[/bold cyan]\n") - - built_in = sorted(k for k in STEP_REGISTRY if k not in installed) - if built_in: - console.print(" [bold]Built-in:[/bold]") - for key in built_in: - console.print(f" • {key}") - console.print() - - if installed: - console.print(" [bold]Custom (installed):[/bold]") - for key in sorted(installed): - meta = installed[key] or {} - name = _escape_markup(str(meta.get("name", key))) - safe_key = _escape_markup(str(key)) - version = _escape_markup(str(meta.get("version", "?"))) - console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}") - console.print() - - if not built_in and not installed: - console.print("[yellow]No step types found.[/yellow]") - - if specify_dir.exists(): - console.print( - " Install a new step type with: [cyan]specify workflow step add [/cyan]" - ) - - -# IDs that map to internal names used under .specify/workflows/steps/ and must -# not be used as custom step IDs (dotfile check is done separately at runtime). -_RESERVED_STEP_IDS: frozenset[str] = frozenset({".cache", "step-registry.json"}) - -# Windows reserved device names (case-insensitive, with or without extensions) -_WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset({ - "con", "prn", "aux", "nul", - "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", - "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", -}) - -# Characters invalid in filenames on Windows -_WINDOWS_INVALID_CHARS: frozenset[str] = frozenset('<>:"|?*') - - -def _validate_step_id_or_exit(step_id: str) -> None: - """Validate that ``step_id`` is a single safe path component. - - Rejects empty strings, whitespace-only strings, leading/trailing whitespace, - path separators, ``.``/``..`` components, dotfile prefixes, reserved names, - Windows-invalid filename characters, trailing dots/spaces, and Windows - reserved device names. Exits with code 1 on failure. - """ - # Strip the stem (before first dot) for Windows reserved-name check - stem = step_id.split(".")[0].lower() if step_id else "" - if ( - not step_id - or not step_id.strip() - or step_id != step_id.strip() - or "/" in step_id - or "\\" in step_id - or step_id in (".", "..") - or step_id.startswith(".") - or step_id.endswith(".") - or step_id.endswith(" ") - or step_id.lower() in _RESERVED_STEP_IDS - or stem in _WINDOWS_RESERVED_NAMES - or any(c in _WINDOWS_INVALID_CHARS for c in step_id) - or any(ord(c) < 32 for c in step_id) - ): - console.print( - f"[red]Error:[/red] Invalid step id '{step_id}': must be a single safe " - "path component (no separators, no leading dot, not a reserved name, " - "no invalid filename characters)" - ) - raise typer.Exit(1) - - -def _resolve_steps_base_dir_or_exit(project_root: Path) -> Path: - """Resolve .specify/workflows/steps while refusing symlinked parent directories.""" - project_root_resolved = project_root.resolve() - steps_base_dir_unresolved = project_root / ".specify" / "workflows" / "steps" - - current = project_root - for part in (".specify", "workflows", "steps"): - current = current / part - if current.is_symlink(): - console.print( - f"[red]Error:[/red] Refusing to use symlinked step directory '{current}'" - ) - raise typer.Exit(1) - if current.exists() and not current.is_dir(): - console.print( - f"[red]Error:[/red] Step directory path is not a directory: '{current}'" - ) - raise typer.Exit(1) - - steps_base_dir = steps_base_dir_unresolved.resolve() - try: - steps_base_dir.relative_to(project_root_resolved) - except ValueError: - console.print( - f"[red]Error:[/red] Step directory escapes project root: '{steps_base_dir}'" - ) - raise typer.Exit(1) - - return steps_base_dir - - -@workflow_step_app.command("add") -def workflow_step_add( - step_id: str = typer.Argument(..., help="Step type ID from catalog"), -): - """Install a custom step type from the step catalog.""" - from .catalog import StepCatalog, StepCatalogError, StepRegistry, StepValidationError - - project_root = _require_specify_project() - - catalog = StepCatalog(project_root) - try: - info = catalog.get_step_info(step_id) - except StepCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not info: - console.print(f"[red]Error:[/red] Step type '{step_id}' not found in catalog") - raise typer.Exit(1) - - if not info.get("_install_allowed", True): - console.print( - f"[yellow]Warning:[/yellow] Step type '{step_id}' is from a discovery-only catalog" - ) - console.print("Direct installation is not enabled for this catalog source.") - raise typer.Exit(1) - - # Reject step IDs that collide with built-in step types - from . import STEP_REGISTRY as _step_reg - if step_id in _step_reg: - console.print( - f"[red]Error:[/red] Step type '{step_id}' conflicts with a built-in step type" - ) - raise typer.Exit(1) - - # Reject if already installed - registry = StepRegistry(project_root) - if registry.is_installed(step_id): - console.print( - f"[red]Error:[/red] Step type '{step_id}' is already installed. " - "Remove it first with: [cyan]specify workflow step remove " - f"{step_id}[/cyan]" - ) - raise typer.Exit(1) - - declared_step_yml_url = info.get("step_yml_url") - if declared_step_yml_url is not None and not isinstance( - declared_step_yml_url, str - ): - console.print( - f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " - "step.yml URL; expected a non-empty string" - ) - raise typer.Exit(1) - step_yml_url = declared_step_yml_url or info.get("url") - if step_yml_url is None or ( - isinstance(step_yml_url, str) and not step_yml_url.strip() - ): - console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL") - raise typer.Exit(1) - if not isinstance(step_yml_url, str): - console.print( - f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " - "step.yml URL; expected a non-empty string" - ) - raise typer.Exit(1) - - # Derive __init__.py URL: replace trailing step.yml with __init__.py - # or use explicit init_url if provided. - init_url = info.get("init_url") - if init_url is not None and ( - not isinstance(init_url, str) or not init_url.strip() - ): - console.print( - f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " - "__init__.py URL; expected a non-empty string" - ) - raise typer.Exit(1) - if not init_url: - if step_yml_url.endswith("step.yml"): - init_url = step_yml_url[: -len("step.yml")] + "__init__.py" - else: - console.print( - f"[red]Error:[/red] Cannot derive __init__.py URL from '{step_yml_url}'. " - "Catalog entry should provide 'init_url' or a 'url' ending in 'step.yml'." - ) - raise typer.Exit(1) - - # Preflight the declared file count before creating a staging directory or - # issuing any request. The two required files are always part of the package; - # duplicate declarations for them in extra_files are ignored below and do - # not count twice. - extra_files = info.get("extra_files") - if extra_files is not None and not isinstance(extra_files, dict): - console.print( - "[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; " - "additional package files will not be downloaded." - ) - extra_files = {} - - def _is_required_package_file(rel_path: object) -> bool: - """Match portable path/case aliases of the two required package files.""" - if not isinstance(rel_path, str): - return False - parts = PurePosixPath(rel_path.replace("\\", "/")).parts - return len(parts) == 1 and parts[0].casefold() in { - "step.yml", - "__init__.py", - } - - declared_extra_count = sum( - 1 - for rel_path in (extra_files or {}) - if not _is_required_package_file(rel_path) - ) - package_file_count = 2 + declared_extra_count - if package_file_count > _MAX_STEP_PACKAGE_FILES: - console.print( - f"[red]Error:[/red] Step package declares {package_file_count} files, " - f"exceeding the {_MAX_STEP_PACKAGE_FILES}-file limit" - ) - raise typer.Exit(1) - - from specify_cli.authentication.http import open_url as _open_url - - def _safe_fetch(url: str) -> bytes: - if not is_https_or_localhost_http(url): - raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}") - with _open_url( - url, timeout=30, redirect_validator=_reject_insecure_download_redirect - ) as resp: - final_url = resp.geturl() - if not is_https_or_localhost_http(final_url): - raise ValueError(f"Redirect to non-HTTPS URL: {final_url}") - return _read_response_within_limit(resp) - - _validate_step_id_or_exit(step_id) - - steps_base_dir = _resolve_steps_base_dir_or_exit(project_root) - step_dir = (steps_base_dir / step_id).resolve() - # Defense-in-depth: ensure the resolved directory is a direct child of - # steps_base_dir even after symlink resolution. - try: - rel_parts = step_dir.relative_to(steps_base_dir).parts - except ValueError: - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - if rel_parts != (step_id,): - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - - import shutil - import tempfile - - # Refuse if step_dir already exists (e.g. leftover from a previous failed/manual - # install that wasn't registered). The user should remove it before retrying. - if step_dir.exists(): - console.print( - f"[red]Error:[/red] Step directory already exists at '{step_dir}'. " - f"Remove it manually or use: [cyan]specify workflow step remove {step_id}[/cyan]" - ) - raise typer.Exit(1) - - # Create steps_base_dir now so the staging temp dir is on the same filesystem, - # enabling a truly atomic os.rename() below. - try: - steps_base_dir.mkdir(parents=True, exist_ok=True) - tmp_path = Path(tempfile.mkdtemp(prefix="speckit_step_tmp_", dir=steps_base_dir)) - except OSError as exc: - console.print(f"[red]Error:[/red] Failed to create staging directory: {exc}") - raise typer.Exit(1) - try: - try: - step_yml_content = _safe_fetch(step_yml_url) - init_py_content = _safe_fetch(init_url) - except Exception as exc: - console.print(f"[red]Error:[/red] Failed to download step files: {exc}") - raise typer.Exit(1) - - package_bytes = len(step_yml_content) + len(init_py_content) - if package_bytes > _MAX_STEP_PACKAGE_BYTES: - console.print( - f"[red]Error:[/red] Step package exceeds the " - f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit" - ) - raise typer.Exit(1) - - # Validate step.yml - try: - import yaml as _yaml - - step_yml_text = step_yml_content.decode("utf-8") - # ``safe_load`` returns None for BOTH an empty document and an - # explicit null scalar (``null``, ``~``, ``NULL``), so it cannot - # tell them apart on its own. ``compose`` yields no node only for - # a genuinely empty document. - node = _yaml.compose(step_yml_text) - meta = _yaml.safe_load(step_yml_text) - is_empty_document = node is None or ( - meta is None - and isinstance(node, _yaml.nodes.ScalarNode) - and node.value == "" - and node.start_mark.index == node.end_mark.index - ) - except Exception as exc: - console.print(f"[red]Error:[/red] Invalid step.yml: {exc}") - raise typer.Exit(1) - - # Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping - # (top-level ``[]``, ``false``, ``0``, ``''``, or an explicit ``null``) - # into ``{}`` and silently bypasses this shape check, surfacing the - # unrelated "missing 'step.type_key'" error below instead of the real - # problem. Only a genuinely empty document defaults to ``{}``. - if meta is None and is_empty_document: - meta = {} - elif not isinstance(meta, dict): - console.print("[red]Error:[/red] step.yml must be a YAML mapping") - raise typer.Exit(1) - - step_meta = meta.get("step", {}) - if not isinstance(step_meta, dict): - console.print("[red]Error:[/red] step.yml 'step' field must be a mapping") - raise typer.Exit(1) - type_key = step_meta.get("type_key", "") - if not type_key: - console.print("[red]Error:[/red] step.yml missing 'step.type_key' field") - raise typer.Exit(1) + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({_escape_markup(repr(definition.id))}) " + f"does not match catalog key ({_escape_markup(repr(workflow_id))}). " + f"The catalog entry may be misconfigured." + ) + raise typer.Exit(1) - if type_key != step_id: + # A stale or misconfigured URL can serve a different version than the + # catalog advertised; without this check `update` would report success + # while leaving the old version installed (or even downgrading). + if expected_version is not None: + if not versions_match(definition.version, expected_version): + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) console.print( - f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match " - f"catalog ID ({step_id!r})" + f"[red]Error:[/red] Downloaded workflow version ({_escape_markup(str(definition.version))}) " + f"does not match the catalog version ({_escape_markup(expected_version)}). " + f"The catalog entry may be stale or misconfigured." ) raise typer.Exit(1) - # Write the two required files. - try: - (tmp_path / "step.yml").write_bytes(step_yml_content) - (tmp_path / "__init__.py").write_bytes(init_py_content) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to write step files to staging directory: {exc}" + try: + transaction = _workflow_install_transaction(project_root) + with transaction: + transaction_existed_before = ( + existed_before or workflow_file.exists() ) - raise typer.Exit(1) - - # Optionally download additional package files declared in the catalog entry - # (e.g. helper modules). Each entry in ``extra_files`` is a mapping of - # relative-path → URL. step.yml and __init__.py are ignored here (already - # written). Paths are validated to stay within the step package directory to - # prevent path-traversal attacks. - for rel_path, file_url in (extra_files or {}).items(): - if not isinstance(rel_path, str) or not rel_path.strip(): - console.print( - "[red]Error:[/red] Catalog entry 'extra_files' contains an " - "empty or non-string path key" - ) - raise typer.Exit(1) - if _is_required_package_file(rel_path): - continue # already written above - # Reject dot-path segments ('', '.', '..') that would refer to the - # package directory itself (IsADirectoryError) or escape it. - rel_parts = Path(rel_path).parts - if not rel_parts or any(seg in ("", ".", "..") for seg in rel_parts): - console.print( - f"[red]Error:[/red] extra_files path '{rel_path}' is not a " - "valid relative file path" - ) - raise typer.Exit(1) - if not isinstance(file_url, str) or not file_url.strip(): - console.print( - f"[red]Error:[/red] extra_files entry '{rel_path}' has an " - "empty or non-string URL" - ) - raise typer.Exit(1) - # Resolve both destination and base to handle any symlinks in tmp_path itself, - # ensuring the traversal check is robust even on non-canonical paths. - resolved_base = tmp_path.resolve() - dest = (tmp_path / rel_path).resolve() + transaction_registry = _open_workflow_registry(project_root) + if expected_installed_version is not None: + current = transaction_registry.get(workflow_id) + if ( + not isinstance(current, dict) + or current.get("source") != "catalog" + or not versions_match( + current.get("version"), expected_installed_version + ) + ): + console.print( + f"[yellow]Warning:[/yellow] Workflow '{safe_wf_id}' " + "changed during update; rerun the command to use its " + "current source and version." + ) + raise typer.Exit(1) + # Commit the staged download onto workflow_file via an atomic + # swap. A prior file is renamed aside for registry rollback. try: - dest.relative_to(resolved_base) - except ValueError: - console.print( - f"[red]Error:[/red] extra_files path '{rel_path}' is outside " - "the step package directory" + backup_file = _commit_workflow_file( + staged_file, workflow_file, transaction_existed_before ) - raise typer.Exit(1) - try: - file_content = _safe_fetch(file_url) - except Exception as exc: - console.print( - f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}" + except OSError as exc: + _safe_discard_staged_workflow_file( + staged_file, workflow_dir, existed_before ) - raise typer.Exit(1) - package_bytes += len(file_content) - if package_bytes > _MAX_STEP_PACKAGE_BYTES: console.print( - f"[red]Error:[/red] Step package exceeds the " - f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit" + f"[red]Error:[/red] Failed to install workflow " + f"'{safe_wf_id}' from catalog: {_escape_markup(str(exc))}" ) raise typer.Exit(1) + + entry = { + "name": definition.name or info.get("name", workflow_id), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description + or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + } + # Preserve a prior disabled state across updates/reinstalls. + existing = transaction_registry.get(workflow_id) + if isinstance(existing, dict) and not existing.get( + "enabled", True + ): + entry["enabled"] = False try: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(file_content) - except OSError as exc: + transaction_registry.add(workflow_id, entry) + except (OSError, TypeError, ValueError) as exc: + _safe_rollback_committed_workflow_file( + workflow_file, + workflow_dir, + transaction_existed_before, + backup_file, + ) console.print( - f"[red]Error:[/red] Failed to write extra file '{rel_path}': {exc}" + f"[red]Error:[/red] Failed to update workflow registry for " + f"'{_escape_markup(workflow_id)}': " + f"{_escape_markup(str(exc))}" ) raise typer.Exit(1) - - # Atomically rename the staging directory to the final location. - # Both paths are under steps_base_dir (same filesystem), so os.rename() - # is atomic on POSIX and won't leave a partially-written directory at - # step_dir on failure. - try: - os.rename(tmp_path, step_dir) - except OSError as exc: - console.print(f"[red]Error:[/red] Failed to install step '{step_id}': {exc}") - raise typer.Exit(1) - finally: - # Clean up if the rename hasn't moved tmp_path yet (i.e. on any failure). - shutil.rmtree(tmp_path, ignore_errors=True) - - step_name = info.get("name") or step_id - step_version = info.get("version") or step_meta.get("version") or "0.0.0" - - # Register in step registry - registry = StepRegistry(project_root) - try: - registry.add( - step_id, - { - "name": step_name, - "version": step_version, - "description": info.get("description", step_meta.get("description", "")), - "author": info.get("author", step_meta.get("author", "")), - "source": "catalog", - "catalog_name": info.get("_catalog_name", ""), - "type_key": type_key, - }, + # Registry update succeeded while the transaction lock is held. + _discard_committed_backup_file(backup_file) + except typer.Exit: + _safe_discard_staged_workflow_file( + staged_file, workflow_dir, existed_before + ) + raise + except OSError as exc: + _safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before) + console.print( + f"[red]Error:[/red] Failed to lock workflow install " + f"'{safe_wf_id}': " + f"{_escape_markup(str(exc))}" ) - except StepValidationError as exc: - # Roll back the just-installed directory so the system isn't left with - # an unregistered step package on disk after a registry write failure - # (e.g. read-only filesystem, permission denied). - shutil.rmtree(step_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - - console.print( - f"[green]✓[/green] Step type '{step_name}' ({step_id}) installed" - ) console.print( - " Use [cyan]specify workflow step list[/cyan] to verify the installation." + f"[green]✓[/green] Workflow '{_escape_markup(str(info.get('name', workflow_id)))}' " + "installed from catalog" ) -@workflow_step_app.command("remove") -def workflow_step_remove( - step_id: str = typer.Argument(..., help="Step type ID to uninstall"), -): - """Uninstall a custom step type.""" - from .catalog import StepRegistry, StepValidationError - +def _set_workflow_enabled(workflow_id: str, enabled: bool) -> None: + """Update enabled state from a fresh registry snapshot while locked.""" project_root = _require_specify_project() - - _validate_step_id_or_exit(step_id) - - registry = StepRegistry(project_root) - in_registry = registry.is_installed(step_id) - - steps_base_dir = _resolve_steps_base_dir_or_exit(project_root) - step_dir = (steps_base_dir / step_id).resolve() - # Defense-in-depth: even though _validate_step_id_or_exit rejects path - # separators, ensure that the resolved directory is a single child of - # steps_base_dir and is not steps_base_dir itself. + safe_id = _escape_markup(workflow_id) try: - rel_parts = step_dir.relative_to(steps_base_dir).parts - except ValueError: - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - if rel_parts != (step_id,): - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - - dir_exists = step_dir.exists() - - if not in_registry and not dir_exists: - console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed") - raise typer.Exit(1) - - if not in_registry and dir_exists: - # The registry was likely reset due to corruption. Warn the user that the - # directory is being removed even though there is no registry entry, so - # the orphaned package can be cleaned up and a fresh install attempted. - console.print( - f"[yellow]Warning:[/yellow] '{step_id}' has no registry entry " - "(registry may have been reset). Removing the orphaned directory." - ) - - if dir_exists and not in_registry: - # No registry write needed; just delete the orphaned directory. - import shutil - try: - shutil.rmtree(step_dir) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" - ) - raise typer.Exit(1) - elif in_registry: - # Remove the registry entry, then the directory. If the directory - # delete fails, restore the registry entry so state stays consistent - # and a future `step add` isn't blocked by an orphaned directory - # with no registry entry. - registry_metadata = registry.get(step_id) - try: - registry.remove(step_id) - except StepValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - if dir_exists: - import shutil + with _workflow_install_transaction(project_root): + registry = _open_workflow_registry(project_root) + metadata = registry.get(workflow_id) + if metadata is None: + console.print( + f"[red]Error:[/red] Workflow '{safe_id}' is not installed" + ) + raise typer.Exit(1) + if not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Registry entry for '{safe_id}' " + "is corrupted" + ) + raise typer.Exit(1) + current = bool(metadata.get("enabled", True)) + state = "enabled" if enabled else "disabled" + if current is enabled: + console.print( + f"[yellow]Workflow '{safe_id}' is already {state}[/yellow]" + ) + raise typer.Exit(0) try: - shutil.rmtree(step_dir) + registry.add(workflow_id, {**metadata, "enabled": enabled}) except OSError as exc: - # Restore the original registry entry verbatim (bypass add() - # which would overwrite timestamps). - try: - if registry_metadata is not None: - registry.data["steps"][step_id] = registry_metadata - registry.save() - except Exception as restore_exc: # noqa: BLE001 - console.print( - f"[yellow]Warning:[/yellow] Failed to restore registry entry " - f"for '{step_id}' after directory removal failure: {restore_exc}" - ) console.print( - f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" + f"[red]Error:[/red] Failed to update workflow registry " + f"for '{safe_id}': {_escape_markup(str(exc))}" ) raise typer.Exit(1) - console.print(f"[green]✓[/green] Step type '{step_id}' uninstalled") - - -@workflow_step_app.command("search") -def workflow_step_search( - query: str | None = typer.Argument(None, help="Search query"), -): - """Search the step type catalog.""" - from .catalog import StepCatalog, StepCatalogError - - project_root = _require_specify_project() - - catalog = StepCatalog(project_root) - - try: - results = catalog.search(query=query) - except StepCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not results: - if query: - console.print(f"[yellow]No step types found matching '{query}'.[/yellow]") - else: - console.print("[yellow]No step types found in catalog.[/yellow]") - return - - console.print(f"\n[bold cyan]Step Types ({len(results)}):[/bold cyan]\n") - for step in results: - install_note = ( - "" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]" - ) - name = _escape_markup(str(step.get("name", step.get("id", "?")))) - step_id = _escape_markup(str(step.get("id", "?"))) - version = _escape_markup(str(step.get("version", "?"))) - console.print( - f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}" - ) - desc = step.get("description", "") - if desc: - console.print(f" {_escape_markup(str(desc))}") - console.print() - - -@workflow_step_app.command("info") -def workflow_step_info( - step_id: str = typer.Argument(..., help="Step type ID"), -): - """Show details for a step type.""" - from . import STEP_REGISTRY - from .catalog import StepCatalog, StepCatalogError, StepRegistry - - project_root = _require_specify_project() - safe_step_id = _escape_markup(str(step_id)) - - registry = StepRegistry(project_root) - installed_meta = registry.get(step_id) - - # Check if it's a built-in - builtin_step = STEP_REGISTRY.get(step_id) - is_builtin = builtin_step is not None and not installed_meta - - if is_builtin: - console.print(f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]") - console.print(f" Type key: {safe_step_id}") - console.print(" [green]Built-in step type[/green]") - return - - if installed_meta: - name = _escape_markup(str(installed_meta.get("name", step_id))) - version = _escape_markup(str(installed_meta.get("version", "?"))) - console.print( - f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})" - ) - console.print(f" Version: {version}") - if installed_meta.get("author"): - console.print( - f" Author: {_escape_markup(str(installed_meta['author']))}" - ) - if installed_meta.get("description"): - console.print( - f" Description: " - f"{_escape_markup(str(installed_meta['description']))}" - ) - console.print(" [green]Installed[/green]") - return - - # Try catalog - catalog = StepCatalog(project_root) - try: - info = catalog.get_step_info(step_id) - except StepCatalogError: - info = None - - if info: - name = _escape_markup(str(info.get("name", step_id))) - version = _escape_markup(str(info.get("version", "?"))) - console.print( - f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})" - ) - console.print(f" Version: {version}") - if info.get("author"): - console.print(f" Author: {_escape_markup(str(info['author']))}") - if info.get("description"): - console.print( - f" Description: {_escape_markup(str(info['description']))}" - ) - console.print(" [yellow]Not installed[/yellow]") + except OSError as exc: console.print( - f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]" - ) - else: - console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found") - raise typer.Exit(1) - - -@workflow_step_catalog_app.command("list") -def workflow_step_catalog_list(): - """List configured step catalog sources.""" - from .catalog import StepCatalog, StepCatalogError - - project_root = _require_specify_project() - catalog = StepCatalog(project_root) - - try: - configs = catalog.get_catalog_configs() - except StepCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Step Catalog Sources:[/bold cyan]\n") - for i, cfg in enumerate(configs): - install_status = ( - "[green]install allowed[/green]" - if cfg["install_allowed"] - else "[yellow]discovery only[/yellow]" + f"[red]Error:[/red] Failed to lock workflow registry for " + f"'{safe_id}': {_escape_markup(str(exc))}" ) - console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}") - console.print(f" {_escape_markup(str(cfg['url']))}") - if cfg.get("description"): - console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]") - console.print() - - -@workflow_step_catalog_app.command("add") -def workflow_step_catalog_add( - url: str = typer.Argument(..., help="Catalog URL to add"), - name: str | None = typer.Option(None, "--name", help="Catalog name"), -): - """Add a step catalog source.""" - from .catalog import StepCatalog, StepValidationError - - project_root = _require_specify_project() - - catalog = StepCatalog(project_root) - try: - catalog.add_catalog(url, name) - except StepValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Step catalog source added: {url}") - - -@workflow_step_catalog_app.command("remove") -def workflow_step_catalog_remove( - index: int = typer.Argument( - ..., help="Catalog index to remove (from 'step catalog list')" - ), -): - """Remove a step catalog source by index.""" - from .catalog import StepCatalog, StepValidationError - - project_root = _require_specify_project() - - catalog = StepCatalog(project_root) - try: - removed_name = catalog.remove_catalog(index) - except StepValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - - console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed") + state = "enabled" if enabled else "disabled" + console.print(f"[green]✓[/green] Workflow '{safe_id}' {state}") -@workflow_overlay_app.command("add") -def workflow_overlay_add_cmd( - source: Path = typer.Argument(..., help="Path to overlay YAML file"), - priority: int = typer.Option( - 10, - "--priority", - help="Resolution priority (lower = higher precedence, default 10)", - ), -): - """Add a project-local overlay for a workflow.""" - from .overlays._commands import workflow_overlay_add +# Compatibility forwarders for established public and direct-call paths. +def workflow_add(*args, **kwargs): + from .command_add import workflow_add as command - project_root = _require_specify_project() - if workflow_overlay_add(project_root, source, priority) is None: - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_overlay_app.command("set-priority") -def workflow_overlay_set_priority_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"), - overlay_id: str = typer.Argument(..., help="Overlay ID"), - priority: int = typer.Argument( - ..., help="New priority (lower = higher precedence)" - ), -): - """Set the priority of a project-local overlay.""" - from .overlays._commands import workflow_overlay_set_priority +def workflow_remove(*args, **kwargs): + from .command_remove import workflow_remove as command - project_root = _require_specify_project() - if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority): - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_overlay_app.command("enable") -def workflow_overlay_enable_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"), - overlay_id: str = typer.Argument(..., help="Overlay ID"), -): - """Enable a project-local overlay.""" - from .overlays._commands import workflow_overlay_enable +def workflow_status(*args, **kwargs): + from .command_status import workflow_status as command - project_root = _require_specify_project() - if not workflow_overlay_enable(project_root, workflow_id, overlay_id): - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_overlay_app.command("disable") -def workflow_overlay_disable_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"), - overlay_id: str = typer.Argument(..., help="Overlay ID"), -): - """Disable a project-local overlay.""" - from .overlays._commands import workflow_overlay_disable +def workflow_enable(*args, **kwargs): + from .command_enable import workflow_enable as command - project_root = _require_specify_project() - if not workflow_overlay_disable(project_root, workflow_id, overlay_id): - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_overlay_app.command("remove") -def workflow_overlay_remove_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"), - overlay_id: str = typer.Argument(..., help="Overlay ID"), -): - """Remove a project-local overlay.""" - from .overlays._commands import workflow_overlay_remove +def workflow_disable(*args, **kwargs): + from .command_disable import workflow_disable as command - project_root = _require_specify_project() - if not workflow_overlay_remove(project_root, workflow_id, overlay_id): - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_overlay_app.command("list") -def workflow_overlay_list_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID"), -): - """List overlays for a workflow.""" - from .overlays._commands import workflow_overlay_list +def workflow_step_add(*args, **kwargs): + from .step.command_add import workflow_step_add as command - project_root = _require_specify_project() - if workflow_overlay_list(project_root, workflow_id) is None: - raise typer.Exit(1) + return command(*args, **kwargs) -@workflow_app.command("resolve") -def workflow_resolve_cmd( - workflow_id: str = typer.Argument(..., help="Workflow ID to resolve"), -): - """Show layer attribution for a resolved workflow.""" - from .overlays._commands import workflow_resolve +def workflow_step_remove(*args, **kwargs): + from .step.command_remove import workflow_step_remove as command - project_root = _require_specify_project() - if workflow_resolve(project_root, workflow_id) is None: - raise typer.Exit(1) + return command(*args, **kwargs) def register(app: typer.Typer) -> None: """Attach the workflow command group to the root Typer app.""" + from .catalog import register as register_catalog + from .overlay import register as register_overlay + from .step import register as register_step + + register_catalog(workflow_app) + register_step(workflow_app) + register_overlay(workflow_app) + + # isort: off + from . import command_run # noqa: F401 -- registers handler + from . import command_resume # noqa: F401 -- registers handler + from . import command_status # noqa: F401 -- registers handler + from . import command_list # noqa: F401 -- registers handler + from . import command_add # noqa: F401 -- registers handler + from . import command_remove # noqa: F401 -- registers handler + from . import command_update # noqa: F401 -- registers handler + from . import command_enable # noqa: F401 -- registers handler + from . import command_disable # noqa: F401 -- registers handler + from . import command_search # noqa: F401 -- registers handler + from . import command_info # noqa: F401 -- registers handler + from . import command_resolve # noqa: F401 -- registers handler + # isort: on + app.add_typer(workflow_app, name="workflow") diff --git a/src/specify_cli/workflows/catalog/__init__.py b/src/specify_cli/workflows/catalog/__init__.py new file mode 100644 index 0000000000..0e2667558b --- /dev/null +++ b/src/specify_cli/workflows/catalog/__init__.py @@ -0,0 +1,60 @@ +"""Workflow catalog domain API and nested CLI registration.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +import typer + +from ..._download_security import MAX_JSON_CATALOG_BYTES as MAX_JSON_CATALOG_BYTES + +catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the workflow catalog group to the workflow app.""" + from . import command_list # noqa: F401 -- registers handler + from . import command_add # noqa: F401 -- registers handler + from . import command_remove # noqa: F401 -- registers handler + + app.add_typer(catalog_app, name="catalog") + + +_DOMAIN_EXPORTS = { + "WorkflowCatalog", + "WorkflowCatalogEntry", + "WorkflowCatalogError", + "WorkflowRegistry", + "WorkflowValidationError", +} +_STEP_COMPATIBILITY_EXPORTS = { + "StepCatalog", + "StepCatalogEntry", + "StepCatalogError", + "StepRegistry", + "StepValidationError", +} +_COMPATIBILITY_EXPORTS = {"json", "os", "tempfile"} + + +def __getattr__(name: str) -> Any: + """Load catalog domain symbols only when a consumer requests them.""" + if name in _STEP_COMPATIBILITY_EXPORTS: + step_catalog = import_module("..step.catalog", __name__) + value = getattr(step_catalog, name) + globals()[name] = value + return value + if name not in _DOMAIN_EXPORTS | _COMPATIBILITY_EXPORTS: + raise AttributeError(name) + domain = import_module(f"{__name__}._domain") + value = getattr(domain, name) + globals()[name] = value + return value + + +__all__ = sorted(_DOMAIN_EXPORTS | _STEP_COMPATIBILITY_EXPORTS) diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog/_domain.py similarity index 54% rename from src/specify_cli/workflows/catalog.py rename to src/specify_cli/workflows/catalog/_domain.py index 5fffa4b45f..089b46f2a3 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -1,10 +1,9 @@ -"""Workflow catalog — discovery, install, and management of workflows and step types. +"""Workflow catalog discovery, installation, and registry domain API. Mirrors the existing extension/preset catalog pattern with: - Multi-catalog stack (env var → project → user → built-in) - SHA256-hashed per-URL caching with 1-hour TTL - Workflow registry for installed workflow tracking -- Step registry for installed custom step type tracking - Search across all configured catalog sources """ @@ -22,7 +21,17 @@ import yaml -from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited +from ..._download_security import ( + MAX_JSON_CATALOG_BYTES as MAX_JSON_CATALOG_BYTES, + read_response_limited, +) + + +def _max_json_catalog_bytes() -> int: + """Read the compatibility-exposed size limit at call time.""" + from . import MAX_JSON_CATALOG_BYTES as configured_limit + + return configured_limit # --------------------------------------------------------------------------- @@ -568,7 +577,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: data = json.loads( read_response_limited( resp, - max_bytes=MAX_JSON_CATALOG_BYTES, + max_bytes=_max_json_catalog_bytes(), error_type=WorkflowCatalogError, label="workflow catalog", ).decode("utf-8") @@ -820,688 +829,3 @@ def remove_catalog(self, index: int) -> str: if isinstance(removed, dict): return removed.get("name", f"catalog-{index + 1}") return f"catalog-{index + 1}" - - -# --------------------------------------------------------------------------- -# Step catalog errors -# --------------------------------------------------------------------------- - - -class StepCatalogError(Exception): - """Base error for step catalog operations.""" - - -class StepValidationError(StepCatalogError): - """Validation error for step catalog config or step data.""" - - -# --------------------------------------------------------------------------- -# StepCatalogEntry -# --------------------------------------------------------------------------- - - -@dataclass -class StepCatalogEntry: - """Represents a single step catalog source in the catalog stack.""" - - url: str - name: str - priority: int - install_allowed: bool - description: str = "" - - -# --------------------------------------------------------------------------- -# StepRegistry -# --------------------------------------------------------------------------- - - -class StepRegistry: - """Manages the registry of installed custom step types. - - Tracks installed step types and their metadata in - ``.specify/workflows/steps/step-registry.json``. - """ - - REGISTRY_FILE = "step-registry.json" - SCHEMA_VERSION = "1.0" - - def __init__(self, project_root: Path) -> None: - self.project_root = project_root - self.steps_dir = project_root / ".specify" / "workflows" / "steps" - self.registry_path = self.steps_dir / self.REGISTRY_FILE - self.data = self._load() - - def _has_symlinked_parent(self) -> bool: - """Return True if any directory under .specify/workflows/steps is a symlink.""" - current = self.project_root - for part in (".specify", "workflows", "steps"): - current = current / part - if current.is_symlink(): - return True - return False - - def _load(self) -> dict[str, Any]: - """Load registry from disk or create default.""" - default_registry: dict[str, Any] = {"schema_version": self.SCHEMA_VERSION, "steps": {}} - # Defense-in-depth: refuse to read the registry if any parent directory - # under .specify/workflows/steps is a symlink, which could redirect the - # read outside the project root. - if self._has_symlinked_parent(): - return default_registry - # Defense-in-depth: also refuse to read a symlinked registry file, - # which could redirect the read outside the project root. - if self.registry_path.is_symlink(): - return default_registry - if self.registry_path.exists(): - try: - with open(self.registry_path, encoding="utf-8") as f: - data = json.load(f) - # Validate shape: must be a dict with a dict "steps" field - if not isinstance(data, dict): - return default_registry - if not isinstance(data.get("steps"), dict): - data["steps"] = {} - return data - except (json.JSONDecodeError, ValueError, OSError, UnicodeError): - return default_registry - return default_registry - - def save(self) -> None: - """Persist registry to disk. - - Raises ``StepValidationError`` with a clear message on filesystem - errors (read-only fs, permission denied, ...) so callers can surface - a clean error to the user rather than an unhandled ``OSError``. - """ - if self._has_symlinked_parent() or self.registry_path.is_symlink(): - raise StepValidationError( - "Refusing to write step registry through a symlinked path." - ) - try: - self.steps_dir.mkdir(parents=True, exist_ok=True) - with open(self.registry_path, "w", encoding="utf-8") as f: - json.dump(self.data, f, indent=2) - except OSError as exc: - raise StepValidationError( - f"Failed to write step registry at {self.registry_path}: {exc}" - ) from exc - - def add(self, step_id: str, metadata: dict[str, Any]) -> None: - """Add or update an installed step entry.""" - import copy - from datetime import datetime, timezone - - raw_existing = self.data["steps"].get(step_id) - # Corrupted-but-parseable registries may hold non-dict entries; treat - # them as absent rather than crashing on existing.get() (mirrors - # WorkflowRegistry.add). - existing = raw_existing if isinstance(raw_existing, dict) else {} - metadata_to_store = copy.deepcopy(metadata) - metadata_to_store["installed_at"] = existing.get( - "installed_at", datetime.now(timezone.utc).isoformat() - ) - metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat() - self.data["steps"][step_id] = metadata_to_store - self.save() - - def remove(self, step_id: str) -> bool: - """Remove an installed step entry. Returns True if found.""" - if step_id in self.data["steps"]: - del self.data["steps"][step_id] - self.save() - return True - return False - - def get(self, step_id: str) -> dict[str, Any] | None: - """Get metadata for an installed step.""" - return self.data["steps"].get(step_id) - - def list(self) -> dict[str, dict[str, Any]]: - """Return all installed steps.""" - return dict(self.data["steps"]) - - def is_installed(self, step_id: str) -> bool: - """Check if a step is installed.""" - return step_id in self.data["steps"] - - -# --------------------------------------------------------------------------- -# StepCatalog -# --------------------------------------------------------------------------- - - -class StepCatalog: - """Manages step catalog fetching, caching, and searching. - - Resolution order for catalog sources: - 1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all) - 2. Project-level ``.specify/step-catalogs.yml`` - 3. User-level ``~/.specify/step-catalogs.yml`` - 4. Built-in defaults (official + community) - """ - - DEFAULT_CATALOG_URL = ( - "https://raw.githubusercontent.com/github/spec-kit/main/" - "workflows/step-catalog.json" - ) - COMMUNITY_CATALOG_URL = ( - "https://raw.githubusercontent.com/github/spec-kit/main/" - "workflows/step-catalog.community.json" - ) - CACHE_DURATION = 3600 # 1 hour - - def __init__(self, project_root: Path) -> None: - self.project_root = project_root - self.steps_dir = project_root / ".specify" / "workflows" / "steps" - self.cache_dir = self.steps_dir / ".cache" - - def _is_cache_path_safe(self) -> bool: - """Return False if any component of the cache path is a symlink.""" - current = self.project_root - for part in (".specify", "workflows", "steps", ".cache"): - current = current / part - if current.is_symlink(): - return False - return True - - # -- Catalog resolution ----------------------------------------------- - - def _validate_catalog_url(self, url: str) -> None: - """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" - from urllib.parse import urlparse - - # A malformed authority (e.g. an unterminated IPv6 bracket - # "https://[::1") makes urlparse / hostname access raise ValueError. - # This validator's contract is to raise StepValidationError for a bad - # URL, so surface that rather than leaking a raw ValueError past the - # command handler (which only catches StepValidationError). Mirrors - # specify_cli.catalogs (#3435). - try: - parsed = urlparse(url) - hostname = parsed.hostname - _ = parsed.port - except (TypeError, ValueError): - raise StepValidationError( - f"Catalog URL is malformed: {url}" - ) from None - is_localhost = hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not ( - parsed.scheme == "http" and is_localhost - ): - raise StepValidationError( - f"Catalog URL must use HTTPS (got {parsed.scheme}://). " - "HTTP is only allowed for localhost." - ) - if not hostname: - raise StepValidationError( - "Catalog URL must be a valid URL with a host." - ) - - def _load_catalog_config( - self, config_path: Path - ) -> list[StepCatalogEntry] | None: - """Load catalog stack configuration from a YAML file.""" - if not config_path.exists(): - return None - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError) as exc: - raise StepValidationError( - f"Failed to read catalog config {config_path}: {exc}" - ) from exc - # Same two guards as WorkflowCatalog._load_catalog_config above, kept in - # lockstep: this is the step-catalog twin of that loader and read the - # same way. Dropping ``or {}`` stops a falsy non-mapping top level from - # being coerced past the isinstance check, and the ``catalogs`` shape - # check runs before the emptiness check for the same reason. - if data is None: - return None - if not isinstance(data, dict): - raise StepValidationError( - f"Invalid catalog config: expected a mapping, " - f"got {type(data).__name__}" - ) - catalogs_data = data.get("catalogs") - if catalogs_data is None: - return None - if not isinstance(catalogs_data, list): - raise StepValidationError( - f"Invalid catalog config: 'catalogs' must be a list, " - f"got {type(catalogs_data).__name__}" - ) - if not catalogs_data: - return None - - entries: list[StepCatalogEntry] = [] - for idx, item in enumerate(catalogs_data): - if not isinstance(item, dict): - raise StepValidationError( - f"Invalid catalog entry at index {idx}: " - f"expected a mapping, got {type(item).__name__}" - ) - url = str(item.get("url", "")).strip() - if not url: - continue - self._validate_catalog_url(url) - raw_priority = item.get("priority", idx + 1) - # bool is an int subclass: reject ``priority: true`` explicitly rather - # than silently coercing it to 1 (mirrors CatalogStackBase). - if isinstance(raw_priority, bool): - raise StepValidationError( - f"Invalid priority for catalog " - f"'{item.get('name', idx + 1)}': " - f"expected integer, got {raw_priority!r}" - ) - try: - priority = int(raw_priority) - except (TypeError, ValueError, OverflowError): - # OverflowError: int(float("inf")) — a ``priority: .inf``. - raise StepValidationError( - f"Invalid priority for catalog " - f"'{item.get('name', idx + 1)}': " - f"expected integer, got {raw_priority!r}" - ) - raw_install = item.get("install_allowed", False) - if isinstance(raw_install, str): - install_allowed = raw_install.strip().lower() in ( - "true", - "yes", - "1", - ) - else: - install_allowed = bool(raw_install) - entries.append( - StepCatalogEntry( - url=url, - name=str(item.get("name", f"catalog-{idx + 1}")), - priority=priority, - install_allowed=install_allowed, - description=str(item.get("description", "")), - ) - ) - entries.sort(key=lambda e: e.priority) - if not entries: - raise StepValidationError( - f"Catalog config {config_path} contains {len(catalogs_data)} " - f"entries but none have valid URLs." - ) - return entries - - def get_active_catalogs(self) -> list[StepCatalogEntry]: - """Get the ordered list of active step catalogs.""" - # 1. Environment variable override - env_url = os.environ.get("SPECKIT_STEP_CATALOG_URL", "").strip() - if env_url: - self._validate_catalog_url(env_url) - return [ - StepCatalogEntry( - url=env_url, - name="env-override", - priority=1, - install_allowed=True, - description="From SPECKIT_STEP_CATALOG_URL", - ) - ] - - # 2. Project-level config - project_config = self.project_root / ".specify" / "step-catalogs.yml" - project_entries = self._load_catalog_config(project_config) - if project_entries is not None: - return project_entries - - # 3. User-level config - home = Path.home() - user_config = home / ".specify" / "step-catalogs.yml" - user_entries = self._load_catalog_config(user_config) - if user_entries is not None: - return user_entries - - # 4. Built-in defaults - return [ - StepCatalogEntry( - url=self.DEFAULT_CATALOG_URL, - name="default", - priority=1, - install_allowed=True, - description="Official step types", - ), - StepCatalogEntry( - url=self.COMMUNITY_CATALOG_URL, - name="community", - priority=2, - install_allowed=False, - description="Community-contributed step types (discovery only)", - ), - ] - - # -- Caching ---------------------------------------------------------- - - def _get_cache_paths(self, url: str) -> tuple[Path, Path]: - """Get cache file paths for a URL (hash-based).""" - url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] - cache_file = self.cache_dir / f"step-catalog-{url_hash}.json" - meta_file = self.cache_dir / f"step-catalog-{url_hash}-meta.json" - return cache_file, meta_file - - def _is_url_cache_valid(self, url: str) -> bool: - """Check if cached data for a URL is still fresh.""" - _, meta_file = self._get_cache_paths(url) - if not meta_file.exists(): - return False - try: - with open(meta_file, encoding="utf-8") as f: - meta = json.load(f) - if not isinstance(meta, dict): - return False - fetched_at = float(meta.get("fetched_at", 0)) - return (time.time() - fetched_at) < self.CACHE_DURATION - except (json.JSONDecodeError, OSError, TypeError, ValueError): - return False - - def _fetch_single_catalog( - self, entry: StepCatalogEntry, force_refresh: bool = False - ) -> dict[str, Any]: - """Fetch a single catalog, using cache when possible.""" - cache_safe = self._is_cache_path_safe() - cache_file, meta_file = self._get_cache_paths(entry.url) - - if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url): - try: - with open(cache_file, encoding="utf-8") as f: - cached = json.load(f) - if isinstance(cached, dict): - return cached - except (UnicodeDecodeError, json.JSONDecodeError, OSError): - # Ignore invalid/unreadable cache and fall back to fetching from source. - pass - - from urllib.parse import urlparse - from specify_cli.authentication.http import open_url as _open_url - - def _validate_url(url: str) -> None: - # A malformed authority (e.g. "https://[::1") makes urlparse / - # hostname access raise ValueError; treat it as a refused fetch - # rather than leaking a raw ValueError (this also validates the - # post-redirect resp.geturl(), so a hostile redirect target cannot - # crash the fetch either). - try: - parsed = urlparse(url) - hostname = parsed.hostname - _ = parsed.port - except (TypeError, ValueError): - raise StepCatalogError( - f"Refusing to fetch catalog from malformed URL: {url}" - ) from None - is_localhost = hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not ( - parsed.scheme == "http" and is_localhost - ): - raise StepCatalogError( - f"Refusing to fetch catalog from non-HTTPS URL: {url}" - ) - if not hostname: - raise StepCatalogError( - f"Refusing to fetch catalog from URL with no hostname: {url}" - ) - - _validate_url(entry.url) - - # Validate EVERY redirect hop, not just the final URL: _open_url follows - # redirects, so an https:// entry that 30x-redirects through http:// (or - # to a non-HTTPS host mid-chain) could otherwise let a network attacker - # rewrite the next hop and slip a payload past a final-URL-only check. - # redirect_validator runs before each hop; the geturl() check below is - # retained as a defense-in-depth backstop. Mirrors the presets/extensions - # catalog fix (#3523 / #3524). - def _validate_redirect(_old_url: str, new_url: str) -> None: - _validate_url(new_url) - - try: - with _open_url( - entry.url, timeout=30, redirect_validator=_validate_redirect - ) as resp: - _validate_url(resp.geturl()) - data = json.loads( - read_response_limited( - resp, - max_bytes=MAX_JSON_CATALOG_BYTES, - error_type=StepCatalogError, - label="step catalog", - ).decode("utf-8") - ) - except Exception as exc: - if cache_safe and cache_file.exists(): - try: - with open(cache_file, encoding="utf-8") as f: - cached = json.load(f) - if isinstance(cached, dict): - return cached - except (json.JSONDecodeError, ValueError, OSError): - # Stale-cache read failed; let the original fetch error propagate. - pass - raise StepCatalogError( - f"Failed to fetch catalog from {entry.url}: {exc}" - ) from exc - - if not isinstance(data, dict): - raise StepCatalogError( - f"Catalog from {entry.url} is not a valid JSON object." - ) - - if cache_safe: - try: - self.cache_dir.mkdir(parents=True, exist_ok=True) - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - with open(meta_file, "w", encoding="utf-8") as f: - json.dump({"url": entry.url, "fetched_at": time.time()}, f) - except OSError: - pass # Proceed without caching if disk write fails - - return data - - def _get_merged_steps( - self, force_refresh: bool = False - ) -> dict[str, dict[str, Any]]: - """Merge steps from all active catalogs (lower priority number wins).""" - catalogs = self.get_active_catalogs() - merged: dict[str, dict[str, Any]] = {} - fetch_errors = 0 - - for entry in reversed(catalogs): - try: - data = self._fetch_single_catalog(entry, force_refresh) - except StepCatalogError: - fetch_errors += 1 - continue - steps = data.get("steps", {}) - if isinstance(steps, dict): - for step_id, step_data in steps.items(): - if not isinstance(step_data, dict): - continue - step_data["_catalog_name"] = entry.name - step_data["_install_allowed"] = entry.install_allowed - merged[step_id] = step_data - elif isinstance(steps, list): - for step_data in steps: - if not isinstance(step_data, dict): - continue - raw_step_id = step_data.get("id") - if raw_step_id is None: - continue - step_id = str(raw_step_id).strip() - if step_id: - step_data["id"] = step_id - step_data["_catalog_name"] = entry.name - step_data["_install_allowed"] = entry.install_allowed - merged[step_id] = step_data - if fetch_errors == len(catalogs) and catalogs: - raise StepCatalogError("All configured step catalogs failed to fetch.") - return merged - - # -- Public API ------------------------------------------------------- - - def search( - self, - query: str | None = None, - ) -> list[dict[str, Any]]: - """Search step types across all configured catalogs.""" - merged = self._get_merged_steps() - results: list[dict[str, Any]] = [] - - for step_id, step_data in merged.items(): - step_data.setdefault("id", step_id) - if query: - q = query.lower() - searchable = " ".join( - [ - str(step_data.get("name") or ""), - str(step_data.get("description") or ""), - str(step_data.get("id") or ""), - ] - ).lower() - if q not in searchable: - continue - results.append(step_data) - return results - - def get_step_info(self, step_id: str) -> dict[str, Any] | None: - """Get details for a specific step from the catalog.""" - merged = self._get_merged_steps() - step = merged.get(step_id) - if step: - step.setdefault("id", step_id) - return step - - def get_catalog_configs(self) -> list[dict[str, Any]]: - """Return current catalog configuration as a list of dicts.""" - entries = self.get_active_catalogs() - return [ - { - "name": e.name, - "url": e.url, - "priority": e.priority, - "install_allowed": e.install_allowed, - "description": e.description, - } - for e in entries - ] - - def add_catalog(self, url: str, name: str | None = None) -> None: - """Add a catalog source to the project-level config.""" - self._validate_catalog_url(url) - config_path = self.project_root / ".specify" / "step-catalogs.yml" - - data: dict[str, Any] = {"catalogs": []} - if config_path.exists(): - try: - raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: - raise StepValidationError( - f"Catalog config file is unreadable or malformed: {exc}" - ) from exc - if raw is None: - raw = {} - elif not isinstance(raw, dict): - raise StepValidationError( - "Catalog config file is corrupted (expected a mapping)." - ) - data = raw - - catalogs = data.get("catalogs", []) - if not isinstance(catalogs, list): - raise StepValidationError( - "Catalog config 'catalogs' must be a list." - ) - for cat in catalogs: - if isinstance(cat, dict) and cat.get("url") == url: - raise StepValidationError( - f"Catalog URL already configured: {url}" - ) - - # Coerce existing priorities to int with a safe fallback so a user-edited - # step-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up. - def _coerce_priority(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError, OverflowError): - # OverflowError: int(float("inf")) — treat an uncoercible - # existing priority as 0 rather than crashing 'catalog add'. - return 0 - - max_priority = max( - ( - _coerce_priority(cat.get("priority", 0)) - for cat in catalogs - if isinstance(cat, dict) - ), - default=0, - ) - catalogs.append( - { - "name": name or f"catalog-{len(catalogs) + 1}", - "url": url, - "priority": max_priority + 1, - "install_allowed": True, - "description": "", - } - ) - data["catalogs"] = catalogs - - try: - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - yaml.dump( - data, f, default_flow_style=False, sort_keys=False, allow_unicode=True - ) - except OSError as exc: - raise StepValidationError( - f"Failed to write catalog config {config_path}: {exc}" - ) from exc - - def remove_catalog(self, index: int) -> str: - """Remove a catalog source by index (0-based). Returns the removed name.""" - config_path = self.project_root / ".specify" / "step-catalogs.yml" - if not config_path.exists(): - raise StepValidationError("No step catalog config file found.") - - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: - raise StepValidationError( - f"Catalog config file is unreadable or malformed: {exc}" - ) from exc - if data is None: - data = {} - elif not isinstance(data, dict): - raise StepValidationError( - "Catalog config file is corrupted (expected a mapping)." - ) - catalogs = data.get("catalogs", []) - if not isinstance(catalogs, list): - raise StepValidationError( - "Catalog config 'catalogs' must be a list." - ) - - if index < 0 or index >= len(catalogs): - raise StepValidationError( - f"Catalog index {index} out of range (0-{len(catalogs) - 1})." - ) - - removed = catalogs.pop(index) - data["catalogs"] = catalogs - - try: - with open(config_path, "w", encoding="utf-8") as f: - yaml.dump( - data, f, default_flow_style=False, sort_keys=False, allow_unicode=True - ) - except OSError as exc: - raise StepValidationError( - f"Failed to write catalog config {config_path}: {exc}" - ) from exc - - if isinstance(removed, dict): - return removed.get("name", f"catalog-{index + 1}") - return f"catalog-{index + 1}" diff --git a/src/specify_cli/workflows/catalog/command_add.py b/src/specify_cli/workflows/catalog/command_add.py new file mode 100644 index 0000000000..6363c07c20 --- /dev/null +++ b/src/specify_cli/workflows/catalog/command_add.py @@ -0,0 +1,25 @@ +"""Command handler for ``specify workflow catalog add``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import catalog_app + + +@catalog_app.command("add") +def workflow_catalog_add( + url: str = cli.typer.Argument(..., help="Catalog URL to add"), + name: str | None = cli.typer.Option(None, "--name", help="Catalog name"), +): + """Add a workflow catalog source.""" + from . import WorkflowCatalog, WorkflowValidationError + + project_root = cli._require_specify_project() + catalog = WorkflowCatalog(project_root) + try: + catalog.add_catalog(url, name) + except WorkflowValidationError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Catalog source added: {url}") diff --git a/src/specify_cli/workflows/catalog/command_list.py b/src/specify_cli/workflows/catalog/command_list.py new file mode 100644 index 0000000000..d3084739e2 --- /dev/null +++ b/src/specify_cli/workflows/catalog/command_list.py @@ -0,0 +1,38 @@ +"""Command handler for ``specify workflow catalog list``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import catalog_app + + +@catalog_app.command("list") +def workflow_catalog_list(): + """List configured workflow catalog sources.""" + from . import WorkflowCatalog, WorkflowCatalogError + + project_root = cli._require_specify_project() + catalog = WorkflowCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except WorkflowCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg["install_allowed"] + else "[yellow]discovery only[/yellow]" + ) + cli.console.print( + f" [{i}] [bold]{cli._escape_markup(str(cfg['name']))}[/bold] — {install_status}" + ) + cli.console.print(f" {cli._escape_markup(str(cfg['url']))}") + if cfg.get("description"): + cli.console.print( + f" [dim]{cli._escape_markup(str(cfg['description']))}[/dim]" + ) + cli.console.print() diff --git a/src/specify_cli/workflows/catalog/command_remove.py b/src/specify_cli/workflows/catalog/command_remove.py new file mode 100644 index 0000000000..4240e6cdbf --- /dev/null +++ b/src/specify_cli/workflows/catalog/command_remove.py @@ -0,0 +1,26 @@ +"""Command handler for ``specify workflow catalog remove``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import catalog_app + + +@catalog_app.command("remove") +def workflow_catalog_remove( + index: int = cli.typer.Argument( + ..., help="Catalog index to remove (from 'catalog list')" + ), +): + """Remove a workflow catalog source by index.""" + from . import WorkflowCatalog, WorkflowValidationError + + project_root = cli._require_specify_project() + catalog = WorkflowCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except WorkflowValidationError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") diff --git a/src/specify_cli/workflows/command_add.py b/src/specify_cli/workflows/command_add.py new file mode 100644 index 0000000000..774d575156 --- /dev/null +++ b/src/specify_cli/workflows/command_add.py @@ -0,0 +1,507 @@ +"""Command handler for ``specify workflow add``.""" + +from __future__ import annotations + +from . import _commands as cli + + +def _cleanup_download_tmp_path(tmp_path: cli.Path | None) -> None: + """Best-effort unlink of a partially-downloaded workflow temp file. + + A cleanup ``OSError`` here must never replace/mask whatever error or + interrupt is already propagating -- warn about it and keep going. + """ + if tmp_path is None: + return + try: + tmp_path.unlink(missing_ok=True) + except OSError as cleanup_exc: + cli.console.print( + "[yellow]Warning:[/yellow] Could not remove temporary " + f"workflow download file: {cli._escape_markup(str(cleanup_exc))} " + f"(path: {cli._escape_markup(str(tmp_path))})" + ) + + +def _workflow_package_has_companions(package_dir: cli.Path) -> bool: + """Return whether a directory contains anything beyond workflow.yml.""" + return any(path.name != "workflow.yml" for path in package_dir.iterdir()) + + +@cli.workflow_app.command("add") +def workflow_add( + source: str = cli.typer.Argument(..., help="Workflow ID, URL, or local path"), + dev: bool = cli.typer.Option( + False, "--dev", help="Install from a local workflow YAML file or directory" + ), + from_url: str | None = cli.typer.Option( + None, "--from", help="Install from a custom URL" + ), +): + """Install a workflow from catalog, URL, or local path.""" + from . import load_custom_steps + from .engine import WorkflowDefinition + + project_root = cli._require_specify_project() + load_custom_steps(project_root) + cli._open_workflow_registry(project_root) + workflows_dir = project_root / ".specify" / "workflows" + # With --from, source names the expected workflow ID: validate it up + # front so a URL/path/typo fails without a network fetch. + if from_url is not None and not dev: + cli._validate_workflow_id_or_exit(source) + # Reject a symlinked .specify / .specify/workflows before any write so an + # install can't escape the project root (covers the local, URL, and + # catalog branches below — all write beneath workflows_dir). + cli._reject_unsafe_dir(project_root / ".specify", ".specify") + cli._reject_unsafe_dir(workflows_dir, ".specify/workflows") + + def _validate_and_install_local( + yaml_path: cli.Path, source_label: str, expected_id: str | None = None + ) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + with yaml_path.open("rb") as source_file: + source_mode = cli.os.fstat(source_file.fileno()).st_mode & 0o7777 + source_content = source_file.read() + definition = WorkflowDefinition.from_string(source_content.decode("utf-8")) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to read workflow YAML: " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + except (UnicodeDecodeError, ValueError, cli.yaml.YAMLError) as exc: + cli.console.print( + f"[red]Error:[/red] Invalid workflow YAML: {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + # Non-string ids (e.g. unquoted ``id: 123`` or ``id: 0``) fall through + # to validate_workflow below, which reports a typed error instead of + # crashing on ``.strip()`` here. Only None/empty/whitespace-only ids + # are rejected as missing. + if ( + definition.id is None + or definition.id == "" + or (isinstance(definition.id, str) and not definition.id.strip()) + ): + cli.console.print( + "[red]Error:[/red] Workflow definition has an empty or missing 'id'" + ) + raise cli.typer.Exit(1) + + from .engine import validate_workflow + + errors = validate_workflow(definition) + if errors: + cli.console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + cli.console.print(f" \u2022 {cli._escape_markup(str(err))}") + raise cli.typer.Exit(1) + + if expected_id is not None and definition.id != expected_id: + cli.console.print( + f"[red]Error:[/red] Workflow ID in YAML ({cli._escape_markup(repr(definition.id))}) " + f"does not match the requested workflow ID ({cli._escape_markup(repr(expected_id))})." + ) + raise cli.typer.Exit(1) + + dest_dir = cli._safe_workflow_id_dir(workflows_dir, definition.id) + dest_file = dest_dir / "workflow.yml" + existed_before = dest_dir.is_dir() + + try: + staged_file = cli._stage_workflow_file(dest_dir) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to install workflow " + f"'{cli._escape_markup(definition.id)}': {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + + try: + # Write the exact bytes parsed above so a concurrent source edit + # cannot desynchronize installed content from validated metadata. + staged_file.write_bytes(source_content) + staged_file.set_mode(source_mode) + except OSError as exc: + cli._safe_discard_staged_workflow_file( + staged_file, dest_dir, existed_before + ) + cli.console.print( + f"[red]Error:[/red] Failed to install workflow " + f"'{cli._escape_markup(definition.id)}': {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + + try: + transaction = cli._workflow_install_transaction(project_root) + with transaction: + transaction_existed_before = existed_before or dest_file.exists() + transaction_registry = cli._open_workflow_registry(project_root) + # Commit the staged copy onto dest_file via an atomic swap. A + # prior file is renamed aside so registry failure can restore it. + try: + backup_file = cli._commit_workflow_file( + staged_file, dest_file, transaction_existed_before + ) + except OSError as exc: + cli._safe_discard_staged_workflow_file( + staged_file, dest_dir, existed_before + ) + cli.console.print( + f"[red]Error:[/red] Failed to install workflow " + f"'{cli._escape_markup(definition.id)}': " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + try: + entry = { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + } + existing = transaction_registry.get(definition.id) + if isinstance(existing, dict) and not existing.get("enabled", True): + entry["enabled"] = False + transaction_registry.add(definition.id, entry) + except (OSError, TypeError, ValueError) as exc: + cli._safe_rollback_committed_workflow_file( + dest_file, + dest_dir, + transaction_existed_before, + backup_file, + ) + cli.console.print( + f"[red]Error:[/red] Failed to update workflow registry for " + f"'{cli._escape_markup(definition.id)}': " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + # Registry update succeeded while the transaction lock is held. + cli._discard_committed_backup_file(backup_file) + except cli.typer.Exit: + cli._safe_discard_staged_workflow_file( + staged_file, dest_dir, existed_before + ) + raise + except OSError as exc: + cli._safe_discard_staged_workflow_file( + staged_file, dest_dir, existed_before + ) + cli.console.print( + f"[red]Error:[/red] Failed to lock workflow install " + f"'{cli._escape_markup(definition.id)}': {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + cli.console.print( + f"[green]✓[/green] Workflow '{cli._escape_markup(definition.name)}' " + f"({cli._escape_markup(definition.id)}) installed" + ) + + # Explicit local install (mirrors `extension add --dev`). --dev takes + # precedence over --from so a URL that would be ignored is never fetched. + if dev: + dev_path = cli.Path(source).expanduser() + if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"): + _validate_and_install_local(dev_path, str(dev_path)) + return + if ( + dev_path.is_file() + and cli.archive_format_from_name(str(dev_path)) is not None + ): + import tempfile + + with tempfile.TemporaryDirectory( + prefix="speckit-workflow-archive-" + ) as tmpdir: + extracted_root = cli.Path(tmpdir) + try: + cli.safe_extract_archive(dev_path, extracted_root) + package_root = cli._workflow_package_root(extracted_root) + except ValueError as exc: + cli.console.print( + f"[red]Error:[/red] Invalid workflow archive: " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + cli._install_workflow_package( + project_root, + workflows_dir, + package_root, + str(dev_path), + ) + return + if dev_path.is_dir(): + dev_wf_file = dev_path / "workflow.yml" + if not dev_wf_file.is_file(): + cli.console.print( + f"[red]Error:[/red] No workflow.yml found in {cli._escape_markup(source)}" + ) + raise cli.typer.Exit(1) + if _workflow_package_has_companions(dev_path): + cli._install_workflow_package( + project_root, + workflows_dir, + dev_path, + str(dev_path), + ) + else: + _validate_and_install_local(dev_wf_file, str(dev_path)) + return + cli.console.print( + "[red]Error:[/red] --dev source must be a workflow YAML file, " + "supported archive, or directory containing workflow.yml: " + f"{cli._escape_markup(source)}" + ) + raise cli.typer.Exit(1) + + # Try as URL (http/https) — either the positional source is a URL, or an + # explicit --from URL names where to fetch it (mirrors `extension add --from`). + download_url = ( + from_url + if from_url is not None + else (source if source.startswith(("http://", "https://")) else None) + ) + if download_url is not None: + from urllib.parse import urlparse + from specify_cli.authentication.http import open_url as _open_url + + try: + urlparse(download_url).port + except ValueError: + cli.console.print( + f"[red]Error:[/red] Invalid URL: {cli._escape_markup(download_url)}" + ) + raise cli.typer.Exit(1) + if not cli.is_https_or_localhost_http(download_url): + cli.console.print( + "[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost." + ) + raise cli.typer.Exit(1) + + if from_url is not None: + from rich.panel import Panel + + safe_url = cli._escape_markup(from_url) + cli.console.print() + cli.console.print( + Panel( + "[bold]You are installing a workflow from an external URL " + "that is not\nlisted in any of your configured workflow " + "catalogs.[/bold]\n\n" + f"URL: {safe_url}\n\n" + "Only install workflows from sources you trust.", + title="[bold yellow]⚠ Untrusted Source[/bold yellow]", + border_style="yellow", + padding=(1, 2), + ) + ) + cli.console.print() + if not cli.typer.confirm("Continue with installation?", default=False): + cli.console.print("Cancelled") + raise cli.typer.Exit(0) + + from specify_cli._github_http import ( + resolve_github_release_asset_api_url as _resolve_gh_asset, + ) + from specify_cli.authentication.http import ( + github_provider_hosts as _github_provider_hosts, + ) + + _wf_url_extra_headers = None + _resolved_wf_url = _resolve_gh_asset( + download_url, + _open_url, + timeout=30, + github_hosts=_github_provider_hosts(), + redirect_validator=cli._reject_insecure_download_redirect, + ) + if _resolved_wf_url: + download_url = _resolved_wf_url + _wf_url_extra_headers = {"Accept": "application/octet-stream"} + + import tempfile + + tmp_path: cli.Path | None = None + downloaded_archive_format = None + try: + with _open_url( + download_url, + timeout=30, + extra_headers=_wf_url_extra_headers, + redirect_validator=cli._reject_insecure_download_redirect, + ) as resp: + final_url = resp.geturl() + if not cli.is_https_or_localhost_http(final_url): + cli.console.print( + f"[red]Error:[/red] URL redirected to non-HTTPS: {cli._escape_markup(final_url)}" + ) + raise cli.typer.Exit(1) + content_type = ( + resp.getheader("Content-Type") + if hasattr(resp, "getheader") + else None + ) + downloaded_archive_format = ( + cli.archive_format_from_name(final_url) + or cli.archive_format_from_name(download_url) + or cli.archive_format_from_content_type(content_type) + ) + declared_yaml = cli._workflow_yaml_is_declared(final_url, content_type) + suffix = ( + cli.archive_suffix(downloaded_archive_format) + if downloaded_archive_format is not None + else ".yml" + if declared_yaml + else ".download" + ) + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + # Assign tmp_path immediately: NamedTemporaryFile(delete=False) + # creates the file on disk right away, before any bytes are + # written, so a failure in the size-limited read below must + # still be able to find and remove it. + tmp_path = cli.Path(tmp.name) + if downloaded_archive_format is not None: + downloaded_content = cli.read_response_limited( + resp, + error_type=ValueError, + label="workflow archive download", + ) + elif declared_yaml: + downloaded_content = cli._read_response_within_limit(resp) + else: + downloaded_content = cli.read_response_limited( + resp, + error_type=ValueError, + label="workflow download", + ) + downloaded_archive_format = cli._sniff_workflow_archive_format( + downloaded_content + ) + if downloaded_archive_format is None: + cli._enforce_workflow_yaml_size(downloaded_content) + tmp.write(downloaded_content) + except cli.typer.Exit: + _cleanup_download_tmp_path(tmp_path) + raise + except Exception as exc: + # A cleanup failure here must never replace/mask the + # original download error below with a raw, unhandled + # OSError -- warn about it and keep going, exactly like the + # later post-install finally cleanup does. + _cleanup_download_tmp_path(tmp_path) + cli.console.print( + f"[red]Error:[/red] Failed to download workflow: {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + except BaseException: + # Covers KeyboardInterrupt and other non-Exception exits: the + # temp file is already created on disk (delete=False) by this + # point, so an interrupt during the size-limited read must still + # unlink it rather than leaking it to the system temp directory. + _cleanup_download_tmp_path(tmp_path) + raise + try: + if downloaded_archive_format is None: + _validate_and_install_local( + tmp_path, + download_url, + expected_id=source if from_url else None, + ) + else: + with tempfile.TemporaryDirectory( + prefix="speckit-workflow-archive-" + ) as extract_dir: + extracted_root = cli.Path(extract_dir) + try: + cli.safe_extract_archive( + tmp_path, + extracted_root, + source_name=final_url, + content_type=content_type, + ) + package_root = cli._workflow_package_root(extracted_root) + except ValueError as exc: + cli.console.print( + f"[red]Error:[/red] Invalid workflow archive: " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + cli._install_workflow_package( + project_root, + workflows_dir, + package_root, + download_url, + expected_id=source if from_url else None, + ) + finally: + # Best-effort: _validate_and_install_local may already have + # committed the file + registry entry (success) or already + # raised its own clean typer.Exit (failure) by this point -- + # either way, a cleanup OSError here must never mask that + # outcome or surface as its own unhandled failure. Warn instead, + # same as the committed-backup cleanup above. + try: + tmp_path.unlink(missing_ok=True) + except OSError as exc: + cli.console.print( + "[yellow]Warning:[/yellow] Could not remove temporary " + f"workflow download file: {cli._escape_markup(str(exc))} " + f"(path: {cli._escape_markup(str(tmp_path))})" + ) + return + + # Try as a local file/directory + source_path = cli.Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif ( + source_path.is_file() + and cli.archive_format_from_name(str(source_path)) is not None + ): + import tempfile + + with tempfile.TemporaryDirectory( + prefix="speckit-workflow-archive-" + ) as tmpdir: + extracted_root = cli.Path(tmpdir) + try: + cli.safe_extract_archive(source_path, extracted_root) + package_root = cli._workflow_package_root(extracted_root) + except ValueError as exc: + cli.console.print( + f"[red]Error:[/red] Invalid workflow archive: " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + cli._install_workflow_package( + project_root, + workflows_dir, + package_root, + str(source_path), + ) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.is_file(): + cli.console.print( + f"[red]Error:[/red] No workflow.yml found in {cli._escape_markup(source)}" + ) + raise cli.typer.Exit(1) + if _workflow_package_has_companions(source_path): + cli._install_workflow_package( + project_root, + workflows_dir, + source_path, + str(source_path), + ) + else: + _validate_and_install_local(wf_file, str(source_path)) + return + + # Try from catalog + cli._install_workflow_from_catalog(project_root, workflows_dir, source) diff --git a/src/specify_cli/workflows/command_disable.py b/src/specify_cli/workflows/command_disable.py new file mode 100644 index 0000000000..83948e9737 --- /dev/null +++ b/src/specify_cli/workflows/command_disable.py @@ -0,0 +1,16 @@ +"""Command handler for ``specify workflow disable``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("disable") +def workflow_disable( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID to disable"), +): + """Disable a workflow without removing it.""" + cli._set_workflow_enabled(workflow_id, False) + cli.console.print( + f"To re-enable: specify workflow enable {cli._escape_markup(workflow_id)}" + ) diff --git a/src/specify_cli/workflows/command_enable.py b/src/specify_cli/workflows/command_enable.py new file mode 100644 index 0000000000..e0819cddae --- /dev/null +++ b/src/specify_cli/workflows/command_enable.py @@ -0,0 +1,13 @@ +"""Command handler for ``specify workflow enable``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("enable") +def workflow_enable( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID to enable"), +): + """Enable a disabled workflow.""" + cli._set_workflow_enabled(workflow_id, True) diff --git a/src/specify_cli/workflows/command_info.py b/src/specify_cli/workflows/command_info.py new file mode 100644 index 0000000000..ba3741f7b1 --- /dev/null +++ b/src/specify_cli/workflows/command_info.py @@ -0,0 +1,121 @@ +"""Command handler for ``specify workflow info``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("info") +def workflow_info( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID"), +): + """Show workflow details and step graph.""" + from .catalog import WorkflowCatalog, WorkflowCatalogError + from .engine import WorkflowEngine + + project_root = cli._require_specify_project() + + # Check installed first + registry = cli._open_workflow_registry(project_root) + installed = registry.get(workflow_id) + + engine = WorkflowEngine(project_root) + + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass + except ValueError as exc: + cli.console.print( + f"[red]Error:[/red] Invalid workflow: {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + + if definition: + # Escape every user-controlled field: workflow.yml values (name, + # version, author, description, integration, input names/types) are not + # trusted, and console.print has Rich markup enabled, so an unescaped + # `[...]` in any of them is parsed as a style tag and silently swallowed + # (same defect fixed for the step graph below; the sibling workflow_list + # already escapes all of these). + cli.console.print( + f"\n[bold cyan]{cli._escape_markup(str(definition.name))}[/bold cyan] " + f"({cli._escape_markup(str(definition.id))})" + ) + cli.console.print( + f" Version: {cli._escape_markup(str(definition.version))}" + ) + if definition.author: + cli.console.print( + f" Author: {cli._escape_markup(str(definition.author))}" + ) + if definition.description: + cli.console.print( + f" Description: {cli._escape_markup(str(definition.description))}" + ) + if definition.default_integration: + cli.console.print( + f" Integration: {cli._escape_markup(str(definition.default_integration))}" + ) + if installed: + cli.console.print(" [green]Installed[/green]") + + if definition.inputs: + cli.console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + cli.console.print( + f" {cli._escape_markup(str(name))} " + f"({cli._escape_markup(str(inp.get('type', 'string')))}) — {req}" + ) + + if definition.steps: + cli.console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + # Escape the literal bracket (\[) so Rich renders `[]` + # instead of parsing it as a style tag named after the step + # type (which it silently swallows); escape id/type too, as + # the sibling workflow_list does. Mirrors the `\[disabled]` + # precedent above. + cli.console.print( + f" → {cli._escape_markup(str(step.get('id', '?')))} " + f"\\[{cli._escape_markup(str(stype))}]" + ) + return + + # Try catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: + info = None + + if info: + # Catalog-derived fields are untrusted; escape them so bracketed content + # is rendered literally rather than parsed (and swallowed) as Rich markup. + cli.console.print( + f"\n[bold cyan]{cli._escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] " + f"({cli._escape_markup(str(workflow_id))})" + ) + cli.console.print( + f" Version: {cli._escape_markup(str(info.get('version', '?')))}" + ) + if info.get("description"): + cli.console.print( + f" Description: {cli._escape_markup(str(info['description']))}" + ) + info_tags = info.get("tags", []) + if isinstance(info_tags, list) and info_tags: + safe_tags = cli._escape_markup(", ".join(str(t) for t in info_tags)) + cli.console.print(f" Tags: {safe_tags}") + cli.console.print(" [yellow]Not installed[/yellow]") + else: + cli.console.print( + f"[red]Error:[/red] Workflow '{cli._escape_markup(str(workflow_id))}' not found" + ) + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/command_list.py b/src/specify_cli/workflows/command_list.py new file mode 100644 index 0000000000..69cc3be395 --- /dev/null +++ b/src/specify_cli/workflows/command_list.py @@ -0,0 +1,36 @@ +"""Command handler for ``specify workflow list``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("list") +def workflow_list(): + """List installed workflows.""" + project_root = cli._require_specify_project() + registry = cli._open_workflow_registry(project_root) + installed = registry.list() + + if not installed: + cli.console.print("[yellow]No workflows installed.[/yellow]") + cli.console.print("\nInstall a workflow with:") + cli.console.print(" [cyan]specify workflow add [/cyan]") + return + + cli.console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + safe_id = cli._escape_markup(wf_id) + if not isinstance(wf_data, dict): + cli.console.print( + f" [yellow]Warning:[/yellow] Skipping corrupted registry entry '{safe_id}'.\n" + ) + continue + marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" + name = cli._escape_markup(str(wf_data.get("name", wf_id))) + version = cli._escape_markup(str(wf_data.get("version", "?"))) + cli.console.print(f" [bold]{name}[/bold] ({safe_id}) v{version}{marker}") + desc = wf_data.get("description", "") + if desc: + cli.console.print(f" {cli._escape_markup(str(desc))}") + cli.console.print() diff --git a/src/specify_cli/workflows/command_remove.py b/src/specify_cli/workflows/command_remove.py new file mode 100644 index 0000000000..7c3a68af7f --- /dev/null +++ b/src/specify_cli/workflows/command_remove.py @@ -0,0 +1,127 @@ +"""Command handler for ``specify workflow remove``.""" + +from __future__ import annotations + +from . import _commands as cli + + +def _remove_workflow_locked( + project_root: cli.Path, workflows_dir: cli.Path, workflow_id: str +) -> cli.Path | None: + """Stage a workflow directory and persist removal while locked.""" + registry = cli._open_workflow_registry(project_root) + safe_id = cli._escape_markup(workflow_id) + if not registry.is_installed(workflow_id): + cli.console.print(f"[red]Error:[/red] Workflow '{safe_id}' is not installed") + raise cli.typer.Exit(1) + + workflow_dir_unresolved = workflows_dir / workflow_id + if workflow_dir_unresolved.is_symlink(): + cli.console.print( + f"[red]Error:[/red] Refusing to remove symlinked " + f".specify/workflows/{safe_id}" + ) + raise cli.typer.Exit(1) + + workflow_dir = workflow_dir_unresolved.resolve() + try: + rel_parts = workflow_dir.relative_to(workflows_dir.resolve()).parts + except ValueError: + cli.console.print( + f"[red]Error:[/red] Invalid workflow ID: " + f"{cli._escape_markup(repr(workflow_id))}" + ) + raise cli.typer.Exit(1) + if rel_parts != (workflow_id,): + cli.console.print( + f"[red]Error:[/red] Invalid workflow ID: " + f"{cli._escape_markup(repr(workflow_id))}" + ) + raise cli.typer.Exit(1) + + if workflow_dir.exists() and not workflow_dir.is_dir(): + cli.console.print( + f"[red]Error:[/red] .specify/workflows/{safe_id} exists " + "but is not a directory" + ) + raise cli.typer.Exit(1) + + import tempfile + + staged_dir: cli.Path | None = None + if workflow_dir.exists(): + try: + reserved = cli.Path( + tempfile.mkdtemp(prefix=f".{workflow_id}.removing-", dir=workflows_dir) + ) + reserved.rmdir() + cli.os.rename(workflow_dir, reserved) + staged_dir = reserved + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to stage workflow directory " + f"{cli._escape_markup(str(workflow_dir))} for removal: " + f"{cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + + try: + registry.remove(workflow_id) + except (OSError, TypeError, ValueError) as exc: + if staged_dir is not None: + try: + cli.os.rename(staged_dir, workflow_dir) + except OSError as restore_exc: + cli.console.print( + f"[yellow]Warning:[/yellow] Failed to restore workflow " + "directory after registry update failure; it remains " + f"staged at {cli._escape_markup(str(staged_dir))}: " + f"{cli._escape_markup(str(restore_exc))}" + ) + cli.console.print( + f"[red]Error:[/red] Failed to update workflow registry for " + f"'{safe_id}': {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + return staged_dir + + +@cli.workflow_app.command("remove") +def workflow_remove( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID to uninstall"), +): + """Uninstall a workflow.""" + project_root = cli._require_specify_project() + workflows_dir = project_root / ".specify" / "workflows" + cli._validate_workflow_id_or_exit(workflow_id) + safe_id = cli._escape_markup(workflow_id) + import shutil + + try: + with cli._workflow_install_transaction(project_root): + staged_dir = _remove_workflow_locked( + project_root, workflows_dir, workflow_id + ) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to lock workflow removal " + f"'{safe_id}': {cli._escape_markup(str(exc))}" + ) + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") + + # The registry has already durably committed the removal at this point, + # so it must stand regardless of what happens below: deleting the staged + # directory is now just cleanup, not a data-integrity concern, and a + # failure here is reported as a warning (not an error) to avoid + # contradicting the registry state that already succeeded. + if staged_dir is not None: + try: + shutil.rmtree(staged_dir) + except OSError as exc: + cli.console.print( + f"[yellow]Warning:[/yellow] Workflow '{safe_id}' was removed, but its " + f"staged directory could not be deleted: {cli._escape_markup(str(exc))}. " + f"Remove it manually: {cli._escape_markup(str(staged_dir))}" + ) diff --git a/src/specify_cli/workflows/command_resolve.py b/src/specify_cli/workflows/command_resolve.py new file mode 100644 index 0000000000..6caa4e6253 --- /dev/null +++ b/src/specify_cli/workflows/command_resolve.py @@ -0,0 +1,17 @@ +"""Command handler for ``specify workflow resolve``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("resolve") +def workflow_resolve_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID to resolve"), +): + """Show layer attribution for a resolved workflow.""" + from .overlay.operations import workflow_resolve + + project_root = cli._require_specify_project() + if workflow_resolve(project_root, workflow_id) is None: + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/command_resume.py b/src/specify_cli/workflows/command_resume.py new file mode 100644 index 0000000000..3be24f9582 --- /dev/null +++ b/src/specify_cli/workflows/command_resume.py @@ -0,0 +1,113 @@ +"""Command handler for ``specify workflow resume``.""" + +from __future__ import annotations + +from . import _commands as cli +from . import _command_resume_state as resume_state + + +@cli.workflow_app.command("resume") +def workflow_resume( + run_id: str = cli.typer.Argument(..., help="Run ID to resume"), + input_values: list[str] | None = cli.typer.Option( + None, "--input", "-i", help="Updated input values as key=value pairs" + ), + json_output: bool = cli.typer.Option( + False, + "--json", + help="Emit the resume outcome as a single JSON object instead of formatted text.", + ), +): + """Resume a paused or failed workflow run.""" + from . import load_custom_steps + from .engine import RunState, WorkflowEngine + + project_root = cli._require_specify_project() + load_custom_steps(project_root) + engine = WorkflowEngine(project_root) + if not json_output: + # Escape the literal bracket (\[) so Rich renders `[]` instead + # of parsing it as a style tag named after the step id -- which it + # silently swallows (losing the only identifying content on the line), + # applies as formatting when the id happens to be a real style such as + # `bold`, or raises MarkupError when the id forms a closing tag (`/`), + # failing the whole run. Escape the interpolated values too, since both + # come from workflow YAML. Mirrors the `\[]` step-graph precedent + # in workflow_info below. + engine.on_step_start = lambda sid, label: cli.console.print( + f" \u25b8 \\[{cli._escape_markup(str(sid))}] " + f"{cli._escape_markup(str(label))} \u2026" + ) + + inputs = cli._parse_input_values(input_values, json_output=json_output) + err = cli._error_console(json_output) + + # Pre-load the persisted run state so a run started from an installed + # workflow that has since been disabled cannot resume unchecked -- + # engine.resume() replays the run directly from disk with no registry + # awareness at all, which would otherwise bypass the same disabled + # guard `workflow run` enforces. Runs without installed_workflow_id + # (a direct/non-installed source, or a run persisted before this field + # existed) are unaffected and resume exactly as before. + try: + pre_state = RunState.load(run_id, project_root) + except FileNotFoundError: + err.print(f"[red]Error:[/red] Run not found: {run_id}") + raise cli.typer.Exit(1) + except ValueError as exc: + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + except OSError as exc: + err.print(f"[red]Resume failed:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + if pre_state.installed_workflow_id is not None: + try: + owner_root = resume_state._resolve_run_owner_root( + pre_state.installed_registry_root, project_root + ) + except ValueError as exc: + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + cli._require_enabled_workflow(owner_root, pre_state.installed_workflow_id, err) + elif not pre_state.installed_origin_tracked: + if cli._require_enabled_workflow(project_root, pre_state.workflow_id, err): + pre_state.installed_workflow_id = pre_state.workflow_id + pre_state.installed_origin_tracked = True + try: + pre_state.save() + except OSError as exc: + err.print(f"[red]Resume failed:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + try: + with cli._stdout_to_stderr_when(json_output): + state = engine.resume(run_id, inputs or None) + except FileNotFoundError: + err.print(f"[red]Error:[/red] Run not found: {run_id}") + raise cli.typer.Exit(1) + except ValueError as exc: + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + except Exception as exc: + err.print(f"[red]Resume failed:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + if json_output: + cli._emit_workflow_json(cli._workflow_run_payload(state)) + raise cli.typer.Exit(cli._run_outcome_exit_code(state.status.value)) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + cli.console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + + err_msg = cli._failed_step_error(state) + if err_msg: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(err_msg)}") + + raise cli.typer.Exit(cli._run_outcome_exit_code(state.status.value)) diff --git a/src/specify_cli/workflows/command_run.py b/src/specify_cli/workflows/command_run.py new file mode 100644 index 0000000000..d966fd71e1 --- /dev/null +++ b/src/specify_cli/workflows/command_run.py @@ -0,0 +1,168 @@ +"""Command handler for ``specify workflow run``.""" + +from __future__ import annotations + +from . import _commands as cli +from . import _command_run_ownership as run_ownership + + +@cli.workflow_app.command("run") +def workflow_run( + source: str = cli.typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = cli.typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), + json_output: bool = cli.typer.Option( + False, + "--json", + help="Emit the run outcome as a single JSON object instead of formatted text.", + ), +): + """Run a workflow from an installed ID or local YAML path.""" + from . import load_custom_steps + from .engine import WorkflowEngine + + source_path = cli.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 — unless SPECIFY_INIT_DIR + # explicitly names a project, in which case the strict override applies. + override = cli._resolve_init_dir_override() + project_root = override if override is not None else cli.Path.cwd() + cli._reject_unsafe_workflow_storage(project_root) + else: + project_root = cli._require_specify_project() + + load_custom_steps(project_root) + engine = WorkflowEngine(project_root) + if not json_output: + # Escape the literal bracket (\[) so Rich renders `[]` instead + # of parsing it as a style tag named after the step id -- which it + # silently swallows (losing the only identifying content on the line), + # applies as formatting when the id happens to be a real style such as + # `bold`, or raises MarkupError when the id forms a closing tag (`/`), + # failing the whole run. Escape the interpolated values too, since both + # come from workflow YAML. Mirrors the `\[]` step-graph precedent + # in workflow_info below. + engine.on_step_start = lambda sid, label: cli.console.print( + f" \u25b8 \\[{cli._escape_markup(str(sid))}] " + f"{cli._escape_markup(str(label))} \u2026" + ) + + err = cli._error_console(json_output) + + registered_id: str | None = None + registry_root = project_root + if not is_file_source: + # Reject path-equivalent spellings ("align-wf/", "align-wf/.") that + # would miss the registry lookup yet still load the installed file, + # bypassing the disabled check below. + if ( + source in cli._RESERVED_WORKFLOW_IDS + or not cli._WORKFLOW_ID_PATTERN.fullmatch(source) + ): + err.print( + f"[red]Error:[/red] Invalid workflow ID: {cli._escape_markup(repr(source))}" + ) + raise cli.typer.Exit(1) + registered_id = source + else: + # A direct YAML path may still point at an installed workflow's own + # file (lexically, or via a symlinked alias pointing into installed + # storage); map it back to its owning project and ID so the + # disabled check below can't be silently bypassed. + owner_root, owner_id = run_ownership._resolve_installed_workflow_ownership( + source_path, err + ) + if owner_id is not None: + registry_root = owner_root + registered_id = owner_id + + if registered_id is not None: + cli._require_enabled_workflow(registry_root, registered_id, err) + + try: + definition = engine.load_workflow(source_path if is_file_source else source) + except FileNotFoundError: + err.print(f"[red]Error:[/red] Workflow not found: {source}") + raise cli.typer.Exit(1) + except ValueError as exc: + err.print(f"[red]Error:[/red] Invalid workflow: {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + # Validate + errors = engine.validate(definition) + if errors: + err.print("[red]Workflow validation failed:[/red]") + for verr in errors: + err.print(f" • {cli._escape_markup(str(verr))}") + raise cli.typer.Exit(1) + + # Parse inputs + inputs = cli._parse_input_values(input_values, json_output=json_output) + + if not json_output: + cli.console.print( + f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})" + ) + cli.console.print(f"[dim]Version: {definition.version}[/dim]\n") + + try: + with cli._stdout_to_stderr_when(json_output): + state = engine.execute( + definition, + inputs, + installed_workflow_id=registered_id, + # Only persist an explicit root when the installed workflow + # genuinely belongs to a *different* project than the one + # whose runs/ directory holds this run's own state (a + # direct external workflow-file invocation) -- the common + # case (an installed workflow run from its own project) + # leaves this None so resume re-derives the owning root + # from wherever the project currently is, transparently + # surviving a project rename/move instead of baking in a + # stale absolute path at run start. + installed_registry_root=( + registry_root.resolve(strict=True) + if registered_id + and not run_ownership._same_existing_path( + registry_root, project_root + ) + else None + ), + ) + except ValueError as exc: + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + except Exception as exc: + err.print(f"[red]Workflow failed:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + if json_output: + cli._emit_workflow_json(cli._workflow_run_payload(state)) + raise cli.typer.Exit(cli._run_outcome_exit_code(state.status.value)) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + cli.console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + cli.console.print(f"[dim]Run ID: {state.run_id}[/dim]") + + err_msg = cli._failed_step_error(state) + if err_msg: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(err_msg)}") + + if state.status.value == "paused": + cli.console.print( + f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]" + ) + + raise cli.typer.Exit(cli._run_outcome_exit_code(state.status.value)) diff --git a/src/specify_cli/workflows/command_search.py b/src/specify_cli/workflows/command_search.py new file mode 100644 index 0000000000..57d9d0f5b1 --- /dev/null +++ b/src/specify_cli/workflows/command_search.py @@ -0,0 +1,43 @@ +"""Command handler for ``specify workflow search``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("search") +def workflow_search( + query: str | None = cli.typer.Argument(None, help="Search query"), + tag: str | None = cli.typer.Option(None, "--tag", help="Filter by tag"), + author: str | None = cli.typer.Option(None, "--author", help="Filter by author"), +): + """Search workflow catalogs.""" + from .catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = cli._require_specify_project() + catalog = WorkflowCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except WorkflowCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + if not results: + cli.console.print("[yellow]No workflows found.[/yellow]") + return + + cli.console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + name = cli._escape_markup(str(wf.get("name", wf.get("id", "?")))) + wf_id = cli._escape_markup(str(wf.get("id", "?"))) + version = cli._escape_markup(str(wf.get("version", "?"))) + cli.console.print(f" [bold]{name}[/bold] ({wf_id}) v{version}") + desc = wf.get("description", "") + if desc: + cli.console.print(f" {cli._escape_markup(str(desc))}") + tags = wf.get("tags", []) + if isinstance(tags, list) and tags: + safe_tags = cli._escape_markup(", ".join(str(t) for t in tags)) + cli.console.print(f" [dim]Tags: {safe_tags}[/dim]") + cli.console.print() diff --git a/src/specify_cli/workflows/command_status.py b/src/specify_cli/workflows/command_status.py new file mode 100644 index 0000000000..d9af4b48d6 --- /dev/null +++ b/src/specify_cli/workflows/command_status.py @@ -0,0 +1,129 @@ +"""Command handler for ``specify workflow status``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("status") +def workflow_status( + run_id: str | None = cli.typer.Argument( + None, help="Run ID to inspect (shows all if omitted)" + ), + json_output: bool = cli.typer.Option( + False, + "--json", + help="Emit run status as a single JSON object instead of formatted text.", + ), +): + """Show workflow run status.""" + from .engine import WorkflowEngine + + project_root = cli._require_specify_project() + engine = WorkflowEngine(project_root) + + if run_id: + # Route errors to stderr under --json so the stdout JSON stream stays + # parseable (mirrors `workflow run`/`workflow resume`); both handlers + # fire before the json_output branch below. + err = cli._error_console(json_output) + try: + from .engine import RunState + + state = RunState.load(run_id, project_root) + except FileNotFoundError: + err.print(f"[red]Error:[/red] Run not found: {run_id}") + raise cli.typer.Exit(1) + except ValueError as exc: + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + except OSError as exc: + # An unreadable state.json (bad permissions, a directory in its + # place, I/O error) must fail as cleanly as the malformed-JSON + # case above -- `workflow resume` already handles OSError here. + err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + + if json_output: + # Build on the shared run/resume payload so the common fields + # (including current_step_index) stay identical across commands. + payload = { + **cli._workflow_run_payload(state), + "created_at": state.created_at, + "updated_at": state.updated_at, + "steps": { + sid: sd.get("status", "unknown") + for sid, sd in state.step_results.items() + }, + } + cli._emit_workflow_json(payload) + return + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") + + cli.console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + cli.console.print(f" Workflow: {state.workflow_id}") + cli.console.print(f" Status: [{color}]{state.status.value}[/{color}]") + cli.console.print(f" Created: {state.created_at}") + cli.console.print(f" Updated: {state.updated_at}") + + if state.current_step_id: + cli.console.print(f" Current: {state.current_step_id}") + + err_msg = cli._failed_step_error(state) + if err_msg: + cli.console.print(f" [red]Error: {cli._escape_markup(err_msg)}[/red]") + + if state.step_results: + cli.console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get( + s, "white" + ) + cli.console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() + + if json_output: + payload = { + "runs": [ + { + "run_id": r["run_id"], + "workflow_id": r.get("workflow_id"), + "status": r.get("status", "unknown"), + "updated_at": r.get("updated_at"), + } + for r in runs + ] + } + cli._emit_workflow_json(payload) + return + + if not runs: + cli.console.print("[yellow]No workflow runs found.[/yellow]") + return + + cli.console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = { + "completed": "green", + "failed": "red", + "paused": "yellow", + "running": "blue", + }.get(s, "white") + cli.console.print( + f" [{sc}]●[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) diff --git a/src/specify_cli/workflows/command_update.py b/src/specify_cli/workflows/command_update.py new file mode 100644 index 0000000000..84ecc530e3 --- /dev/null +++ b/src/specify_cli/workflows/command_update.py @@ -0,0 +1,148 @@ +"""Command handler for ``specify workflow update``.""" + +from __future__ import annotations + +from . import _commands as cli + + +@cli.workflow_app.command("update") +def workflow_update( + workflow_id: str | None = cli.typer.Argument( + None, help="Workflow ID to update (default: all)" + ), +): + """Update installed workflow(s) to the latest catalog version.""" + from packaging import version as pkg_version + + from .catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = cli._require_specify_project() + registry = cli._open_workflow_registry(project_root) + workflows_dir = project_root / ".specify" / "workflows" + cli._reject_unsafe_dir(project_root / ".specify", ".specify") + cli._reject_unsafe_dir(workflows_dir, ".specify/workflows") + + installed = registry.list() + if workflow_id: + if not registry.is_installed(workflow_id): + cli.console.print( + f"[red]Error:[/red] Workflow '{cli._escape_markup(workflow_id)}' is not installed" + ) + raise cli.typer.Exit(1) + targets = [workflow_id] + else: + targets = list(installed) + + if not targets: + cli.console.print("[yellow]No workflows installed[/yellow]") + raise cli.typer.Exit(0) + + catalog = WorkflowCatalog(project_root) + cli.console.print("🔄 Checking for updates...\n") + + updates_available: list[dict[str, str]] = [] + checked = 0 + for wf_id in targets: + safe_id = cli._escape_markup(str(wf_id)) + metadata = installed.get(wf_id) + if not isinstance(metadata, dict): + cli.console.print(f"⚠ {safe_id}: Registry entry is corrupted (skipping)") + continue + if metadata.get("source") != "catalog": + cli.console.print( + f"⚠ {safe_id}: Not installed from a catalog — re-add to update (skipping)" + ) + continue + try: + installed_version = pkg_version.Version(str(metadata.get("version"))) + except pkg_version.InvalidVersion: + cli.console.print( + f"⚠ {safe_id}: Invalid installed version '{cli._escape_markup(str(metadata.get('version')))}' in registry (skipping)" + ) + continue + try: + info = catalog.get_workflow_info(wf_id) + except WorkflowCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") + raise cli.typer.Exit(1) + if not info: + cli.console.print(f"⚠ {safe_id}: Not found in catalog (skipping)") + continue + if not info.get("_install_allowed", True): + cli.console.print( + f"⚠ {safe_id}: Updates not allowed from '{cli._escape_markup(str(info.get('_catalog_name', 'catalog')))}' (skipping)" + ) + continue + try: + catalog_version = pkg_version.Version(str(info.get("version"))) + except pkg_version.InvalidVersion: + cli.console.print( + f"⚠ {safe_id}: Invalid catalog version '{cli._escape_markup(str(info.get('version')))}' (skipping)" + ) + continue + if catalog_version > installed_version: + checked += 1 + updates_available.append( + { + "id": wf_id, + "installed": str(installed_version), + "available": str(catalog_version), + } + ) + else: + checked += 1 + cli.console.print(f"✓ {safe_id}: Up to date (v{installed_version})") + + if not updates_available: + if not checked: + cli.console.print( + "\n[yellow]No workflows were eligible for update[/yellow]" + ) + elif checked == len(targets): + cli.console.print("\n[green]All workflows are up to date![/green]") + else: + cli.console.print( + f"\n[green]All checked workflows are up to date[/green] " + f"[yellow]({len(targets) - checked} skipped)[/yellow]" + ) + raise cli.typer.Exit(0) + + cli.console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + cli.console.print( + f" • {cli._escape_markup(update['id'])}: {update['installed']} → {update['available']}" + ) + cli.console.print() + if not cli.typer.confirm("Update these workflows?"): + cli.console.print("Cancelled") + raise cli.typer.Exit(0) + + cli.console.print() + failed: list[str] = [] + for update in updates_available: + # _install_workflow_from_catalog is fully transactional (staged + # download, atomic commit, rename-based rollback on registry + # failure): it never leaves a partially-written workflow.yml, so + # this loop only needs to record success/failure, not perform its + # own backup/restore. + try: + cli._install_workflow_from_catalog( + project_root, + workflows_dir, + update["id"], + expected_version=update["available"], + expected_installed_version=update["installed"], + ) + except (cli.typer.Exit, OSError) as exc: + if isinstance(exc, OSError): + cli.console.print( + f"[red]Error:[/red] Filesystem error updating " + f"'{cli._escape_markup(update['id'])}': {cli._escape_markup(str(exc))}" + ) + failed.append(update["id"]) + + if failed: + cli.console.print( + f"\n[red]Failed to update:[/red] {', '.join(cli._escape_markup(f) for f in failed)}" + ) + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index a72f6f0898..f6d5a2a663 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -948,7 +948,7 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition: ValueError: If the workflow YAML is invalid. """ - from .overlays import WorkflowResolver + from .overlay import WorkflowResolver path = Path(source).expanduser() diff --git a/src/specify_cli/workflows/overlay/__init__.py b/src/specify_cli/workflows/overlay/__init__.py new file mode 100644 index 0000000000..935f788bd0 --- /dev/null +++ b/src/specify_cli/workflows/overlay/__init__.py @@ -0,0 +1,39 @@ +"""Workflow overlay domain API and nested CLI registration.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +import typer + +overlay_app = typer.Typer( + name="overlay", + help="Manage workflow overlays", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the overlay command group to the workflow app.""" + from . import command_add # noqa: F401 -- registers handler + from . import command_set_priority # noqa: F401 -- registers handler + from . import command_enable # noqa: F401 -- registers handler + from . import command_disable # noqa: F401 -- registers handler + from . import command_remove # noqa: F401 -- registers handler + from . import command_list # noqa: F401 -- registers handler + + app.add_typer(overlay_app, name="overlay") + + +def __getattr__(name: str) -> Any: + """Load the resolver only when the domain API is requested.""" + if name != "WorkflowResolver": + raise AttributeError(name) + resolver = import_module(f"{__name__}.resolver") + value = resolver.WorkflowResolver + globals()[name] = value + return value + + +__all__ = ["WorkflowResolver"] diff --git a/src/specify_cli/workflows/overlay/command_add.py b/src/specify_cli/workflows/overlay/command_add.py new file mode 100644 index 0000000000..9af10ca285 --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_add.py @@ -0,0 +1,23 @@ +"""Command handler for ``specify workflow overlay add``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("add") +def workflow_overlay_add_cmd( + source: cli.Path = cli.typer.Argument(..., help="Path to overlay YAML file"), + priority: int = cli.typer.Option( + 10, + "--priority", + help="Resolution priority (lower = higher precedence, default 10)", + ), +): + """Add a project-local overlay for a workflow.""" + from .operations import workflow_overlay_add + + project_root = cli._require_specify_project() + if workflow_overlay_add(project_root, source, priority) is None: + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlay/command_disable.py b/src/specify_cli/workflows/overlay/command_disable.py new file mode 100644 index 0000000000..ffcc4de30d --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_disable.py @@ -0,0 +1,19 @@ +"""Command handler for ``specify workflow overlay disable``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("disable") +def workflow_overlay_disable_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID the overlay extends"), + overlay_id: str = cli.typer.Argument(..., help="Overlay ID"), +): + """Disable a project-local overlay.""" + from .operations import workflow_overlay_disable + + project_root = cli._require_specify_project() + if not workflow_overlay_disable(project_root, workflow_id, overlay_id): + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlay/command_enable.py b/src/specify_cli/workflows/overlay/command_enable.py new file mode 100644 index 0000000000..7a07eaee61 --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_enable.py @@ -0,0 +1,19 @@ +"""Command handler for ``specify workflow overlay enable``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("enable") +def workflow_overlay_enable_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID the overlay extends"), + overlay_id: str = cli.typer.Argument(..., help="Overlay ID"), +): + """Enable a project-local overlay.""" + from .operations import workflow_overlay_enable + + project_root = cli._require_specify_project() + if not workflow_overlay_enable(project_root, workflow_id, overlay_id): + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlay/command_list.py b/src/specify_cli/workflows/overlay/command_list.py new file mode 100644 index 0000000000..da93d73efd --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_list.py @@ -0,0 +1,18 @@ +"""Command handler for ``specify workflow overlay list``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("list") +def workflow_overlay_list_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID"), +): + """List overlays for a workflow.""" + from .operations import workflow_overlay_list + + project_root = cli._require_specify_project() + if workflow_overlay_list(project_root, workflow_id) is None: + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlay/command_remove.py b/src/specify_cli/workflows/overlay/command_remove.py new file mode 100644 index 0000000000..8e3db9bc6d --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_remove.py @@ -0,0 +1,19 @@ +"""Command handler for ``specify workflow overlay remove``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("remove") +def workflow_overlay_remove_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID the overlay extends"), + overlay_id: str = cli.typer.Argument(..., help="Overlay ID"), +): + """Remove a project-local overlay.""" + from .operations import workflow_overlay_remove + + project_root = cli._require_specify_project() + if not workflow_overlay_remove(project_root, workflow_id, overlay_id): + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlay/command_set_priority.py b/src/specify_cli/workflows/overlay/command_set_priority.py new file mode 100644 index 0000000000..e2734fd6db --- /dev/null +++ b/src/specify_cli/workflows/overlay/command_set_priority.py @@ -0,0 +1,24 @@ +"""Command handler for ``specify workflow overlay set-priority``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import overlay_app + + +@overlay_app.command("set-priority") +def workflow_overlay_set_priority_cmd( + workflow_id: str = cli.typer.Argument(..., help="Workflow ID the overlay extends"), + overlay_id: str = cli.typer.Argument(..., help="Overlay ID"), + priority: int = cli.typer.Argument( + ..., help="New priority (lower = higher precedence)" + ), +): + """Set the priority of a project-local overlay.""" + from .operations import workflow_overlay_set_priority + + project_root = cli._require_specify_project() + if not workflow_overlay_set_priority( + project_root, workflow_id, overlay_id, priority + ): + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/overlays/composer.py b/src/specify_cli/workflows/overlay/composer.py similarity index 100% rename from src/specify_cli/workflows/overlays/composer.py rename to src/specify_cli/workflows/overlay/composer.py diff --git a/src/specify_cli/workflows/overlays/layer_sources.py b/src/specify_cli/workflows/overlay/layer_sources.py similarity index 100% rename from src/specify_cli/workflows/overlays/layer_sources.py rename to src/specify_cli/workflows/overlay/layer_sources.py diff --git a/src/specify_cli/workflows/overlays/merge.py b/src/specify_cli/workflows/overlay/merge.py similarity index 100% rename from src/specify_cli/workflows/overlays/merge.py rename to src/specify_cli/workflows/overlay/merge.py diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlay/operations.py similarity index 93% rename from src/specify_cli/workflows/overlays/_commands.py rename to src/specify_cli/workflows/overlay/operations.py index 156e0d6c07..ab390774c3 100644 --- a/src/specify_cli/workflows/overlays/_commands.py +++ b/src/specify_cli/workflows/overlay/operations.py @@ -1,4 +1,4 @@ -"""CLI handlers for ``specify workflow overlay *`` and ``specify workflow resolve``.""" +"""Domain operations used by workflow overlay and resolve commands.""" from __future__ import annotations @@ -12,14 +12,7 @@ from ..._console import console, err_console from ...extensions import normalize_priority -from .._commands import ( - _commit_workflow_file, - _discard_committed_backup_file, - _reject_unsafe_dir, - _reject_unsafe_workflow_storage, - _safe_discard_staged_workflow_file, - _stage_workflow_file, -) +from .. import _commands as cli from . import WorkflowResolver from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml @@ -50,9 +43,9 @@ def _validate_workflow_id_or_exit(workflow_id: str) -> None: def _overlay_root(project_root: Path) -> Path: """Return the project-local overlay root after rejecting unsafe ancestors.""" - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) root = project_root / ".specify" / "workflows" / "overlays" - _reject_unsafe_dir(root, ".specify/workflows/overlays") + cli._reject_unsafe_dir(root, ".specify/workflows/overlays") return root @@ -72,7 +65,7 @@ def _ensure_contained_dir(path: Path, root: Path) -> Path: Returns *path* if safe. Raises typer.Exit on traversal or symlink. """ - _reject_unsafe_dir(root, ".specify/workflows/overlays") + cli._reject_unsafe_dir(root, ".specify/workflows/overlays") if path.is_symlink(): err_console.print( f"[red]Error:[/red] Refusing to use symlinked path {path}." @@ -134,7 +127,7 @@ def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> def _ensure_contained_path(path: Path, root: Path) -> Path: """Return *path* only if it resolves inside *root*; otherwise raise typer.Exit.""" - _reject_unsafe_dir(root, ".specify/workflows/overlays") + cli._reject_unsafe_dir(root, ".specify/workflows/overlays") if path.is_symlink(): err_console.print( f"[red]Error:[/red] Refusing to use symlinked path {path}." @@ -176,7 +169,7 @@ def workflow_overlay_add( Returns the path of the installed overlay file, or None on failure. """ - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) data, errors = _read_overlay(source) if data is None: for err in errors: @@ -258,7 +251,7 @@ def workflow_overlay_add( try: target_dir.mkdir(parents=True, exist_ok=True) existed_before = target_path.exists() - staged = _stage_workflow_file(target_path.parent) + staged = cli._stage_workflow_file(target_path.parent) try: # ``allow_unicode=True`` matches every other YAML writer in the # repo. Without it every non-ASCII character in a hand-authored @@ -269,16 +262,16 @@ def workflow_overlay_add( "utf-8" ) ) - backup = _commit_workflow_file(staged, target_path, existed_before) + backup = cli._commit_workflow_file(staged, target_path, existed_before) except BaseException: - _safe_discard_staged_workflow_file( + cli._safe_discard_staged_workflow_file( staged, target_path.parent, existed_before ) raise except OSError as exc: err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}") return None - _discard_committed_backup_file(backup) + cli._discard_committed_backup_file(backup) console.print( f"[green]\u2713[/green] Overlay '{overlay.id}' added for workflow '{overlay.extends}'" @@ -294,7 +287,7 @@ def _update_overlay_field( value: Any, ) -> bool: """Update a single field in a project-local overlay file.""" - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) path = _find_overlay_file(project_root, workflow_id, overlay_id) if path is None: err_console.print( @@ -319,7 +312,7 @@ def _update_overlay_field( backup: Path | None = None try: existed_before = path.exists() - staged = _stage_workflow_file(path.parent) + staged = cli._stage_workflow_file(path.parent) try: # ``allow_unicode=True`` matches every other YAML writer in the # repo. Without it every non-ASCII character in a hand-authored @@ -330,14 +323,16 @@ def _update_overlay_field( "utf-8" ) ) - backup = _commit_workflow_file(staged, path, existed_before) + backup = cli._commit_workflow_file(staged, path, existed_before) except BaseException: - _safe_discard_staged_workflow_file(staged, path.parent, existed_before) + cli._safe_discard_staged_workflow_file( + staged, path.parent, existed_before + ) raise except OSError as exc: err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}") return False - _discard_committed_backup_file(backup) + cli._discard_committed_backup_file(backup) return True @@ -393,7 +388,7 @@ def workflow_overlay_remove( overlay_id: str, ) -> bool: """Remove a project-local overlay file.""" - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) path = _find_overlay_file(project_root, workflow_id, overlay_id) if path is None: err_console.print( @@ -416,7 +411,7 @@ def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str Returns the raw list data for machine-readable callers, or None on error. """ - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) _validate_workflow_id_or_exit(workflow_id) resolver = WorkflowResolver(project_root) try: @@ -455,7 +450,7 @@ def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | N Returns a serializable attribution payload. """ - _reject_unsafe_workflow_storage(project_root) + cli._reject_unsafe_workflow_storage(project_root) _validate_workflow_id_or_exit(workflow_id) resolver = WorkflowResolver(project_root) try: diff --git a/src/specify_cli/workflows/overlays/__init__.py b/src/specify_cli/workflows/overlay/resolver.py similarity index 97% rename from src/specify_cli/workflows/overlays/__init__.py rename to src/specify_cli/workflows/overlay/resolver.py index 2bb87ffbed..bd50de6da3 100644 --- a/src/specify_cli/workflows/overlays/__init__.py +++ b/src/specify_cli/workflows/overlay/resolver.py @@ -1,4 +1,4 @@ -"""Workflow overlay resolver — composes installed workflows from layers.""" +"""Workflow overlay resolver.""" from __future__ import annotations diff --git a/src/specify_cli/workflows/overlays/schema.py b/src/specify_cli/workflows/overlay/schema.py similarity index 100% rename from src/specify_cli/workflows/overlays/schema.py rename to src/specify_cli/workflows/overlay/schema.py diff --git a/src/specify_cli/workflows/step/__init__.py b/src/specify_cli/workflows/step/__init__.py new file mode 100644 index 0000000000..39669343b7 --- /dev/null +++ b/src/specify_cli/workflows/step/__init__.py @@ -0,0 +1,28 @@ +"""Registration for the nested ``specify workflow step`` command group.""" + +from __future__ import annotations + +import typer + +step_app = typer.Typer( + name="step", + help="Manage workflow step types", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the step command group to the workflow app.""" + from .catalog import register as register_catalog + + register_catalog(step_app) + + # isort: off + from . import command_list # noqa: F401 -- registers handler + from . import command_add # noqa: F401 -- registers handler + from . import command_remove # noqa: F401 -- registers handler + from . import command_search # noqa: F401 -- registers handler + from . import command_info # noqa: F401 -- registers handler + # isort: on + + app.add_typer(step_app, name="step") diff --git a/src/specify_cli/workflows/step/_helpers.py b/src/specify_cli/workflows/step/_helpers.py new file mode 100644 index 0000000000..45250ceffc --- /dev/null +++ b/src/specify_cli/workflows/step/_helpers.py @@ -0,0 +1,107 @@ +"""Shared validation helpers for workflow step commands.""" + +from __future__ import annotations + +from .. import _commands as cli + +# Custom step packages contain executable Python, metadata, and optional helper +# files downloaded one-by-one rather than as an archive. Mirror the archive +# ceilings so a catalog cannot turn individually valid files into an unbounded +# aggregate download. +_MAX_STEP_PACKAGE_FILES = 512 +_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB + +_RESERVED_STEP_IDS: frozenset[str] = frozenset({".cache", "step-registry.json"}) + +_WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset( + { + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9", + } +) + +_WINDOWS_INVALID_CHARS: frozenset[str] = frozenset('<>:"|?*') + + +def _validate_step_id_or_exit(step_id: str) -> None: + """Validate that ``step_id`` is a single safe path component. + + Rejects empty strings, whitespace-only strings, leading/trailing whitespace, + path separators, ``.``/``..`` components, dotfile prefixes, reserved names, + Windows-invalid filename characters, trailing dots/spaces, and Windows + reserved device names. Exits with code 1 on failure. + """ + # Strip the stem (before first dot) for Windows reserved-name check + stem = step_id.split(".")[0].lower() if step_id else "" + if ( + not step_id + or not step_id.strip() + or step_id != step_id.strip() + or "/" in step_id + or "\\" in step_id + or step_id in (".", "..") + or step_id.startswith(".") + or step_id.endswith(".") + or step_id.endswith(" ") + or step_id.lower() in _RESERVED_STEP_IDS + or stem in _WINDOWS_RESERVED_NAMES + or any(c in _WINDOWS_INVALID_CHARS for c in step_id) + or any(ord(c) < 32 for c in step_id) + ): + cli.console.print( + f"[red]Error:[/red] Invalid step id '{step_id}': must be a single safe " + "path component (no separators, no leading dot, not a reserved name, " + "no invalid filename characters)" + ) + raise cli.typer.Exit(1) + + +def _resolve_steps_base_dir_or_exit(project_root: cli.Path) -> cli.Path: + """Resolve .specify/workflows/steps while refusing symlinked parent directories.""" + project_root_resolved = project_root.resolve() + steps_base_dir_unresolved = project_root / ".specify" / "workflows" / "steps" + + current = project_root + for part in (".specify", "workflows", "steps"): + current = current / part + if current.is_symlink(): + cli.console.print( + f"[red]Error:[/red] Refusing to use symlinked step directory '{current}'" + ) + raise cli.typer.Exit(1) + if current.exists() and not current.is_dir(): + cli.console.print( + f"[red]Error:[/red] Step directory path is not a directory: '{current}'" + ) + raise cli.typer.Exit(1) + + steps_base_dir = steps_base_dir_unresolved.resolve() + try: + steps_base_dir.relative_to(project_root_resolved) + except ValueError: + cli.console.print( + f"[red]Error:[/red] Step directory escapes project root: '{steps_base_dir}'" + ) + raise cli.typer.Exit(1) + + return steps_base_dir diff --git a/src/specify_cli/workflows/step/catalog/__init__.py b/src/specify_cli/workflows/step/catalog/__init__.py new file mode 100644 index 0000000000..da783f45e8 --- /dev/null +++ b/src/specify_cli/workflows/step/catalog/__init__.py @@ -0,0 +1,48 @@ +"""Step catalog domain API and nested CLI registration.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +import typer + +from ...._download_security import MAX_JSON_CATALOG_BYTES as MAX_JSON_CATALOG_BYTES + +catalog_app = typer.Typer( + name="catalog", + help="Manage step catalogs", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the step catalog group to the step app.""" + from . import command_list # noqa: F401 -- registers handler + from . import command_add # noqa: F401 -- registers handler + from . import command_remove # noqa: F401 -- registers handler + + app.add_typer(catalog_app, name="catalog") + + +_DOMAIN_EXPORTS = { + "StepCatalog", + "StepCatalogEntry", + "StepCatalogError", + "StepRegistry", + "StepValidationError", +} +_COMPATIBILITY_EXPORTS = {"json", "os"} + + +def __getattr__(name: str) -> Any: + """Load step catalog domain symbols only when requested.""" + if name not in _DOMAIN_EXPORTS | _COMPATIBILITY_EXPORTS: + raise AttributeError(name) + domain = import_module(f"{__name__}._domain") + value = getattr(domain, name) + globals()[name] = value + return value + + +__all__ = sorted(_DOMAIN_EXPORTS) diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py new file mode 100644 index 0000000000..cebebb3e2f --- /dev/null +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -0,0 +1,710 @@ +"""Step catalog discovery, installation, and registry domain API.""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from ...._download_security import ( + MAX_JSON_CATALOG_BYTES as MAX_JSON_CATALOG_BYTES, + read_response_limited, +) + + +def _max_json_catalog_bytes() -> int: + """Read the step-catalog compatibility size limit at call time.""" + from . import MAX_JSON_CATALOG_BYTES as configured_limit + + return configured_limit + + +# --------------------------------------------------------------------------- +# Step catalog errors +# --------------------------------------------------------------------------- + + +class StepCatalogError(Exception): + """Base error for step catalog operations.""" + + +class StepValidationError(StepCatalogError): + """Validation error for step catalog config or step data.""" + + +# --------------------------------------------------------------------------- +# StepCatalogEntry +# --------------------------------------------------------------------------- + + +@dataclass +class StepCatalogEntry: + """Represents a single step catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# StepRegistry +# --------------------------------------------------------------------------- + + +class StepRegistry: + """Manages the registry of installed custom step types. + + Tracks installed step types and their metadata in + ``.specify/workflows/steps/step-registry.json``. + """ + + REGISTRY_FILE = "step-registry.json" + SCHEMA_VERSION = "1.0" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.steps_dir = project_root / ".specify" / "workflows" / "steps" + self.registry_path = self.steps_dir / self.REGISTRY_FILE + self.data = self._load() + + def _has_symlinked_parent(self) -> bool: + """Return True if any directory under .specify/workflows/steps is a symlink.""" + current = self.project_root + for part in (".specify", "workflows", "steps"): + current = current / part + if current.is_symlink(): + return True + return False + + def _load(self) -> dict[str, Any]: + """Load registry from disk or create default.""" + default_registry: dict[str, Any] = {"schema_version": self.SCHEMA_VERSION, "steps": {}} + # Defense-in-depth: refuse to read the registry if any parent directory + # under .specify/workflows/steps is a symlink, which could redirect the + # read outside the project root. + if self._has_symlinked_parent(): + return default_registry + # Defense-in-depth: also refuse to read a symlinked registry file, + # which could redirect the read outside the project root. + if self.registry_path.is_symlink(): + return default_registry + if self.registry_path.exists(): + try: + with open(self.registry_path, encoding="utf-8") as f: + data = json.load(f) + # Validate shape: must be a dict with a dict "steps" field + if not isinstance(data, dict): + return default_registry + if not isinstance(data.get("steps"), dict): + data["steps"] = {} + return data + except (json.JSONDecodeError, ValueError, OSError, UnicodeError): + return default_registry + return default_registry + + def save(self) -> None: + """Persist registry to disk. + + Raises ``StepValidationError`` with a clear message on filesystem + errors (read-only fs, permission denied, ...) so callers can surface + a clean error to the user rather than an unhandled ``OSError``. + """ + if self._has_symlinked_parent() or self.registry_path.is_symlink(): + raise StepValidationError( + "Refusing to write step registry through a symlinked path." + ) + try: + self.steps_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=2) + except OSError as exc: + raise StepValidationError( + f"Failed to write step registry at {self.registry_path}: {exc}" + ) from exc + + def add(self, step_id: str, metadata: dict[str, Any]) -> None: + """Add or update an installed step entry.""" + import copy + from datetime import datetime, timezone + + raw_existing = self.data["steps"].get(step_id) + # Corrupted-but-parseable registries may hold non-dict entries; treat + # them as absent rather than crashing on existing.get() (mirrors + # WorkflowRegistry.add). + existing = raw_existing if isinstance(raw_existing, dict) else {} + metadata_to_store = copy.deepcopy(metadata) + metadata_to_store["installed_at"] = existing.get( + "installed_at", datetime.now(timezone.utc).isoformat() + ) + metadata_to_store["updated_at"] = datetime.now(timezone.utc).isoformat() + self.data["steps"][step_id] = metadata_to_store + self.save() + + def remove(self, step_id: str) -> bool: + """Remove an installed step entry. Returns True if found.""" + if step_id in self.data["steps"]: + del self.data["steps"][step_id] + self.save() + return True + return False + + def get(self, step_id: str) -> dict[str, Any] | None: + """Get metadata for an installed step.""" + return self.data["steps"].get(step_id) + + def list(self) -> dict[str, dict[str, Any]]: + """Return all installed steps.""" + return dict(self.data["steps"]) + + def is_installed(self, step_id: str) -> bool: + """Check if a step is installed.""" + return step_id in self.data["steps"] + + +# --------------------------------------------------------------------------- +# StepCatalog +# --------------------------------------------------------------------------- + + +class StepCatalog: + """Manages step catalog fetching, caching, and searching. + + Resolution order for catalog sources: + 1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all) + 2. Project-level ``.specify/step-catalogs.yml`` + 3. User-level ``~/.specify/step-catalogs.yml`` + 4. Built-in defaults (official + community) + """ + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/step-catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/step-catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.steps_dir = project_root / ".specify" / "workflows" / "steps" + self.cache_dir = self.steps_dir / ".cache" + + def _is_cache_path_safe(self) -> bool: + """Return False if any component of the cache path is a symlink.""" + current = self.project_root + for part in (".specify", "workflows", "steps", ".cache"): + current = current / part + if current.is_symlink(): + return False + return True + + # -- Catalog resolution ----------------------------------------------- + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" + from urllib.parse import urlparse + + # A malformed authority (e.g. an unterminated IPv6 bracket + # "https://[::1") makes urlparse / hostname access raise ValueError. + # This validator's contract is to raise StepValidationError for a bad + # URL, so surface that rather than leaking a raw ValueError past the + # command handler (which only catches StepValidationError). Mirrors + # specify_cli.catalogs (#3435). + try: + parsed = urlparse(url) + hostname = parsed.hostname + _ = parsed.port + except (TypeError, ValueError): + raise StepValidationError( + f"Catalog URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise StepValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not hostname: + raise StepValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config( + self, config_path: Path + ) -> list[StepCatalogEntry] | None: + """Load catalog stack configuration from a YAML file.""" + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise StepValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + # Same two guards as WorkflowCatalog._load_catalog_config above, kept in + # lockstep: this is the step-catalog twin of that loader and read the + # same way. Dropping ``or {}`` stops a falsy non-mapping top level from + # being coerced past the isinstance check, and the ``catalogs`` shape + # check runs before the emptiness check for the same reason. + if data is None: + return None + if not isinstance(data, dict): + raise StepValidationError( + f"Invalid catalog config: expected a mapping, " + f"got {type(data).__name__}" + ) + catalogs_data = data.get("catalogs") + if catalogs_data is None: + return None + if not isinstance(catalogs_data, list): + raise StepValidationError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + return None + + entries: list[StepCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise StepValidationError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + raw_priority = item.get("priority", idx + 1) + # bool is an int subclass: reject ``priority: true`` explicitly rather + # than silently coercing it to 1 (mirrors CatalogStackBase). + if isinstance(raw_priority, bool): + raise StepValidationError( + f"Invalid priority for catalog " + f"'{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + try: + priority = int(raw_priority) + except (TypeError, ValueError, OverflowError): + # OverflowError: int(float("inf")) — a ``priority: .inf``. + raise StepValidationError( + f"Invalid priority for catalog " + f"'{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ( + "true", + "yes", + "1", + ) + else: + install_allowed = bool(raw_install) + entries.append( + StepCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise StepValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs." + ) + return entries + + def get_active_catalogs(self) -> list[StepCatalogEntry]: + """Get the ordered list of active step catalogs.""" + # 1. Environment variable override + env_url = os.environ.get("SPECKIT_STEP_CATALOG_URL", "").strip() + if env_url: + self._validate_catalog_url(env_url) + return [ + StepCatalogEntry( + url=env_url, + name="env-override", + priority=1, + install_allowed=True, + description="From SPECKIT_STEP_CATALOG_URL", + ) + ] + + # 2. Project-level config + project_config = self.project_root / ".specify" / "step-catalogs.yml" + project_entries = self._load_catalog_config(project_config) + if project_entries is not None: + return project_entries + + # 3. User-level config + home = Path.home() + user_config = home / ".specify" / "step-catalogs.yml" + user_entries = self._load_catalog_config(user_config) + if user_entries is not None: + return user_entries + + # 4. Built-in defaults + return [ + StepCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Official step types", + ), + StepCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed step types (discovery only)", + ), + ] + + # -- Caching ---------------------------------------------------------- + + def _get_cache_paths(self, url: str) -> tuple[Path, Path]: + """Get cache file paths for a URL (hash-based).""" + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"step-catalog-{url_hash}.json" + meta_file = self.cache_dir / f"step-catalog-{url_hash}-meta.json" + return cache_file, meta_file + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached data for a URL is still fresh.""" + _, meta_file = self._get_cache_paths(url) + if not meta_file.exists(): + return False + try: + with open(meta_file, encoding="utf-8") as f: + meta = json.load(f) + if not isinstance(meta, dict): + return False + fetched_at = float(meta.get("fetched_at", 0)) + return (time.time() - fetched_at) < self.CACHE_DURATION + except (json.JSONDecodeError, OSError, TypeError, ValueError): + return False + + def _fetch_single_catalog( + self, entry: StepCatalogEntry, force_refresh: bool = False + ) -> dict[str, Any]: + """Fetch a single catalog, using cache when possible.""" + cache_safe = self._is_cache_path_safe() + cache_file, meta_file = self._get_cache_paths(entry.url) + + if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url): + try: + with open(cache_file, encoding="utf-8") as f: + cached = json.load(f) + if isinstance(cached, dict): + return cached + except (UnicodeDecodeError, json.JSONDecodeError, OSError): + # Ignore invalid/unreadable cache and fall back to fetching from source. + pass + + from urllib.parse import urlparse + from specify_cli.authentication.http import open_url as _open_url + + def _validate_url(url: str) -> None: + # A malformed authority (e.g. "https://[::1") makes urlparse / + # hostname access raise ValueError; treat it as a refused fetch + # rather than leaking a raw ValueError (this also validates the + # post-redirect resp.geturl(), so a hostile redirect target cannot + # crash the fetch either). + try: + parsed = urlparse(url) + hostname = parsed.hostname + _ = parsed.port + except (TypeError, ValueError): + raise StepCatalogError( + f"Refusing to fetch catalog from malformed URL: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise StepCatalogError( + f"Refusing to fetch catalog from non-HTTPS URL: {url}" + ) + if not hostname: + raise StepCatalogError( + f"Refusing to fetch catalog from URL with no hostname: {url}" + ) + + _validate_url(entry.url) + + # Validate EVERY redirect hop, not just the final URL: _open_url follows + # redirects, so an https:// entry that 30x-redirects through http:// (or + # to a non-HTTPS host mid-chain) could otherwise let a network attacker + # rewrite the next hop and slip a payload past a final-URL-only check. + # redirect_validator runs before each hop; the geturl() check below is + # retained as a defense-in-depth backstop. Mirrors the presets/extensions + # catalog fix (#3523 / #3524). + def _validate_redirect(_old_url: str, new_url: str) -> None: + _validate_url(new_url) + + try: + with _open_url( + entry.url, timeout=30, redirect_validator=_validate_redirect + ) as resp: + _validate_url(resp.geturl()) + data = json.loads( + read_response_limited( + resp, + max_bytes=_max_json_catalog_bytes(), + error_type=StepCatalogError, + label="step catalog", + ).decode("utf-8") + ) + except Exception as exc: + if cache_safe and cache_file.exists(): + try: + with open(cache_file, encoding="utf-8") as f: + cached = json.load(f) + if isinstance(cached, dict): + return cached + except (json.JSONDecodeError, ValueError, OSError): + # Stale-cache read failed; let the original fetch error propagate. + pass + raise StepCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise StepCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + + if cache_safe: + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + with open(meta_file, "w", encoding="utf-8") as f: + json.dump({"url": entry.url, "fetched_at": time.time()}, f) + except OSError: + pass # Proceed without caching if disk write fails + + return data + + def _get_merged_steps( + self, force_refresh: bool = False + ) -> dict[str, dict[str, Any]]: + """Merge steps from all active catalogs (lower priority number wins).""" + catalogs = self.get_active_catalogs() + merged: dict[str, dict[str, Any]] = {} + fetch_errors = 0 + + for entry in reversed(catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + except StepCatalogError: + fetch_errors += 1 + continue + steps = data.get("steps", {}) + if isinstance(steps, dict): + for step_id, step_data in steps.items(): + if not isinstance(step_data, dict): + continue + step_data["_catalog_name"] = entry.name + step_data["_install_allowed"] = entry.install_allowed + merged[step_id] = step_data + elif isinstance(steps, list): + for step_data in steps: + if not isinstance(step_data, dict): + continue + raw_step_id = step_data.get("id") + if raw_step_id is None: + continue + step_id = str(raw_step_id).strip() + if step_id: + step_data["id"] = step_id + step_data["_catalog_name"] = entry.name + step_data["_install_allowed"] = entry.install_allowed + merged[step_id] = step_data + if fetch_errors == len(catalogs) and catalogs: + raise StepCatalogError("All configured step catalogs failed to fetch.") + return merged + + # -- Public API ------------------------------------------------------- + + def search( + self, + query: str | None = None, + ) -> list[dict[str, Any]]: + """Search step types across all configured catalogs.""" + merged = self._get_merged_steps() + results: list[dict[str, Any]] = [] + + for step_id, step_data in merged.items(): + step_data.setdefault("id", step_id) + if query: + q = query.lower() + searchable = " ".join( + [ + str(step_data.get("name") or ""), + str(step_data.get("description") or ""), + str(step_data.get("id") or ""), + ] + ).lower() + if q not in searchable: + continue + results.append(step_data) + return results + + def get_step_info(self, step_id: str) -> dict[str, Any] | None: + """Get details for a specific step from the catalog.""" + merged = self._get_merged_steps() + step = merged.get(step_id) + if step: + step.setdefault("id", step_id) + return step + + def get_catalog_configs(self) -> list[dict[str, Any]]: + """Return current catalog configuration as a list of dicts.""" + entries = self.get_active_catalogs() + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: str | None = None) -> None: + """Add a catalog source to the project-level config.""" + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / "step-catalogs.yml" + + data: dict[str, Any] = {"catalogs": []} + if config_path.exists(): + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: + raise StepValidationError( + f"Catalog config file is unreadable or malformed: {exc}" + ) from exc + if raw is None: + raw = {} + elif not isinstance(raw, dict): + raise StepValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise StepValidationError( + "Catalog config 'catalogs' must be a list." + ) + for cat in catalogs: + if isinstance(cat, dict) and cat.get("url") == url: + raise StepValidationError( + f"Catalog URL already configured: {url}" + ) + + # Coerce existing priorities to int with a safe fallback so a user-edited + # step-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up. + def _coerce_priority(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: int(float("inf")) — treat an uncoercible + # existing priority as 0 rather than crashing 'catalog add'. + return 0 + + max_priority = max( + ( + _coerce_priority(cat.get("priority", 0)) + for cat in catalogs + if isinstance(cat, dict) + ), + default=0, + ) + catalogs.append( + { + "name": name or f"catalog-{len(catalogs) + 1}", + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, f, default_flow_style=False, sort_keys=False, allow_unicode=True + ) + except OSError as exc: + raise StepValidationError( + f"Failed to write catalog config {config_path}: {exc}" + ) from exc + + def remove_catalog(self, index: int) -> str: + """Remove a catalog source by index (0-based). Returns the removed name.""" + config_path = self.project_root / ".specify" / "step-catalogs.yml" + if not config_path.exists(): + raise StepValidationError("No step catalog config file found.") + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: + raise StepValidationError( + f"Catalog config file is unreadable or malformed: {exc}" + ) from exc + if data is None: + data = {} + elif not isinstance(data, dict): + raise StepValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise StepValidationError( + "Catalog config 'catalogs' must be a list." + ) + + if index < 0 or index >= len(catalogs): + raise StepValidationError( + f"Catalog index {index} out of range (0-{len(catalogs) - 1})." + ) + + removed = catalogs.pop(index) + data["catalogs"] = catalogs + + try: + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, f, default_flow_style=False, sort_keys=False, allow_unicode=True + ) + except OSError as exc: + raise StepValidationError( + f"Failed to write catalog config {config_path}: {exc}" + ) from exc + + if isinstance(removed, dict): + return removed.get("name", f"catalog-{index + 1}") + return f"catalog-{index + 1}" diff --git a/src/specify_cli/workflows/step/catalog/command_add.py b/src/specify_cli/workflows/step/catalog/command_add.py new file mode 100644 index 0000000000..845473c7a6 --- /dev/null +++ b/src/specify_cli/workflows/step/catalog/command_add.py @@ -0,0 +1,26 @@ +"""Command handler for ``specify workflow step catalog add``.""" + +from __future__ import annotations + +from ... import _commands as cli +from . import catalog_app + + +@catalog_app.command("add") +def workflow_step_catalog_add( + url: str = cli.typer.Argument(..., help="Catalog URL to add"), + name: str | None = cli.typer.Option(None, "--name", help="Catalog name"), +): + """Add a step catalog source.""" + from . import StepCatalog, StepValidationError + + project_root = cli._require_specify_project() + + catalog = StepCatalog(project_root) + try: + catalog.add_catalog(url, name) + except StepValidationError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Step catalog source added: {url}") diff --git a/src/specify_cli/workflows/step/catalog/command_list.py b/src/specify_cli/workflows/step/catalog/command_list.py new file mode 100644 index 0000000000..2addd05794 --- /dev/null +++ b/src/specify_cli/workflows/step/catalog/command_list.py @@ -0,0 +1,38 @@ +"""Command handler for ``specify workflow step catalog list``.""" + +from __future__ import annotations + +from ... import _commands as cli +from . import catalog_app + + +@catalog_app.command("list") +def workflow_step_catalog_list(): + """List configured step catalog sources.""" + from . import StepCatalog, StepCatalogError + + project_root = cli._require_specify_project() + catalog = StepCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except StepCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print("\n[bold cyan]Step Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg["install_allowed"] + else "[yellow]discovery only[/yellow]" + ) + cli.console.print( + f" [{i}] [bold]{cli._escape_markup(str(cfg['name']))}[/bold] — {install_status}" + ) + cli.console.print(f" {cli._escape_markup(str(cfg['url']))}") + if cfg.get("description"): + cli.console.print( + f" [dim]{cli._escape_markup(str(cfg['description']))}[/dim]" + ) + cli.console.print() diff --git a/src/specify_cli/workflows/step/catalog/command_remove.py b/src/specify_cli/workflows/step/catalog/command_remove.py new file mode 100644 index 0000000000..6eba4f8530 --- /dev/null +++ b/src/specify_cli/workflows/step/catalog/command_remove.py @@ -0,0 +1,27 @@ +"""Command handler for ``specify workflow step catalog remove``.""" + +from __future__ import annotations + +from ... import _commands as cli +from . import catalog_app + + +@catalog_app.command("remove") +def workflow_step_catalog_remove( + index: int = cli.typer.Argument( + ..., help="Catalog index to remove (from 'step catalog list')" + ), +): + """Remove a step catalog source by index.""" + from . import StepCatalog, StepValidationError + + project_root = cli._require_specify_project() + + catalog = StepCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except StepValidationError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed") diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/step/command/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/command/__init__.py rename to src/specify_cli/workflows/step/command/__init__.py diff --git a/src/specify_cli/workflows/step/command_add.py b/src/specify_cli/workflows/step/command_add.py new file mode 100644 index 0000000000..7a3a3b8cad --- /dev/null +++ b/src/specify_cli/workflows/step/command_add.py @@ -0,0 +1,376 @@ +"""Command handler for ``specify workflow step add``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import step_app + +from . import _helpers as step_helpers + + +@step_app.command("add") +def workflow_step_add( + step_id: str = cli.typer.Argument(..., help="Step type ID from catalog"), +): + """Install a custom step type from the step catalog.""" + from .catalog import ( + StepCatalog, + StepCatalogError, + StepRegistry, + StepValidationError, + ) + + project_root = cli._require_specify_project() + + catalog = StepCatalog(project_root) + try: + info = catalog.get_step_info(step_id) + except StepCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + if not info: + cli.console.print( + f"[red]Error:[/red] Step type '{step_id}' not found in catalog" + ) + raise cli.typer.Exit(1) + + if not info.get("_install_allowed", True): + cli.console.print( + f"[yellow]Warning:[/yellow] Step type '{step_id}' is from a discovery-only catalog" + ) + cli.console.print("Direct installation is not enabled for this catalog source.") + raise cli.typer.Exit(1) + + # Reject step IDs that collide with built-in step types + from .. import STEP_REGISTRY as _step_reg + + if step_id in _step_reg: + cli.console.print( + f"[red]Error:[/red] Step type '{step_id}' conflicts with a built-in step type" + ) + raise cli.typer.Exit(1) + + # Reject if already installed + registry = StepRegistry(project_root) + if registry.is_installed(step_id): + cli.console.print( + f"[red]Error:[/red] Step type '{step_id}' is already installed. " + "Remove it first with: [cyan]specify workflow step remove " + f"{step_id}[/cyan]" + ) + raise cli.typer.Exit(1) + + declared_step_yml_url = info.get("step_yml_url") + if declared_step_yml_url is not None and not isinstance(declared_step_yml_url, str): + cli.console.print( + f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " + "step.yml URL; expected a non-empty string" + ) + raise cli.typer.Exit(1) + step_yml_url = declared_step_yml_url or info.get("url") + if step_yml_url is None or ( + isinstance(step_yml_url, str) and not step_yml_url.strip() + ): + cli.console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL") + raise cli.typer.Exit(1) + if not isinstance(step_yml_url, str): + cli.console.print( + f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " + "step.yml URL; expected a non-empty string" + ) + raise cli.typer.Exit(1) + + # Derive __init__.py URL: replace trailing step.yml with __init__.py + # or use explicit init_url if provided. + init_url = info.get("init_url") + if init_url is not None and (not isinstance(init_url, str) or not init_url.strip()): + cli.console.print( + f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed " + "__init__.py URL; expected a non-empty string" + ) + raise cli.typer.Exit(1) + if not init_url: + if step_yml_url.endswith("step.yml"): + init_url = step_yml_url[: -len("step.yml")] + "__init__.py" + else: + cli.console.print( + f"[red]Error:[/red] Cannot derive __init__.py URL from '{step_yml_url}'. " + "Catalog entry should provide 'init_url' or a 'url' ending in 'step.yml'." + ) + raise cli.typer.Exit(1) + + # Preflight the declared file count before creating a staging directory or + # issuing any request. The two required files are always part of the package; + # duplicate declarations for them in extra_files are ignored below and do + # not count twice. + extra_files = info.get("extra_files") + if extra_files is not None and not isinstance(extra_files, dict): + cli.console.print( + "[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; " + "additional package files will not be downloaded." + ) + extra_files = {} + + def _is_required_package_file(rel_path: object) -> bool: + """Match portable path/case aliases of the two required package files.""" + if not isinstance(rel_path, str): + return False + parts = cli.PurePosixPath(rel_path.replace("\\", "/")).parts + return len(parts) == 1 and parts[0].casefold() in { + "step.yml", + "__init__.py", + } + + declared_extra_count = sum( + 1 for rel_path in (extra_files or {}) if not _is_required_package_file(rel_path) + ) + package_file_count = 2 + declared_extra_count + if package_file_count > step_helpers._MAX_STEP_PACKAGE_FILES: + cli.console.print( + f"[red]Error:[/red] Step package declares {package_file_count} files, " + f"exceeding the {step_helpers._MAX_STEP_PACKAGE_FILES}-file limit" + ) + raise cli.typer.Exit(1) + + from specify_cli.authentication.http import open_url as _open_url + + def _safe_fetch(url: str) -> bytes: + if not cli.is_https_or_localhost_http(url): + raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}") + with _open_url( + url, timeout=30, redirect_validator=cli._reject_insecure_download_redirect + ) as resp: + final_url = resp.geturl() + if not cli.is_https_or_localhost_http(final_url): + raise ValueError(f"Redirect to non-HTTPS URL: {final_url}") + return cli._read_response_within_limit(resp) + + step_helpers._validate_step_id_or_exit(step_id) + + steps_base_dir = step_helpers._resolve_steps_base_dir_or_exit(project_root) + step_dir = (steps_base_dir / step_id).resolve() + # Defense-in-depth: ensure the resolved directory is a direct child of + # steps_base_dir even after symlink resolution. + try: + rel_parts = step_dir.relative_to(steps_base_dir).parts + except ValueError: + cli.console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") + raise cli.typer.Exit(1) + if rel_parts != (step_id,): + cli.console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") + raise cli.typer.Exit(1) + + import shutil + import tempfile + + # Refuse if step_dir already exists (e.g. leftover from a previous failed/manual + # install that wasn't registered). The user should remove it before retrying. + if step_dir.exists(): + cli.console.print( + f"[red]Error:[/red] Step directory already exists at '{step_dir}'. " + f"Remove it manually or use: [cyan]specify workflow step remove {step_id}[/cyan]" + ) + raise cli.typer.Exit(1) + + # Create steps_base_dir now so the staging temp dir is on the same filesystem, + # enabling a truly atomic os.rename() below. + try: + steps_base_dir.mkdir(parents=True, exist_ok=True) + tmp_path = cli.Path( + tempfile.mkdtemp(prefix="speckit_step_tmp_", dir=steps_base_dir) + ) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to create staging directory: {exc}" + ) + raise cli.typer.Exit(1) + try: + try: + step_yml_content = _safe_fetch(step_yml_url) + init_py_content = _safe_fetch(init_url) + except Exception as exc: + cli.console.print(f"[red]Error:[/red] Failed to download step files: {exc}") + raise cli.typer.Exit(1) + + package_bytes = len(step_yml_content) + len(init_py_content) + if package_bytes > step_helpers._MAX_STEP_PACKAGE_BYTES: + cli.console.print( + f"[red]Error:[/red] Step package exceeds the " + f"{step_helpers._MAX_STEP_PACKAGE_BYTES}-byte total size limit" + ) + raise cli.typer.Exit(1) + + # Validate step.yml + try: + import yaml as _yaml + + step_yml_text = step_yml_content.decode("utf-8") + # ``safe_load`` returns None for BOTH an empty document and an + # explicit null scalar (``null``, ``~``, ``NULL``), so it cannot + # tell them apart on its own. ``compose`` yields no node only for + # a genuinely empty document. + node = _yaml.compose(step_yml_text) + meta = _yaml.safe_load(step_yml_text) + is_empty_document = node is None or ( + meta is None + and isinstance(node, _yaml.nodes.ScalarNode) + and node.value == "" + and node.start_mark.index == node.end_mark.index + ) + except Exception as exc: + cli.console.print(f"[red]Error:[/red] Invalid step.yml: {exc}") + raise cli.typer.Exit(1) + + # Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping + # (top-level ``[]``, ``false``, ``0``, ``''``, or an explicit ``null``) + # into ``{}`` and silently bypasses this shape check, surfacing the + # unrelated "missing 'step.type_key'" error below instead of the real + # problem. Only a genuinely empty document defaults to ``{}``. + if meta is None and is_empty_document: + meta = {} + elif not isinstance(meta, dict): + cli.console.print("[red]Error:[/red] step.yml must be a YAML mapping") + raise cli.typer.Exit(1) + + step_meta = meta.get("step", {}) + if not isinstance(step_meta, dict): + cli.console.print( + "[red]Error:[/red] step.yml 'step' field must be a mapping" + ) + raise cli.typer.Exit(1) + type_key = step_meta.get("type_key", "") + if not type_key: + cli.console.print( + "[red]Error:[/red] step.yml missing 'step.type_key' field" + ) + raise cli.typer.Exit(1) + + if type_key != step_id: + cli.console.print( + f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match " + f"catalog ID ({step_id!r})" + ) + raise cli.typer.Exit(1) + + # Write the two required files. + try: + (tmp_path / "step.yml").write_bytes(step_yml_content) + (tmp_path / "__init__.py").write_bytes(init_py_content) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to write step files to staging directory: {exc}" + ) + raise cli.typer.Exit(1) + + # Optionally download additional package files declared in the catalog entry + # (e.g. helper modules). Each entry in ``extra_files`` is a mapping of + # relative-path → URL. step.yml and __init__.py are ignored here (already + # written). Paths are validated to stay within the step package directory to + # prevent path-traversal attacks. + for rel_path, file_url in (extra_files or {}).items(): + if not isinstance(rel_path, str) or not rel_path.strip(): + cli.console.print( + "[red]Error:[/red] Catalog entry 'extra_files' contains an " + "empty or non-string path key" + ) + raise cli.typer.Exit(1) + if _is_required_package_file(rel_path): + continue # already written above + # Reject dot-path segments ('', '.', '..') that would refer to the + # package directory itself (IsADirectoryError) or escape it. + rel_parts = cli.Path(rel_path).parts + if not rel_parts or any(seg in ("", ".", "..") for seg in rel_parts): + cli.console.print( + f"[red]Error:[/red] extra_files path '{rel_path}' is not a " + "valid relative file path" + ) + raise cli.typer.Exit(1) + if not isinstance(file_url, str) or not file_url.strip(): + cli.console.print( + f"[red]Error:[/red] extra_files entry '{rel_path}' has an " + "empty or non-string URL" + ) + raise cli.typer.Exit(1) + # Resolve both destination and base to handle any symlinks in tmp_path itself, + # ensuring the traversal check is robust even on non-canonical paths. + resolved_base = tmp_path.resolve() + dest = (tmp_path / rel_path).resolve() + try: + dest.relative_to(resolved_base) + except ValueError: + cli.console.print( + f"[red]Error:[/red] extra_files path '{rel_path}' is outside " + "the step package directory" + ) + raise cli.typer.Exit(1) + try: + file_content = _safe_fetch(file_url) + except Exception as exc: + cli.console.print( + f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}" + ) + raise cli.typer.Exit(1) + package_bytes += len(file_content) + if package_bytes > step_helpers._MAX_STEP_PACKAGE_BYTES: + cli.console.print( + f"[red]Error:[/red] Step package exceeds the " + f"{step_helpers._MAX_STEP_PACKAGE_BYTES}-byte total size limit" + ) + raise cli.typer.Exit(1) + try: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(file_content) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to write extra file '{rel_path}': {exc}" + ) + raise cli.typer.Exit(1) + + # Atomically rename the staging directory to the final location. + # Both paths are under steps_base_dir (same filesystem), so os.rename() + # is atomic on POSIX and won't leave a partially-written directory at + # step_dir on failure. + try: + cli.os.rename(tmp_path, step_dir) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to install step '{step_id}': {exc}" + ) + raise cli.typer.Exit(1) + finally: + # Clean up if the rename hasn't moved tmp_path yet (i.e. on any failure). + shutil.rmtree(tmp_path, ignore_errors=True) + + step_name = info.get("name") or step_id + step_version = info.get("version") or step_meta.get("version") or "0.0.0" + + # Register in step registry + registry = StepRegistry(project_root) + try: + registry.add( + step_id, + { + "name": step_name, + "version": step_version, + "description": info.get( + "description", step_meta.get("description", "") + ), + "author": info.get("author", step_meta.get("author", "")), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "type_key": type_key, + }, + ) + except StepValidationError as exc: + # Roll back the just-installed directory so the system isn't left with + # an unregistered step package on disk after a registry write failure + # (e.g. read-only filesystem, permission denied). + shutil.rmtree(step_dir, ignore_errors=True) + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + cli.console.print(f"[green]✓[/green] Step type '{step_name}' ({step_id}) installed") + cli.console.print( + " Use [cyan]specify workflow step list[/cyan] to verify the installation." + ) diff --git a/src/specify_cli/workflows/step/command_info.py b/src/specify_cli/workflows/step/command_info.py new file mode 100644 index 0000000000..c98072d8c0 --- /dev/null +++ b/src/specify_cli/workflows/step/command_info.py @@ -0,0 +1,78 @@ +"""Command handler for ``specify workflow step info``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import step_app + + +@step_app.command("info") +def workflow_step_info( + step_id: str = cli.typer.Argument(..., help="Step type ID"), +): + """Show details for a step type.""" + from .. import STEP_REGISTRY + from .catalog import StepCatalog, StepCatalogError, StepRegistry + + project_root = cli._require_specify_project() + safe_step_id = cli._escape_markup(str(step_id)) + + registry = StepRegistry(project_root) + installed_meta = registry.get(step_id) + + # Check if it's a built-in + builtin_step = STEP_REGISTRY.get(step_id) + is_builtin = builtin_step is not None and not installed_meta + + if is_builtin: + cli.console.print( + f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]" + ) + cli.console.print(f" Type key: {safe_step_id}") + cli.console.print(" [green]Built-in step type[/green]") + return + + if installed_meta: + name = cli._escape_markup(str(installed_meta.get("name", step_id))) + version = cli._escape_markup(str(installed_meta.get("version", "?"))) + cli.console.print(f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})") + cli.console.print(f" Version: {version}") + if installed_meta.get("author"): + cli.console.print( + f" Author: {cli._escape_markup(str(installed_meta['author']))}" + ) + if installed_meta.get("description"): + cli.console.print( + f" Description: " + f"{cli._escape_markup(str(installed_meta['description']))}" + ) + cli.console.print(" [green]Installed[/green]") + return + + # Try catalog + catalog = StepCatalog(project_root) + try: + info = catalog.get_step_info(step_id) + except StepCatalogError: + info = None + + if info: + name = cli._escape_markup(str(info.get("name", step_id))) + version = cli._escape_markup(str(info.get("version", "?"))) + cli.console.print(f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})") + cli.console.print(f" Version: {version}") + if info.get("author"): + cli.console.print( + f" Author: {cli._escape_markup(str(info['author']))}" + ) + if info.get("description"): + cli.console.print( + f" Description: {cli._escape_markup(str(info['description']))}" + ) + cli.console.print(" [yellow]Not installed[/yellow]") + cli.console.print( + f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]" + ) + else: + cli.console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found") + raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/step/command_list.py b/src/specify_cli/workflows/step/command_list.py new file mode 100644 index 0000000000..35afdf5349 --- /dev/null +++ b/src/specify_cli/workflows/step/command_list.py @@ -0,0 +1,49 @@ +"""Command handler for ``specify workflow step list``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import step_app + + +@step_app.command("list") +def workflow_step_list(): + """List installed step types (built-in and custom).""" + from .. import STEP_REGISTRY + from .catalog import StepRegistry + + project_root = cli._require_specify_project() + specify_dir = project_root / ".specify" + + # Read installed custom steps from registry only — no dynamic imports + installed: dict = {} + if specify_dir.exists(): + registry = StepRegistry(project_root) + installed = registry.list() + + cli.console.print("\n[bold cyan]Installed Step Types:[/bold cyan]\n") + + built_in = sorted(k for k in STEP_REGISTRY if k not in installed) + if built_in: + cli.console.print(" [bold]Built-in:[/bold]") + for key in built_in: + cli.console.print(f" • {key}") + cli.console.print() + + if installed: + cli.console.print(" [bold]Custom (installed):[/bold]") + for key in sorted(installed): + meta = installed[key] or {} + name = cli._escape_markup(str(meta.get("name", key))) + safe_key = cli._escape_markup(str(key)) + version = cli._escape_markup(str(meta.get("version", "?"))) + cli.console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}") + cli.console.print() + + if not built_in and not installed: + cli.console.print("[yellow]No step types found.[/yellow]") + + if specify_dir.exists(): + cli.console.print( + " Install a new step type with: [cyan]specify workflow step add [/cyan]" + ) diff --git a/src/specify_cli/workflows/step/command_remove.py b/src/specify_cli/workflows/step/command_remove.py new file mode 100644 index 0000000000..58ec7602f3 --- /dev/null +++ b/src/specify_cli/workflows/step/command_remove.py @@ -0,0 +1,97 @@ +"""Command handler for ``specify workflow step remove``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import step_app + +from . import _helpers as step_helpers + + +@step_app.command("remove") +def workflow_step_remove( + step_id: str = cli.typer.Argument(..., help="Step type ID to uninstall"), +): + """Uninstall a custom step type.""" + from .catalog import StepRegistry, StepValidationError + + project_root = cli._require_specify_project() + + step_helpers._validate_step_id_or_exit(step_id) + + registry = StepRegistry(project_root) + in_registry = registry.is_installed(step_id) + + steps_base_dir = step_helpers._resolve_steps_base_dir_or_exit(project_root) + step_dir = (steps_base_dir / step_id).resolve() + # Defense-in-depth: even though step_helpers._validate_step_id_or_exit rejects path + # separators, ensure that the resolved directory is a single child of + # steps_base_dir and is not steps_base_dir itself. + try: + rel_parts = step_dir.relative_to(steps_base_dir).parts + except ValueError: + cli.console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") + raise cli.typer.Exit(1) + if rel_parts != (step_id,): + cli.console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") + raise cli.typer.Exit(1) + + dir_exists = step_dir.exists() + + if not in_registry and not dir_exists: + cli.console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed") + raise cli.typer.Exit(1) + + if not in_registry and dir_exists: + # The registry was likely reset due to corruption. Warn the user that the + # directory is being removed even though there is no registry entry, so + # the orphaned package can be cleaned up and a fresh install attempted. + cli.console.print( + f"[yellow]Warning:[/yellow] '{step_id}' has no registry entry " + "(registry may have been reset). Removing the orphaned directory." + ) + + if dir_exists and not in_registry: + # No registry write needed; just delete the orphaned directory. + import shutil + + try: + shutil.rmtree(step_dir) + except OSError as exc: + cli.console.print( + f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" + ) + raise cli.typer.Exit(1) + elif in_registry: + # Remove the registry entry, then the directory. If the directory + # delete fails, restore the registry entry so state stays consistent + # and a future `step add` isn't blocked by an orphaned directory + # with no registry entry. + registry_metadata = registry.get(step_id) + try: + registry.remove(step_id) + except StepValidationError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + if dir_exists: + import shutil + + try: + shutil.rmtree(step_dir) + except OSError as exc: + # Restore the original registry entry verbatim (bypass add() + # which would overwrite timestamps). + try: + if registry_metadata is not None: + registry.data["steps"][step_id] = registry_metadata + registry.save() + except Exception as restore_exc: # noqa: BLE001 + cli.console.print( + f"[yellow]Warning:[/yellow] Failed to restore registry entry " + f"for '{step_id}' after directory removal failure: {restore_exc}" + ) + cli.console.print( + f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" + ) + raise cli.typer.Exit(1) + cli.console.print(f"[green]✓[/green] Step type '{step_id}' uninstalled") diff --git a/src/specify_cli/workflows/step/command_search.py b/src/specify_cli/workflows/step/command_search.py new file mode 100644 index 0000000000..0e905e38d3 --- /dev/null +++ b/src/specify_cli/workflows/step/command_search.py @@ -0,0 +1,47 @@ +"""Command handler for ``specify workflow step search``.""" + +from __future__ import annotations + +from .. import _commands as cli +from . import step_app + + +@step_app.command("search") +def workflow_step_search( + query: str | None = cli.typer.Argument(None, help="Search query"), +): + """Search the step type catalog.""" + from .catalog import StepCatalog, StepCatalogError + + project_root = cli._require_specify_project() + + catalog = StepCatalog(project_root) + + try: + results = catalog.search(query=query) + except StepCatalogError as exc: + cli.console.print(f"[red]Error:[/red] {exc}") + raise cli.typer.Exit(1) + + if not results: + if query: + cli.console.print( + f"[yellow]No step types found matching '{query}'.[/yellow]" + ) + else: + cli.console.print("[yellow]No step types found in catalog.[/yellow]") + return + + cli.console.print(f"\n[bold cyan]Step Types ({len(results)}):[/bold cyan]\n") + for step in results: + install_note = ( + "" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]" + ) + name = cli._escape_markup(str(step.get("name", step.get("id", "?")))) + step_id = cli._escape_markup(str(step.get("id", "?"))) + version = cli._escape_markup(str(step.get("version", "?"))) + cli.console.print(f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}") + desc = step.get("description", "") + if desc: + cli.console.print(f" {cli._escape_markup(str(desc))}") + cli.console.print() diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/step/do_while/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/do_while/__init__.py rename to src/specify_cli/workflows/step/do_while/__init__.py diff --git a/src/specify_cli/workflows/steps/fan_in/__init__.py b/src/specify_cli/workflows/step/fan_in/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/fan_in/__init__.py rename to src/specify_cli/workflows/step/fan_in/__init__.py diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/step/fan_out/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/fan_out/__init__.py rename to src/specify_cli/workflows/step/fan_out/__init__.py diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/step/gate/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/gate/__init__.py rename to src/specify_cli/workflows/step/gate/__init__.py diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/step/if_then/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/if_then/__init__.py rename to src/specify_cli/workflows/step/if_then/__init__.py diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/step/init/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/init/__init__.py rename to src/specify_cli/workflows/step/init/__init__.py diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/step/prompt/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/prompt/__init__.py rename to src/specify_cli/workflows/step/prompt/__init__.py diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/step/shell/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/shell/__init__.py rename to src/specify_cli/workflows/step/shell/__init__.py diff --git a/src/specify_cli/workflows/steps/slot/__init__.py b/src/specify_cli/workflows/step/slot/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/slot/__init__.py rename to src/specify_cli/workflows/step/slot/__init__.py diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/step/switch/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/switch/__init__.py rename to src/specify_cli/workflows/step/switch/__init__.py diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/step/while_loop/__init__.py similarity index 100% rename from src/specify_cli/workflows/steps/while_loop/__init__.py rename to src/specify_cli/workflows/step/while_loop/__init__.py diff --git a/src/specify_cli/workflows/steps/__init__.py b/src/specify_cli/workflows/steps/__init__.py deleted file mode 100644 index 0aa9182dd0..0000000000 --- a/src/specify_cli/workflows/steps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Auto-discovery for built-in step types.""" diff --git a/tests/specify_cli/workflows/__init__.py b/tests/specify_cli/workflows/__init__.py new file mode 100644 index 0000000000..09457e7543 --- /dev/null +++ b/tests/specify_cli/workflows/__init__.py @@ -0,0 +1 @@ +"""Tests for workflow CLI commands.""" diff --git a/tests/specify_cli/workflows/catalog/__init__.py b/tests/specify_cli/workflows/catalog/__init__.py new file mode 100644 index 0000000000..c31758b024 --- /dev/null +++ b/tests/specify_cli/workflows/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests for workflow catalog CLI commands.""" diff --git a/tests/specify_cli/workflows/catalog/test_command_add.py b/tests/specify_cli/workflows/catalog/test_command_add.py new file mode 100644 index 0000000000..8885d62ecc --- /dev/null +++ b/tests/specify_cli/workflows/catalog/test_command_add.py @@ -0,0 +1,30 @@ +"""Tests for ``specify workflow catalog add``.""" + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.workflows.catalog import WorkflowCatalog + + +def test_workflow_catalog_add_persists_named_source(project_dir, monkeypatch): + monkeypatch.chdir(project_dir) + + result = CliRunner().invoke( + app, + [ + "workflow", + "catalog", + "add", + "https://example.com/workflows.json", + "--name", + "local", + ], + ) + + assert result.exit_code == 0, result.output + configs = WorkflowCatalog(project_dir).get_catalog_configs() + assert any( + config["name"] == "local" + and config["url"] == "https://example.com/workflows.json" + for config in configs + ) diff --git a/tests/specify_cli/workflows/catalog/test_command_list.py b/tests/specify_cli/workflows/catalog/test_command_list.py new file mode 100644 index 0000000000..4f1196f274 --- /dev/null +++ b/tests/specify_cli/workflows/catalog/test_command_list.py @@ -0,0 +1,37 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + def test_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch): + """User-editable catalog name/url/description must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + configs = [ + { + "name": "Bracket [Catalog]", + "url": "https://example.com/[cat].json", + "description": "desc [with] brackets", + "install_allowed": True, + }, + ] + monkeypatch.setattr( + WorkflowCatalog, + "get_catalog_configs", + lambda self: [dict(c) for c in configs], + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "catalog", "list"]) + assert result.exit_code == 0, result.output + assert "Bracket [Catalog]" in result.output + assert "https://example.com/[cat].json" in result.output + assert "desc [with] brackets" in result.output diff --git a/tests/specify_cli/workflows/catalog/test_command_remove.py b/tests/specify_cli/workflows/catalog/test_command_remove.py new file mode 100644 index 0000000000..22eaa3891a --- /dev/null +++ b/tests/specify_cli/workflows/catalog/test_command_remove.py @@ -0,0 +1,19 @@ +"""Tests for ``specify workflow catalog remove``.""" + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.workflows.catalog import WorkflowCatalog + + +def test_workflow_catalog_remove_deletes_selected_source(project_dir, monkeypatch): + monkeypatch.chdir(project_dir) + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/workflows.json", "local") + + result = CliRunner().invoke(app, ["workflow", "catalog", "remove", "0"]) + + assert result.exit_code == 0, result.output + assert all( + config["name"] != "local" for config in catalog.get_catalog_configs() + ) diff --git a/tests/specify_cli/workflows/conftest.py b/tests/specify_cli/workflows/conftest.py new file mode 100644 index 0000000000..f956cbb260 --- /dev/null +++ b/tests/specify_cli/workflows/conftest.py @@ -0,0 +1,58 @@ +"""Shared fixtures for workflow command tests.""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir, ignore_errors=(sys.platform == "win32")) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock Spec Kit project with workflow storage.""" + workflows_dir = temp_dir / ".specify" / "workflows" + workflows_dir.mkdir(parents=True, exist_ok=True) + return temp_dir + + +@pytest.fixture +def sample_workflow_yaml(): + """Return a valid minimal workflow YAML string.""" + return """ +schema_version: "1.0" +workflow: + id: "test-workflow" + name: "Test Workflow" + version: "1.0.0" + description: "A test workflow" + +inputs: + spec: + type: string + required: true + scope: + type: string + default: "full" + +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.spec }}" + + - id: step-two + command: speckit.plan + input: + args: "{{ steps.step-one.output.command }}" +""" diff --git a/tests/specify_cli/workflows/helpers.py b/tests/specify_cli/workflows/helpers.py new file mode 100644 index 0000000000..fafa555e73 --- /dev/null +++ b/tests/specify_cli/workflows/helpers.py @@ -0,0 +1,48 @@ +"""Shared helpers for workflow command tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + + +class _StubStdin: + def __init__(self, tty: bool): + self._tty = tty + + def isatty(self): + return self._tty + + +class _FakeSys: + def __init__(self, tty: bool): + self.stdin = _StubStdin(tty) + + def __getattr__(self, name): + return getattr(sys, name) + + +def force_gate_stdin(monkeypatch, *, tty: bool): + from specify_cli.workflows.step import gate as gate_module + + monkeypatch.setattr(gate_module, "sys", _FakeSys(tty=tty)) + + +def write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path: + workflow_dir = project_root / ".specify" / "workflows" / workflow_id + workflow_dir.mkdir(parents=True, exist_ok=True) + workflow_path = workflow_dir / "workflow.yml" + workflow_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return workflow_path + + +def write_overlay( + project_root: Path, workflow_id: str, overlay_id: str, data: dict +) -> Path: + overlay_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id + overlay_dir.mkdir(parents=True, exist_ok=True) + overlay_path = overlay_dir / f"{overlay_id}.yml" + overlay_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return overlay_path diff --git a/tests/specify_cli/workflows/overlay/__init__.py b/tests/specify_cli/workflows/overlay/__init__.py new file mode 100644 index 0000000000..fc8904724c --- /dev/null +++ b/tests/specify_cli/workflows/overlay/__init__.py @@ -0,0 +1 @@ +"""Tests for workflow overlay CLI commands.""" diff --git a/tests/specify_cli/workflows/overlay/test_command_add.py b/tests/specify_cli/workflows/overlay/test_command_add.py new file mode 100644 index 0000000000..9282f487ff --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_add.py @@ -0,0 +1,513 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_overlay_add(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"] + ) + assert result.exit_code == 0, result.output + assert "Overlay 'ov1' added" in result.output + + installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + assert installed.is_file() + data = yaml.safe_load(installed.read_text(encoding="utf-8")) + assert data["priority"] == 5 + + def test_overlay_add_reuses_yaml_extension(self, project_dir, monkeypatch): + """If .yaml already exists, overlay add must write to it instead of creating .yml.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # Pre-create the overlay using the .yaml extension. + existing_yaml = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yaml" + existing_yaml.parent.mkdir(parents=True, exist_ok=True) + existing_yaml.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 1, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 20, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + assert result.exit_code == 0, result.output + + # Should have written to the pre-existing .yaml file. + assert existing_yaml.is_file() + data = yaml.safe_load(existing_yaml.read_text(encoding="utf-8")) + assert data["priority"] == 10 + + # Must NOT have created a duplicate .yml alongside the .yaml. + duplicate_yml = existing_yaml.with_suffix(".yml") + assert not duplicate_yml.exists(), "duplicate .yml was created alongside existing .yaml" + assert list(existing_yaml.parent.glob(f".{existing_yaml.name}.*.bak")) == [] + + def test_overlay_add_with_priority_override_missing_in_file(self, project_dir, monkeypatch): + """--priority must fix a missing priority in the overlay file.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # Overlay file has NO priority field + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"] + ) + assert result.exit_code == 0, result.output + assert "Overlay 'ov1' added" in result.output + + installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + assert installed.is_file() + data = yaml.safe_load(installed.read_text(encoding="utf-8")) + assert data["priority"] == 5 + + def test_overlay_add_defaults_priority_to_ten(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + assert yaml.safe_load(installed.read_text(encoding="utf-8"))["priority"] == 10 + + def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + ["workflow", "overlay", "add", str(overlay_file), "--priority", "0"], + ) + + assert result.exit_code == 1 + assert "must be >= 1" in result.output + + def test_overlay_add_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """``overlay add`` must not escape non-ASCII text in the written file. + + Overlay files are documented as hand-authored, so writing them back + with ``\\uXXXX`` escapes makes the user's own file unreadable. + """ + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + assert result.exit_code == 0, result.output + + installed = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ) + text = installed.read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + # The value must still round-trip identically. + data = yaml.safe_load(text) + assert data["edits"][0]["step"]["message"] == message + + + +class TestOverlayPathTraversal: + """Overlay CLI must stay inside the overlay directory.""" + + def test_overlay_add_rejects_traversal_in_workflow_id(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "../wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"] + ) + assert result.exit_code != 0, result.output + assert "invalid" in result.output.lower() or "traversal" in result.output.lower() + + def test_overlay_add_rejects_traversal_in_overlay_id(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "../../ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"] + ) + assert result.exit_code != 0, result.output + assert "invalid" in result.output.lower() or "traversal" in result.output.lower() + + def test_overlay_add_rejects_symlinked_target_file(self, project_dir, monkeypatch): + """overlay add must not overwrite through a symlinked overlay file target.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + + overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + overlay_dir.mkdir(parents=True, exist_ok=True) + real_file = overlay_dir / "other.yml" + real_file.write_text("sentinel\n", encoding="utf-8") + (overlay_dir / "ov1.yml").symlink_to(real_file) + + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + + assert result.exit_code != 0, result.output + assert "symlinked path" in result.output.lower() + assert real_file.read_text(encoding="utf-8") == "sentinel\n" + + +class TestOverlayAddDoesNotClobber: + """`overlay add` must not destroy a different overlay sitting at .yml. + + Overlay identity is the manifest `id`, not the filename, so `lint.yml` can + legitimately contain `id: format`. The fallback filename-derived target + must not overwrite an occupant with a different or unreadable identity. + """ + + def _setup(self, project_dir: Path, occupant_id: str | None) -> tuple[Path, Path]: + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + overlay_dir.mkdir(parents=True, exist_ok=True) + if occupant_id is not None: + (overlay_dir / "lint.yml").write_text( + yaml.safe_dump( + { + "id": occupant_id, + "extends": "wf", + "priority": 3, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + incoming = project_dir / "incoming.yml" + incoming.write_text( + yaml.safe_dump( + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + return overlay_dir, incoming + + def test_add_does_not_clobber_a_different_overlay( + self, project_dir, monkeypatch + ): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id="format") + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 1, result.output + survivor = yaml.safe_load( + (overlay_dir / "lint.yml").read_text(encoding="utf-8") + ) + assert survivor["id"] == "format", survivor + assert survivor["priority"] == 3, survivor + assert [path.name for path in overlay_dir.iterdir() if "bak" in path.name] == [] + + def test_add_still_updates_the_same_overlay_in_place( + self, project_dir, monkeypatch + ): + """The guard must only fire for a different overlay id.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id="lint") + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 0, result.output + updated = yaml.safe_load( + (overlay_dir / "lint.yml").read_text(encoding="utf-8") + ) + assert updated["id"] == "lint" + assert updated["priority"] == 10 + + def test_add_refuses_a_directory_occupant(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id=None) + occupant = overlay_dir / "lint.yml" + occupant.mkdir() + (occupant / "precious.txt").write_text("user data", encoding="utf-8") + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 1, result.output + assert "not a regular file" in " ".join(result.output.split()) + assert occupant.is_dir() + assert (occupant / "precious.txt").read_text(encoding="utf-8") == "user data" + assert [path.name for path in overlay_dir.iterdir() if "bak" in path.name] == [] + + @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFOs are POSIX-only") + def test_add_refuses_a_fifo_occupant(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id=None) + occupant = overlay_dir / "lint.yml" + os.mkfifo(occupant) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 1, result.output + assert "not a regular file" in " ".join(result.output.split()) + assert occupant.is_fifo() + assert [path.name for path in overlay_dir.iterdir() if "bak" in path.name] == [] + + @pytest.mark.parametrize( + "raw", + [ + "id: [1, 2\n bad: yaml:\n", + "- just\n- a\n- sequence\n", + "just a scalar\n", + "extends: wf\npriority: 3\n", + "id: 5\nextends: wf\npriority: 3\n", + ], + ids=["malformed", "sequence", "scalar", "missing_id", "non_string_id"], + ) + def test_add_fails_closed_when_the_occupant_cannot_be_identified( + self, project_dir, monkeypatch, raw + ): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id=None) + occupant = overlay_dir / "lint.yml" + occupant.write_text(raw, encoding="utf-8") + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 1, result.output + assert occupant.read_text(encoding="utf-8") == raw + assert [path.name for path in overlay_dir.iterdir() if "bak" in path.name] == [] + + def test_add_creates_the_file_when_absent(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + overlay_dir, incoming = self._setup(project_dir, occupant_id=None) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(incoming)]) + + assert result.exit_code == 0, result.output + created = yaml.safe_load( + (overlay_dir / "lint.yml").read_text(encoding="utf-8") + ) + assert created["id"] == "lint" diff --git a/tests/specify_cli/workflows/overlay/test_command_disable.py b/tests/specify_cli/workflows/overlay/test_command_disable.py new file mode 100644 index 0000000000..6ab9c72e07 --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_disable.py @@ -0,0 +1,165 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_overlay as _write_overlay, + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_overlay_disable_and_enable(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "ov1"]) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + ) + assert data["enabled"] is False + + result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "ov1"]) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + ) + assert data["enabled"] is True + + def test_overlay_list_shows_disabled_overlay(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "enabled": False, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "list", "wf"]) + assert result.exit_code == 0, result.output + assert "ov1" in result.output + assert "disabled" in result.output + + + +class TestOverlayFilenameVsManifestId: + """Overlay identity must come from the manifest ``id`` field, not the filename. + + This matches the project-wide convention: presets use ``preset.id``, + extensions use ``extension.id``, workflows use ``workflow.id``, and + workflow steps use ``step.type_key``. Overlays must follow the same pattern. + """ + + def _write_mismatched_overlay( + self, project_root: Path, workflow_id: str, filename: str, manifest_id: str, data: dict + ) -> Path: + """Write an overlay file where filename != manifest id.""" + ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id + ov_dir.mkdir(parents=True, exist_ok=True) + ov_path = ov_dir / filename + ov_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return ov_path + + def test_enable_disable_with_mismatched_filename(self, project_dir, monkeypatch): + """enable/disable must work when filename != manifest id.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + self._write_mismatched_overlay( + project_dir, + "wf", + "custom.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "lint"]) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + (project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text( + encoding="utf-8" + ) + ) + assert data["enabled"] is False + + result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "lint"]) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + (project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text( + encoding="utf-8" + ) + ) + assert data["enabled"] is True diff --git a/tests/specify_cli/workflows/overlay/test_command_enable.py b/tests/specify_cli/workflows/overlay/test_command_enable.py new file mode 100644 index 0000000000..e764b23de3 --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_enable.py @@ -0,0 +1,32 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + + +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayPathTraversal: + """Overlay CLI must stay inside the overlay directory.""" + + def test_overlay_enable_rejects_traversal(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "../other"]) + assert result.exit_code != 0, result.output + assert "invalid" in result.output.lower() or "traversal" in result.output.lower() diff --git a/tests/specify_cli/workflows/overlay/test_command_list.py b/tests/specify_cli/workflows/overlay/test_command_list.py new file mode 100644 index 0000000000..dc1a43ece8 --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_list.py @@ -0,0 +1,239 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import typer +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_overlay as _write_overlay, + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_overlay_list(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "list", "wf"]) + assert result.exit_code == 0, result.output + assert "ov1" in result.output + + + +class TestOverlayFilenameVsManifestId: + """Overlay identity must come from the manifest ``id`` field, not the filename. + + This matches the project-wide convention: presets use ``preset.id``, + extensions use ``extension.id``, workflows use ``workflow.id``, and + workflow steps use ``step.type_key``. Overlays must follow the same pattern. + """ + + def _write_mismatched_overlay( + self, project_root: Path, workflow_id: str, filename: str, manifest_id: str, data: dict + ) -> Path: + """Write an overlay file where filename != manifest id.""" + ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id + ov_dir.mkdir(parents=True, exist_ok=True) + ov_path = ov_dir / filename + ov_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return ov_path + + def test_find_overlay_by_manifest_id_not_filename(self, project_dir, monkeypatch): + """_find_overlay_file must locate overlays by manifest id, not filename.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # File is named "custom.yml" but manifest declares id: "lint" + self._write_mismatched_overlay( + project_dir, + "wf", + "custom.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + }, + ) + + from specify_cli.workflows.overlay.operations import _find_overlay_file + + # Must find by manifest id "lint", not by filename "custom" + found = _find_overlay_file(project_dir, "wf", "lint") + assert found is not None + assert found.name == "custom.yml" + + # Must NOT find by filename stem "custom" + not_found = _find_overlay_file(project_dir, "wf", "custom") + assert not_found is None + + def test_duplicate_manifest_id_is_rejected(self, project_dir, monkeypatch): + """Two files with the same manifest ID are ambiguous.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # Two files, both declare id: "lint" + self._write_mismatched_overlay( + project_dir, + "wf", + "aaa.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + }, + ) + self._write_mismatched_overlay( + project_dir, + "wf", + "zzz.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 20, + "edits": [{"remove": "a"}], + }, + ) + + from specify_cli.workflows.overlay.operations import _find_overlay_file + + with pytest.raises(typer.Exit): + _find_overlay_file(project_dir, "wf", "lint") + + + +class TestOverlayPathTraversal: + """Overlay CLI must stay inside the overlay directory.""" + + @pytest.mark.parametrize("workflow_id", ["overlays", "runs", "steps"]) + def test_overlay_operations_reject_reserved_workflow_id( + self, project_dir, monkeypatch, workflow_id + ): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + result = runner.invoke(app, ["workflow", "overlay", "list", workflow_id]) + assert result.exit_code != 0, result.output + assert "Invalid" in result.output or "reserved" in result.output.lower() + + def test_overlay_rejects_symlinked_overlays_dir(self, project_dir, monkeypatch, tmp_path): + """Overlay commands must reject a symlinked .specify/workflows/overlays directory.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + + # Create a symlinked overlays directory pointing outside the project + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + overlays_dir = project_dir / ".specify" / "workflows" / "overlays" + overlays_dir.symlink_to(outside_dir) + + result = runner.invoke(app, ["workflow", "overlay", "list", "wf"]) + assert result.exit_code != 0, result.output + assert "symlink" in result.output.lower() + + def test_overlay_list_rejects_symlinked_per_workflow_dir(self, project_dir, monkeypatch, tmp_path): + """Overlay list must reject a symlinked per-workflow overlay directory.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + + # Create a real overlay directory outside the project. + outside_dir = tmp_path / "outside_wf" + outside_dir.mkdir() + outside_dir.joinpath("evil.yml").write_text( + yaml.safe_dump( + { + "id": "evil", + "extends": "wf", + "priority": 100, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "evil-step", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + # Symlink the per-workflow overlay directory to the outside location. + overlays_root = project_dir / ".specify" / "workflows" / "overlays" + overlays_root.mkdir(parents=True, exist_ok=True) + symlink_dir = overlays_root / "wf" + symlink_dir.symlink_to(outside_dir) + + result = runner.invoke(app, ["workflow", "overlay", "list", "wf"]) + assert result.exit_code != 0, result.output + assert "symlink" in result.output.lower() + + def test_overlay_list_reports_invalid_yaml_cleanly(self, project_dir, monkeypatch): + """Overlay list should surface malformed overlay YAML as a clean user error.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + overlay_dir.mkdir(parents=True, exist_ok=True) + (overlay_dir / "broken.yml").write_text("id: broken\nextends: wf\npriority: [\n", encoding="utf-8") + + result = runner.invoke(app, ["workflow", "overlay", "list", "wf"]) + + assert result.exit_code != 0, result.output + assert "Invalid YAML" in result.output diff --git a/tests/specify_cli/workflows/overlay/test_command_remove.py b/tests/specify_cli/workflows/overlay/test_command_remove.py new file mode 100644 index 0000000000..561f814479 --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_remove.py @@ -0,0 +1,172 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_overlay as _write_overlay, + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_overlay_remove(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "ov1"]) + assert result.exit_code == 0, result.output + assert not ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).exists() + + + +class TestOverlayFilenameVsManifestId: + """Overlay identity must come from the manifest ``id`` field, not the filename. + + This matches the project-wide convention: presets use ``preset.id``, + extensions use ``extension.id``, workflows use ``workflow.id``, and + workflow steps use ``step.type_key``. Overlays must follow the same pattern. + """ + + def _write_mismatched_overlay( + self, project_root: Path, workflow_id: str, filename: str, manifest_id: str, data: dict + ) -> Path: + """Write an overlay file where filename != manifest id.""" + ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id + ov_dir.mkdir(parents=True, exist_ok=True) + ov_path = ov_dir / filename + ov_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return ov_path + + def test_remove_with_mismatched_filename(self, project_dir, monkeypatch): + """remove must work when filename != manifest id.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + self._write_mismatched_overlay( + project_dir, + "wf", + "custom.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert not ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml" + ).exists() + + + +class TestOverlayPathTraversal: + """Overlay CLI must stay inside the overlay directory.""" + + def test_overlay_remove_cannot_escape_overlays_dir(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # Create a base workflow file that would be the traversal target. + target = project_dir / ".specify" / "workflows" / "wf" / "workflow.yml" + assert target.is_file() + + result = runner.invoke( + app, ["workflow", "overlay", "remove", "wf", "../wf/workflow"] + ) + assert result.exit_code != 0, result.output + assert target.is_file() + assert "Invalid" in result.output or "traversal" in result.output.lower() + + def test_overlay_remove_rejects_symlink(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + real_file = overlay_dir / "ov1.yml" + symlink_file = overlay_dir / "symlink.yml" + symlink_file.symlink_to(real_file) + + result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "symlink"]) + assert result.exit_code != 0, result.output + assert real_file.is_file() + assert "symlink" in result.output.lower() or "Invalid" in result.output diff --git a/tests/specify_cli/workflows/overlay/test_command_set_priority.py b/tests/specify_cli/workflows/overlay/test_command_set_priority.py new file mode 100644 index 0000000000..f4290e29aa --- /dev/null +++ b/tests/specify_cli/workflows/overlay/test_command_set_priority.py @@ -0,0 +1,244 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_overlay as _write_overlay, + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_overlay_set_priority_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """Toggling an overlay must not mangle non-ASCII text already in it.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + ) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"] + ) + assert result.exit_code == 0, result.output + + text = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + data = yaml.safe_load(text) + assert data["priority"] == 20 + assert data["edits"][0]["step"]["message"] == message + + def test_overlay_set_priority(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"] + ) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + ) + assert data["priority"] == 20 + assert list( + (project_dir / ".specify" / "workflows" / "overlays" / "wf").glob( + ".ov1.yml.*.bak" + ) + ) == [] + + def test_overlay_set_priority_rejects_zero(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1", "0"] + ) + + assert result.exit_code == 1 + assert "must be >= 1" in result.output + + def test_overlay_set_priority_rejects_ids_with_trailing_newline(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1\n", "20"] + ) + assert result.exit_code == 1 + assert "Invalid overlay ID" in result.output + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf\n", "ov1", "20"] + ) + assert result.exit_code == 1 + assert "Invalid workflow ID" in result.output + + + +class TestOverlayFilenameVsManifestId: + """Overlay identity must come from the manifest ``id`` field, not the filename. + + This matches the project-wide convention: presets use ``preset.id``, + extensions use ``extension.id``, workflows use ``workflow.id``, and + workflow steps use ``step.type_key``. Overlays must follow the same pattern. + """ + + def _write_mismatched_overlay( + self, project_root: Path, workflow_id: str, filename: str, manifest_id: str, data: dict + ) -> Path: + """Write an overlay file where filename != manifest id.""" + ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id + ov_dir.mkdir(parents=True, exist_ok=True) + ov_path = ov_dir / filename + ov_path.write_text(yaml.safe_dump(data), encoding="utf-8") + return ov_path + + def test_set_priority_with_mismatched_filename(self, project_dir, monkeypatch): + """set-priority must work when filename != manifest id.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + self._write_mismatched_overlay( + project_dir, + "wf", + "custom.yml", + "lint", + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + }, + ) + + result = runner.invoke(app, ["workflow", "overlay", "set-priority", "wf", "lint", "25"]) + assert result.exit_code == 0, result.output + data = yaml.safe_load( + (project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text( + encoding="utf-8" + ) + ) + assert data["priority"] == 25 + + + +class TestOverlayPathTraversal: + """Overlay CLI must stay inside the overlay directory.""" + + def test_overlay_set_priority_rejects_traversal(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "../other", "10"] + ) + assert result.exit_code != 0, result.output + assert "invalid" in result.output.lower() or "traversal" in result.output.lower() diff --git a/tests/specify_cli/workflows/step/__init__.py b/tests/specify_cli/workflows/step/__init__.py new file mode 100644 index 0000000000..ad388a9755 --- /dev/null +++ b/tests/specify_cli/workflows/step/__init__.py @@ -0,0 +1 @@ +"""Tests for workflow step CLI commands.""" diff --git a/tests/specify_cli/workflows/step/catalog/__init__.py b/tests/specify_cli/workflows/step/catalog/__init__.py new file mode 100644 index 0000000000..14ba41a84e --- /dev/null +++ b/tests/specify_cli/workflows/step/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests for workflow step catalog CLI commands.""" diff --git a/tests/specify_cli/workflows/step/catalog/test_command_add.py b/tests/specify_cli/workflows/step/catalog/test_command_add.py new file mode 100644 index 0000000000..168dde0ab7 --- /dev/null +++ b/tests/specify_cli/workflows/step/catalog/test_command_add.py @@ -0,0 +1,31 @@ +"""Tests for ``specify workflow step catalog add``.""" + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.workflows.step.catalog import StepCatalog + + +def test_workflow_step_catalog_add_persists_named_source(project_dir, monkeypatch): + monkeypatch.chdir(project_dir) + + result = CliRunner().invoke( + app, + [ + "workflow", + "step", + "catalog", + "add", + "https://example.com/steps.json", + "--name", + "local", + ], + ) + + assert result.exit_code == 0, result.output + configs = StepCatalog(project_dir).get_catalog_configs() + assert any( + config["name"] == "local" + and config["url"] == "https://example.com/steps.json" + for config in configs + ) diff --git a/tests/specify_cli/workflows/step/catalog/test_command_list.py b/tests/specify_cli/workflows/step/catalog/test_command_list.py new file mode 100644 index 0000000000..6aff5d3569 --- /dev/null +++ b/tests/specify_cli/workflows/step/catalog/test_command_list.py @@ -0,0 +1,37 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + def test_step_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch): + """User-editable step-catalog name/url/description must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + configs = [ + { + "name": "Bracket [Step]", + "url": "https://example.com/[step].json", + "description": "step [with] brackets", + "install_allowed": True, + }, + ] + monkeypatch.setattr( + StepCatalog, + "get_catalog_configs", + lambda self: [dict(c) for c in configs], + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "catalog", "list"]) + assert result.exit_code == 0, result.output + assert "Bracket [Step]" in result.output + assert "https://example.com/[step].json" in result.output + assert "step [with] brackets" in result.output diff --git a/tests/specify_cli/workflows/step/catalog/test_command_remove.py b/tests/specify_cli/workflows/step/catalog/test_command_remove.py new file mode 100644 index 0000000000..71ebf937c2 --- /dev/null +++ b/tests/specify_cli/workflows/step/catalog/test_command_remove.py @@ -0,0 +1,23 @@ +"""Tests for ``specify workflow step catalog remove``.""" + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.workflows.step.catalog import StepCatalog + + +def test_workflow_step_catalog_remove_deletes_selected_source( + project_dir, monkeypatch +): + monkeypatch.chdir(project_dir) + catalog = StepCatalog(project_dir) + catalog.add_catalog("https://example.com/steps.json", "local") + + result = CliRunner().invoke( + app, ["workflow", "step", "catalog", "remove", "0"] + ) + + assert result.exit_code == 0, result.output + assert all( + config["name"] != "local" for config in catalog.get_catalog_configs() + ) diff --git a/tests/specify_cli/workflows/step/test_command_add.py b/tests/specify_cli/workflows/step/test_command_add.py new file mode 100644 index 0000000000..e19c53c6b1 --- /dev/null +++ b/tests/specify_cli/workflows/step/test_command_add.py @@ -0,0 +1,606 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import os + +import pytest + + + +class TestWorkflowStepAddCLI: + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_add_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + outside = project_dir.parent / "outside-steps" + outside.mkdir(parents=True, exist_ok=True) + steps_link = project_dir / ".specify" / "workflows" / "steps" + steps_link.symlink_to(outside, target_is_directory=True) + + def _fake_get_step_info(self, step_id): + return { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + } + + monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) + + assert result.exit_code != 0 + assert "Refusing to use symlinked step directory" in result.output + + def test_add_rejects_oversized_step_response(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + from specify_cli.workflows.step.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + }, + ) + + class _FakeResponse: + def __init__(self, url): + self.url = url + self.body = b"x" * 500 + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getheader(self, name): + return None + + def geturl(self): + return self.url + + def read(self, size=-1): + if size < 0: + size = len(self.body) - self.offset + chunk = self.body[self.offset : self.offset + size] + self.offset += len(chunk) + return chunk + + monkeypatch.setattr( + auth_http, + "open_url", + lambda url, timeout=30, redirect_validator=None: _FakeResponse(url), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert ( + "responseexceedsthe100-byteworkflowsizelimit" + in "".join(result.output.split()) + ) + assert not ( + project_dir / ".specify" / "workflows" / "steps" / "my-step" + ).exists() + + @pytest.mark.parametrize( + "step_yml_body", [b"[]", b"false", b"0", b"''", b"null", b"~", b"NULL"] + ) + def test_add_rejects_falsy_non_mapping_step_yml( + self, project_dir, monkeypatch, step_yml_body + ): + """A FALSY non-mapping step.yml document ([], false, 0, '') must be + reported as "step.yml must be a YAML mapping", not silently coerced by + ``or {}`` into {} and then misreported as the unrelated "missing + 'step.type_key'" error — matching how a TRUTHY non-mapping document + (e.g. a bare string) already reports the mapping-shape error. An + explicit null scalar (null/~/NULL) parses to the same ``None`` as a + genuinely empty document, so it must be distinguished (via + ``yaml.compose``) and rejected too, rather than defaulting to {}.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + }, + ) + + class _FakeResponse: + def __init__(self, url): + self.url = url + self.body = step_yml_body if url.endswith("step.yml") else b"" + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getheader(self, name): + return None + + def geturl(self): + return self.url + + def read(self, size=-1): + if size < 0: + size = len(self.body) - self.offset + chunk = self.body[self.offset : self.offset + size] + self.offset += len(chunk) + return chunk + + monkeypatch.setattr( + auth_http, + "open_url", + lambda url, timeout=30, redirect_validator=None: _FakeResponse(url), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert "step.yml must be a YAML mapping" in result.output + assert not ( + project_dir / ".specify" / "workflows" / "steps" / "my-step" + ).exists() + + @pytest.mark.parametrize( + ("catalog_fields", "expected"), + [ + ({"url": 123}, "malformed step.yml URL"), + ( + { + "step_yml_url": [], + "url": "https://example.com/step.yml", + }, + "malformed step.yml URL", + ), + ( + { + "url": "https://example.com/step.yml", + "init_url": 123, + }, + "malformed __init__.py URL", + ), + ], + ) + def test_add_rejects_non_string_required_urls_before_network( + self, project_dir, monkeypatch, catalog_fields, expected + ): + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as auth_http + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "_install_allowed": True, + **catalog_fields, + }, + ) + monkeypatch.setattr( + auth_http, + "open_url", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("download should not start") + ), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert expected in result.output + assert not ( + project_dir / ".specify" / "workflows" / "steps" / "my-step" + ).exists() + + @pytest.mark.parametrize( + ("alias", "protected_name"), + [ + ("./step.yml", "step.yml"), + ("step.yml/", "step.yml"), + ("STEP.YML", "step.yml"), + (".\\step.yml", "step.yml"), + ("./__init__.py", "__init__.py"), + ("__init__.py/", "__init__.py"), + ("__INIT__.PY", "__init__.py"), + (".\\__init__.py", "__init__.py"), + ], + ) + def test_add_does_not_overwrite_required_files_through_path_aliases( + self, project_dir, monkeypatch, alias, protected_name + ): + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as auth_http + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + alias_url = "https://example.com/overwrite" + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": {alias: alias_url}, + }, + ) + bodies = { + "https://example.com/step.yml": b"step:\n type_key: my-step\n", + "https://example.com/__init__.py": b"# trusted init\n", + } + requested_urls: list[str] = [] + + class _FakeResponse: + def __init__(self, url): + self.url = url + self.body = bodies[url] + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return self.url + + def read(self, size=-1): + if size < 0: + size = len(self.body) - self.offset + chunk = self.body[self.offset : self.offset + size] + self.offset += len(chunk) + return chunk + + def fake_open_url(url, timeout=30, redirect_validator=None): + requested_urls.append(url) + return _FakeResponse(url) + + monkeypatch.setattr(auth_http, "open_url", fake_open_url) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code == 0, result.output + assert alias_url not in requested_urls + installed_dir = ( + project_dir / ".specify" / "workflows" / "steps" / "my-step" + ) + assert (installed_dir / protected_name).read_bytes() == bodies[ + f"https://example.com/{protected_name}" + ] + + def test_add_rejects_too_many_package_files_before_network( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as auth_http + from specify_cli.workflows.step import _helpers as step_helpers + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(step_helpers, "_MAX_STEP_PACKAGE_FILES", 3) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": { + "one.py": "https://example.com/one.py", + "two.py": "https://example.com/two.py", + }, + }, + ) + monkeypatch.setattr( + auth_http, + "open_url", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("download should not start") + ), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "exceeding the 3-file limit" in result.output + steps_dir = project_dir / ".specify" / "workflows" / "steps" + assert not (steps_dir / "my-step").exists() + assert list(steps_dir.glob("speckit_step_tmp_*")) == [] + + def test_add_rejects_package_over_cumulative_size_and_cleans_staging( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as auth_http + from specify_cli.workflows.step import _helpers as step_helpers + from specify_cli.workflows.step.catalog import StepCatalog + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(step_helpers, "_MAX_STEP_PACKAGE_BYTES", 40) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda self, step_id: { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": { + "helper.py": "https://example.com/helper.py", + }, + }, + ) + + bodies = { + "https://example.com/step.yml": b"step:\n type_key: my-step\n", + "https://example.com/__init__.py": b"# init\n", + "https://example.com/helper.py": b"0123456789", + } + + class _FakeResponse: + def __init__(self, url): + self.url = url + self.body = bodies[url] + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getheader(self, name): + return None + + def geturl(self): + return self.url + + def read(self, size=-1): + if size < 0: + size = len(self.body) - self.offset + chunk = self.body[self.offset : self.offset + size] + self.offset += len(chunk) + return chunk + + monkeypatch.setattr( + auth_http, + "open_url", + lambda url, timeout=30, redirect_validator=None: _FakeResponse(url), + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "add", "my-step"] + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "40-byte total size limit" in result.output + steps_dir = project_dir / ".specify" / "workflows" / "steps" + assert not (steps_dir / "my-step").exists() + assert list(steps_dir.glob("speckit_step_tmp_*")) == [] + + def test_add_rejects_non_string_extra_files_key(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + + def _fake_get_step_info(self, step_id): + return { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": { + 123: "https://example.com/helper.py", + }, + } + + class _FakeResponse: + def __init__(self, url: str): + self.url = url + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + if getattr(self, "_read", False): + return b"" + self._read = True + if self.url.endswith("/step.yml"): + return b"step:\n type_key: my-step\n" + return b"" + + def geturl(self): + return self.url + + def _fake_open_url(url, timeout=30, redirect_validator=None): + return _FakeResponse(url) + + monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) + monkeypatch.setattr(auth_http, "open_url", _fake_open_url) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) + + assert result.exit_code != 0 + assert "non-string path key" in result.output + + @pytest.mark.parametrize( + "rel_path,expected", + [ + ("", "empty or non-string path key"), + (".", "not a valid relative file path"), + ("..", "not a valid relative file path"), + ("sub/../x", "not a valid relative file path"), + ], + ) + def test_add_rejects_invalid_extra_files_path( + self, project_dir, monkeypatch, rel_path, expected + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + + def _fake_get_step_info(self, step_id): + return { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": {rel_path: "https://example.com/helper.py"}, + } + + class _FakeResponse: + def __init__(self, url: str): + self.url = url + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + if getattr(self, "_read", False): + return b"" + self._read = True + if self.url.endswith("/step.yml"): + return b"step:\n type_key: my-step\n" + return b"" + + def geturl(self): + return self.url + + def _fake_open_url(url, timeout=30, redirect_validator=None): + return _FakeResponse(url) + + monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) + monkeypatch.setattr(auth_http, "open_url", _fake_open_url) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) + + assert result.exit_code != 0 + assert expected in result.output + + def test_add_rejects_non_string_extra_files_url(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + from specify_cli.authentication import http as auth_http + + monkeypatch.chdir(project_dir) + + def _fake_get_step_info(self, step_id): + return { + "id": step_id, + "name": "Test Step", + "url": "https://example.com/step.yml", + "init_url": "https://example.com/__init__.py", + "_install_allowed": True, + "extra_files": {"helper.py": None}, + } + + class _FakeResponse: + def __init__(self, url: str): + self.url = url + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + if getattr(self, "_read", False): + return b"" + self._read = True + if self.url.endswith("/step.yml"): + return b"step:\n type_key: my-step\n" + return b"" + + def geturl(self): + return self.url + + def _fake_open_url(url, timeout=30, redirect_validator=None): + return _FakeResponse(url) + + monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) + monkeypatch.setattr(auth_http, "open_url", _fake_open_url) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) + + assert result.exit_code != 0 + assert "empty or non-string URL" in result.output diff --git a/tests/specify_cli/workflows/step/test_command_info.py b/tests/specify_cli/workflows/step/test_command_info.py new file mode 100644 index 0000000000..663b12be47 --- /dev/null +++ b/tests/specify_cli/workflows/step/test_command_info.py @@ -0,0 +1,63 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowStepRichMarkup: + """Step discovery commands render metadata as literal text.""" + + METADATA = { + "id": "[magenta]step-id[/magenta]", + "name": "[red]Step Name[/red]", + "version": "[green]1.0.0[/green]", + "author": "[yellow]Author[/yellow]", + "description": "[blue]Description[/blue]", + } + + def test_info_escapes_catalog_metadata( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog, StepRegistry + + metadata = dict(self.METADATA) + monkeypatch.chdir(project_dir) + monkeypatch.setattr(StepRegistry, "get", lambda _registry, step_id: None) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda _catalog, step_id: metadata, + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "info", metadata["id"]] + ) + + assert result.exit_code == 0, result.output + for value in metadata.values(): + assert value in result.output + + def test_info_escapes_missing_step_id(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog, StepRegistry + + step_id = "[red]missing[/red]" + monkeypatch.chdir(project_dir) + monkeypatch.setattr(StepRegistry, "get", lambda _registry, step_id: None) + monkeypatch.setattr( + StepCatalog, + "get_step_info", + lambda _catalog, step_id: None, + ) + + result = CliRunner().invoke( + app, ["workflow", "step", "info", step_id] + ) + + assert result.exit_code == 1, result.output + assert step_id in result.output diff --git a/tests/specify_cli/workflows/step/test_command_list.py b/tests/specify_cli/workflows/step/test_command_list.py new file mode 100644 index 0000000000..e225eea93d --- /dev/null +++ b/tests/specify_cli/workflows/step/test_command_list.py @@ -0,0 +1,40 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowStepRichMarkup: + """Step discovery commands render metadata as literal text.""" + + METADATA = { + "id": "[magenta]step-id[/magenta]", + "name": "[red]Step Name[/red]", + "version": "[green]1.0.0[/green]", + "author": "[yellow]Author[/yellow]", + "description": "[blue]Description[/blue]", + } + + def test_list_escapes_installed_metadata( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepRegistry + + metadata = dict(self.METADATA) + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + StepRegistry, + "list", + lambda _registry: {metadata["id"]: metadata}, + ) + + result = CliRunner().invoke(app, ["workflow", "step", "list"]) + + assert result.exit_code == 0, result.output + assert metadata["name"] in result.output + assert metadata["id"] in result.output + assert metadata["version"] in result.output diff --git a/tests/specify_cli/workflows/step/test_command_remove.py b/tests/specify_cli/workflows/step/test_command_remove.py new file mode 100644 index 0000000000..a6bd5781c0 --- /dev/null +++ b/tests/specify_cli/workflows/step/test_command_remove.py @@ -0,0 +1,94 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import os + +import pytest + + + +class TestWorkflowStepRemoveCLI: + """Test the 'specify workflow step remove' CLI command edge cases.""" + + def test_remove_orphaned_directory(self, project_dir, monkeypatch): + """step remove works when directory exists but registry entry is missing. + + This covers the case where the registry was reset due to corruption. + """ + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + + # Create an orphaned step directory (no registry entry) + step_dir = project_dir / ".specify" / "workflows" / "steps" / "orphan-step" + step_dir.mkdir(parents=True) + (step_dir / "step.yml").write_text( + "step:\n type_key: orphan-step\n", encoding="utf-8" + ) + (step_dir / "__init__.py").write_text("", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "remove", "orphan-step"]) + + assert result.exit_code == 0, result.output + assert not step_dir.exists() + # Warning should be printed about missing registry entry + assert "Warning" in result.output or "warning" in result.output.lower() + + def test_remove_not_installed(self, project_dir, monkeypatch): + """step remove fails cleanly when neither directory nor registry entry exist.""" + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "remove", "ghost-step"]) + + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_remove_registered_step(self, project_dir, monkeypatch): + """step remove works normally when both directory and registry entry exist.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepRegistry + + monkeypatch.chdir(project_dir) + + # Set up a registered step with a directory + registry = StepRegistry(project_dir) + registry.add("my-step", {"name": "My Step", "type_key": "my-step", "version": "1.0.0"}) + step_dir = project_dir / ".specify" / "workflows" / "steps" / "my-step" + step_dir.mkdir(parents=True) + (step_dir / "step.yml").write_text( + "step:\n type_key: my-step\n", encoding="utf-8" + ) + (step_dir / "__init__.py").write_text("", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "remove", "my-step"]) + + assert result.exit_code == 0, result.output + assert not step_dir.exists() + registry2 = StepRegistry(project_dir) + assert not registry2.is_installed("my-step") + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_remove_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + outside = project_dir.parent / "outside-steps" + outside.mkdir(parents=True, exist_ok=True) + steps_link = project_dir / ".specify" / "workflows" / "steps" + steps_link.symlink_to(outside, target_is_directory=True) + + runner = CliRunner() + result = runner.invoke(app, ["workflow", "step", "remove", "my-step"]) + + assert result.exit_code != 0 + assert "Refusing to use symlinked step directory" in result.output diff --git a/tests/specify_cli/workflows/step/test_command_search.py b/tests/specify_cli/workflows/step/test_command_search.py new file mode 100644 index 0000000000..bd9982da0c --- /dev/null +++ b/tests/specify_cli/workflows/step/test_command_search.py @@ -0,0 +1,39 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowStepRichMarkup: + """Step discovery commands render metadata as literal text.""" + + METADATA = { + "id": "[magenta]step-id[/magenta]", + "name": "[red]Step Name[/red]", + "version": "[green]1.0.0[/green]", + "author": "[yellow]Author[/yellow]", + "description": "[blue]Description[/blue]", + } + + def test_search_escapes_catalog_metadata( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.step.catalog import StepCatalog + + metadata = dict(self.METADATA) + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + StepCatalog, "search", lambda _catalog, query=None: [metadata] + ) + + result = CliRunner().invoke(app, ["workflow", "step", "search"]) + + assert result.exit_code == 0, result.output + assert metadata["name"] in result.output + assert metadata["id"] in result.output + assert metadata["version"] in result.output + assert metadata["description"] in result.output diff --git a/tests/specify_cli/workflows/test_command_add.py b/tests/specify_cli/workflows/test_command_add.py new file mode 100644 index 0000000000..263fe79add --- /dev/null +++ b/tests/specify_cli/workflows/test_command_add.py @@ -0,0 +1,3206 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import tarfile +import tempfile +import zipfile +from pathlib import Path + +import pytest +import typer +import yaml + +from typer.testing import CliRunner + +from specify_cli import app + +runner = CliRunner() + + + +class TestWorkflowAddCaseInsensitiveSuffix: + """`workflow add` must detect a local YAML file case-insensitively, matching + `workflow run` (_commands.py:workflow_run) and the engine loader + (engine.py:WorkflowEngine.load_workflow), which both use `.suffix.lower()`. + Without it, `workflow run Sample.YAML` works but `workflow add Sample.YAML` + fails — an add/run inconsistency for an uppercase extension.""" + + def test_plain_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml): + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + src = temp_dir / "Sample.YAML" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + # Before the fix: `.suffix in (...)` is case-sensitive, so ".YAML" is not + # recognized as a local file; the path falls through to catalog lookup + # and fails. After the fix it installs like the lowercase happy path. + assert result.exit_code == 0, result.output + assert "installed" in result.output + + def test_dev_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml): + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + src = temp_dir / "Sample.YAML" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", "--dev", str(src)]) + + # Before the fix the --dev branch rejects ".YAML" with + # "--dev source must be a workflow YAML file ...". + assert result.exit_code == 0, result.output + assert "installed" in result.output + + def test_lowercase_extension_still_installs(self, temp_dir, monkeypatch, sample_workflow_yaml): + """Happy path (lowercase .yml) is unchanged by the case-normalization.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + src = temp_dir / "sample.yml" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code == 0, result.output + assert "installed" in result.output + + def test_add_installs_workflow_with_custom_step(self, temp_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + step_dir = temp_dir / ".specify" / "workflows" / "steps" / "test-add-step" + step_dir.mkdir(parents=True) + step_manifest = { + "schema_version": "1.0", + "step": { + "type_key": "test-add-step", + "name": "Test Add Step", + "version": "1.0.0", + }, + } + (step_dir / "step.yml").write_text( + yaml.safe_dump(step_manifest, sort_keys=False), + encoding="utf-8", + ) + (step_dir / "__init__.py").write_text( + """ +from specify_cli.workflows.base import StepBase, StepResult + + +class TestAddStep(StepBase): + type_key = "test-add-step" + + def execute(self, config, context): + return StepResult() +""", + encoding="utf-8", + ) + + src = temp_dir / "sample.yml" + workflow_definition = { + "schema_version": "1.0", + "workflow": { + "id": "test-workflow-with-custom-step", + "name": "Test Workflow With Custom Step", + "version": "1.0.0", + }, + "steps": [ + {"id": "custom-step", "type": "test-add-step"}, + ], + } + src.write_text( + yaml.safe_dump(workflow_definition, sort_keys=False), + encoding="utf-8", + ) + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code == 0, result.output + installed_workflow = ( + temp_dir + / ".specify" + / "workflows" + / "test-workflow-with-custom-step" + / "workflow.yml" + ) + assert installed_workflow.is_file() + + + +class TestWorkflowAddUrlResolution: + """CLI-level tests for workflow add GitHub release URL resolution.""" + + VALID_WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "test-wf" + name: "Test Workflow" + version: "1.0.0" + description: "A test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def test_workflow_add_from_github_release_url_resolves_and_downloads(self, project_dir): + """'workflow add ' resolves to API asset URL.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._pos = 0 + self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42" + + def read(self, size=-1): + if size < 0: + size = len(self._data) - self._pos + out = self._data[self._pos : self._pos + size] + self._pos += len(out) + return out + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append( + (url, extra_headers, timeout, redirect_validator) + ) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(self.VALID_WORKFLOW_YAML.encode()) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "workflow", "add", + "https://github.com/org/repo/releases/download/v1.0/workflow.yml", + ]) + + assert result.exit_code == 0, result.output + assert "Test Workflow" in result.output + # First call resolves the release tag with timeout=30 + tag_calls = [ + (url, headers, timeout, validator) + for url, headers, timeout, validator in captured_urls + if "releases/tags/" in url + ] + assert len(tag_calls) == 1 + assert tag_calls[0][2] == 30 # timeout matches download timeout + assert tag_calls[0][3] is not None + # Second call downloads from the resolved asset URL with octet-stream + asset_calls = [ + (url, headers, timeout, validator) + for url, headers, timeout, validator in captured_urls + if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_from_direct_api_asset_url_passes_through(self, project_dir): + """'workflow add ' uses URL directly with octet-stream.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._pos = 0 + self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42" + + def read(self, size=-1): + if size < 0: + size = len(self._data) - self._pos + out = self._data[self._pos : self._pos + size] + self._pos += len(out) + return out + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + return FakeResponse(self.VALID_WORKFLOW_YAML.encode()) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "workflow", "add", + "https://api.github.com/repos/org/repo/releases/assets/42", + ]) + + assert result.exit_code == 0, result.output + # Should go directly to the asset URL with Accept header + assert len(captured_urls) == 1 + assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" + assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_catalog_based_resolves_github_release_url(self, project_dir): + """'workflow add ' with catalog GitHub release URL resolves via API.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._pos = 0 + self._url = url or "https://api.github.com/repos/org/repo/releases/assets/55" + + def read(self, size=-1): + if size < 0: + size = len(self._data) - self._pos + out = self._data[self._pos : self._pos + size] + self._pos += len(out) + return out + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers, redirect_validator)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://api.github.com/repos/org/repo/releases/assets/55"}] + }).encode()) + # Use workflow YAML with id matching catalog key + wf_yaml = """ +schema_version: "1.0" +workflow: + id: "my-wf" + name: "My Workflow" + version: "1.0.0" + description: "A catalog workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + return FakeResponse(wf_yaml.encode()) + + fake_catalog_info = { + "id": "my-wf", + "name": "My Workflow", + "version": "1.0.0", + "url": "https://github.com/org/repo/releases/download/v2.0/workflow.yml", + "_install_allowed": True, + } + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info): + result = runner.invoke(app, ["workflow", "add", "my-wf"]) + + assert result.exit_code == 0, result.output + # Should resolve via releases/tags API + tag_calls = [ + (url, validator) + for url, _, validator in captured_urls + if "releases/tags/" in url + ] + assert len(tag_calls) == 1 + assert "releases/tags/v2.0" in tag_calls[0][0] + assert tag_calls[0][1] is not None + # Should download from resolved asset URL with octet-stream + asset_calls = [ + (url, headers) + for url, headers, _ in captured_urls + if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._pos = 0 + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42" + + def read(self, size=-1): + if size < 0: + size = len(self._data) - self._pos + out = self._data[self._pos : self._pos + size] + self._pos += len(out) + return out + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(self.VALID_WORKFLOW_YAML.encode()) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "workflow", "add", + "https://ghes.example/org/repo/releases/download/v1.0/workflow.yml", + ]) + + assert result.exit_code == 0, result.output + # Tag lookup must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_catalog_based_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' with a GHES catalog URL resolves via /api/v3.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._pos = 0 + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55" + + def read(self, size=-1): + if size < 0: + size = len(self._data) - self._pos + out = self._data[self._pos : self._pos + size] + self._pos += len(out) + return out + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + ghes_wf_yaml = """ +schema_version: "1.0" +workflow: + id: "my-wf" + name: "My GHES Workflow" + version: "1.0.0" + description: "A GHES catalog workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"}] + }).encode()) + return FakeResponse(ghes_wf_yaml.encode()) + + fake_catalog_info = { + "id": "my-wf", + "name": "My GHES Workflow", + "version": "1.0.0", + "url": "https://ghes.example/org/repo/releases/download/v2.0/workflow.yml", + "_install_allowed": True, + } + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info): + result = runner.invoke(app, ["workflow", "add", "my-wf"]) + + assert result.exit_code == 0, result.output + # Tag lookup must use GHES /api/v3 + tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert "ghes.example/api/v3/repos/org/repo/releases/tags/v2.0" in tag_calls[0] + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + + +class TestWorkflowAddNonStringScalars: + """`workflow add` reports clean errors for non-string YAML scalars (#3420).""" + + @pytest.mark.parametrize( + ("field_yaml", "expected"), + [ + ('id: 123\n name: "Probe"\n version: "1.0.0"', "workflow.id"), + ('id: "probe"\n name: "Probe"\n version: 1.0', "workflow.version"), + ], + ) + def test_add_reports_validation_error_not_traceback( + self, project_dir, monkeypatch, field_yaml, expected + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + wf = project_dir / "workflow.yml" + wf.write_text( + "schema_version: \"1.0\"\n" + f"workflow:\n {field_yaml}\n" + "steps:\n - id: s1\n type: shell\n run: \"echo hi\"\n", + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(wf)]) + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert expected in result.output + + def test_add_non_string_step_id_reports_validation_error( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + wf = project_dir / "workflow.yml" + wf.write_text( + "workflow:\n id: \"probe\"\n name: \"Probe\"\n version: \"1.0.0\"\n" + "steps:\n - id: 123\n type: shell\n run: \"echo hi\"\n", + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(wf)]) + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Step ID" in result.output + + + +class TestWorkflowAddSymlinkGuard: + def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch): + """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify").mkdir(exist_ok=True) + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke( + app, + ["workflow", "add", "https://[::1/wf.yaml"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Invalid URL" in result.output + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch): + """workflow add must refuse a symlinked .specify (writes could escape root).""" + from typer.testing import CliRunner + from specify_cli import app + + outside = temp_dir.parent / "outside-specify-target" + (outside / "workflows").mkdir(parents=True, exist_ok=True) + (temp_dir / ".specify").symlink_to(outside, target_is_directory=True) + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"]) + + assert result.exit_code != 0 + assert "symlinked .specify" in result.output + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_add_refuses_symlinked_workflows_dir(self, temp_dir, monkeypatch): + """workflow add must refuse a symlinked .specify/workflows directory.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify").mkdir() + outside = temp_dir.parent / "outside-workflows-target" + outside.mkdir(parents=True, exist_ok=True) + (temp_dir / ".specify" / "workflows").symlink_to(outside, target_is_directory=True) + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"]) + + assert result.exit_code != 0 + assert "symlinked .specify/workflows" in result.output + + def test_add_escapes_rich_markup_in_validation_errors(self, temp_dir, monkeypatch): + """User-controlled YAML values in validation errors must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + src = temp_dir / "incoming.yml" + src.write_text( + """ +schema_version: "1.0" +workflow: + id: "markup-wf" + name: "Markup" + version: "[bold]bad[/bold]" + +steps: + - id: step-one + command: speckit.specify +""", + encoding="utf-8", + ) + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code != 0 + assert "[bold]bad[/bold]" in result.output + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_add_refuses_symlinked_id_dir(self, temp_dir, monkeypatch, sample_workflow_yaml): + """A symlinked install dir must not let a copy escape the project root.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + outside = temp_dir.parent / "outside-id-target" + outside.mkdir(parents=True, exist_ok=True) + # from the YAML below is "test-workflow"; plant it as a symlink. + (temp_dir / ".specify" / "workflows" / "test-workflow").symlink_to( + outside, target_is_directory=True + ) + src = temp_dir / "incoming.yml" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code != 0 + # No write-through: the symlink target stays empty. + assert not (outside / "workflow.yml").exists() + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_add_refuses_symlinked_workflow_yml_leaf(self, temp_dir, monkeypatch, sample_workflow_yaml): + """A symlinked /workflow.yml must not let copy2 write through the link.""" + from typer.testing import CliRunner + from specify_cli import app + + id_dir = temp_dir / ".specify" / "workflows" / "test-workflow" + id_dir.mkdir(parents=True) + outside_file = temp_dir.parent / "outside-leaf-target.yml" + outside_file.write_text("original\n", encoding="utf-8") + (id_dir / "workflow.yml").symlink_to(outside_file) + src = temp_dir / "incoming.yml" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code != 0 + # Rich may wrap the message; assert on the unbroken path fragment. + assert "test-workflow/workflow.yml" in result.output + assert "symlinked" in result.output + # The link target content is untouched. + assert outside_file.read_text(encoding="utf-8") == "original\n" + + def test_add_refuses_non_directory_id(self, temp_dir, monkeypatch, sample_workflow_yaml): + """An path that already exists as a file must fail cleanly, not crash.""" + from typer.testing import CliRunner + from specify_cli import app + + wf_dir = temp_dir / ".specify" / "workflows" + wf_dir.mkdir(parents=True) + (wf_dir / "test-workflow").write_text("not a dir", encoding="utf-8") + src = temp_dir / "incoming.yml" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code != 0 + assert "exists but is not a directory" in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + def test_add_refuses_workflow_yml_as_directory(self, temp_dir, monkeypatch, sample_workflow_yaml): + """A pre-existing /workflow.yml *directory* must fail cleanly, not crash.""" + from typer.testing import CliRunner + from specify_cli import app + + id_dir = temp_dir / ".specify" / "workflows" / "test-workflow" + id_dir.mkdir(parents=True) + # Plant workflow.yml as a directory so a later write/copy2 would raise + # IsADirectoryError without the explicit non-file guard. + (id_dir / "workflow.yml").mkdir() + src = temp_dir / "incoming.yml" + src.write_text(sample_workflow_yaml, encoding="utf-8") + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(src)]) + + assert result.exit_code != 0 + assert "test-workflow/workflow.yml" in result.output + assert "is not a file" in result.output + # Clean exit, not an unhandled IsADirectoryError traceback. + assert result.exception is None or isinstance(result.exception, SystemExit) + + def test_safe_workflow_id_dir_escapes_markup_in_invalid_id(self, temp_dir, capsys): + """A traversal carrying Rich markup must be escaped, not interpreted.""" + from specify_cli.workflows._commands import _safe_workflow_id_dir + + workflows_dir = temp_dir / ".specify" / "workflows" + workflows_dir.mkdir(parents=True) + # Traversal (so the "Invalid workflow ID" branch fires) plus markup. + with pytest.raises(typer.Exit): + _safe_workflow_id_dir(workflows_dir, "../[red]evil[/red]") + + out = capsys.readouterr().out + # Literal bracketed text survives; Rich did not consume it as a tag. + assert "[red]evil[/red]" in out + + def test_add_rejects_reserved_overlay_storage_id(self, temp_dir, monkeypatch): + """workflow add must not install into the overlay storage directory.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + overlay_file = temp_dir / "incoming.yml" + overlay_file.write_text( + """ +schema_version: "1.0" +workflow: + id: "overlays" + name: "Bad Workflow" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""".strip() + + "\n", + encoding="utf-8", + ) + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "add", str(overlay_file)]) + + assert result.exit_code != 0 + assert "Invalid workflow ID" in result.output + assert not (temp_dir / ".specify" / "workflows" / "overlays" / "workflow.yml").exists() + + @pytest.mark.parametrize( + "workflow_id", + [ + "overlays", + "runs", + "steps", + "nested/workflow", + "nested\\workflow", + "bad id", + " bad-id", + "bad-id ", + ], + ) + def test_safe_workflow_id_dir_rejects_reserved_or_non_segment_ids( + self, temp_dir, workflow_id, capsys + ): + """Install IDs must not collide with workflow internals or create nested paths.""" + from specify_cli.workflows._commands import _safe_workflow_id_dir + + workflows_dir = temp_dir / ".specify" / "workflows" + workflows_dir.mkdir(parents=True) + + with pytest.raises(typer.Exit): + _safe_workflow_id_dir(workflows_dir, workflow_id) + + assert "Invalid workflow ID" in capsys.readouterr().out + assert not (workflows_dir / workflow_id).exists() + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "align-wf" + name: "Align Workflow" + version: "{version}" + description: "CLI alignment test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def _write_workflow_dir(self, base, version="1.0.0"): + d = base / "wf-src" + d.mkdir(parents=True, exist_ok=True) + (d / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version=version), encoding="utf-8" + ) + return d + + def _archive_workflow_dir(self, source_dir, archive_path, nested=False): + prefix = Path("align-wf-v1") if nested else Path() + if archive_path.name.lower().endswith(".zip"): + with zipfile.ZipFile(archive_path, "w") as archive: + for file_path in source_dir.rglob("*"): + if file_path.is_file(): + archive.write( + file_path, + prefix / file_path.relative_to(source_dir), + ) + else: + with tarfile.open(archive_path, "w:gz") as archive: + for file_path in source_dir.rglob("*"): + if file_path.is_file(): + archive.add( + file_path, + arcname=prefix / file_path.relative_to(source_dir), + ) + + def _install_dev(self, runner, app, project_dir): + src = self._write_workflow_dir(project_dir) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + assert result.exit_code == 0, result.output + return src + + class _FakeResponse: + def __init__(self, data, url="https://example.com/workflow.yml", headers=None): + self._data = data + self._url = url + self._pos = 0 + self._headers = headers or {} + + def read(self, amt=None): + if amt is None: + chunk = self._data[self._pos :] + self._pos = len(self._data) + return chunk + chunk = self._data[self._pos : self._pos + amt] + self._pos += len(chunk) + return chunk + + def getheader(self, name, default=None): + return self._headers.get(name, default) + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def test_add_dev_directory_installs(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + assert WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_local_directory_preserves_package_files( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "scripts").mkdir() + (source / "scripts" / "helper.sh").write_text("echo helper\n") + + result = CliRunner().invoke(app, ["workflow", "add", str(source)]) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert (installed / "scripts" / "helper.sh").read_text() == "echo helper\n" + + @pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"]) + @pytest.mark.parametrize("nested", [False, True]) + def test_add_local_archive_preserves_package_files( + self, project_dir, monkeypatch, suffix, nested + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "assets").mkdir() + (source / "assets" / "message.txt").write_text("hello\n") + archive_path = project_dir / f"align-wf{suffix}" + self._archive_workflow_dir(source, archive_path, nested=nested) + + result = CliRunner().invoke(app, ["workflow", "add", str(archive_path)]) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert (installed / "assets" / "message.txt").read_text() == "hello\n" + + def test_add_dev_yaml_file_installs(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + src = self._write_workflow_dir(project_dir) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(src / "workflow.yml"), "--dev"]) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_dev_missing_path_errors(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(project_dir / "missing"), "--dev"]) + assert result.exit_code != 0 + assert "--dev" in result.output + + def test_add_dev_dir_without_workflow_yml_errors(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + empty = project_dir / "empty-src" + empty.mkdir() + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(empty), "--dev"]) + assert result.exit_code != 0 + assert "No workflow.yml found" in result.output + + def test_add_local_dir_without_workflow_yml_errors(self, project_dir, monkeypatch): + """Same as the --dev case, but for the plain local-path fallback (no --dev).""" + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + empty = project_dir / "empty-src-[bracket]" + empty.mkdir() + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(empty)]) + assert result.exit_code != 0 + assert "No workflow.yml found" in result.output + assert "[bracket]" in result.output + + def test_add_local_dir_with_workflow_yml_directory_errors_cleanly(self, project_dir, monkeypatch): + """Same as the --dev case, but for the plain local-path fallback (no --dev): + a directory named workflow.yml must not reach open() and leak IsADirectoryError.""" + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + src_dir = project_dir / "local-wf" + (src_dir / "workflow.yml").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(src_dir)]) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "No workflow.yml found" in result.output + + def test_add_yaml_parse_error_escapes_rich_markup(self, project_dir, monkeypatch): + """A YAML syntax error can quote the offending line verbatim; brackets in it must not be Rich markup.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.engine import WorkflowDefinition + + monkeypatch.chdir(project_dir) + bad = project_dir / "bad.yml" + bad.write_text("workflow:\n id: wf\n", encoding="utf-8") + runner = CliRunner() + with patch.object( + WorkflowDefinition, + "from_string", + side_effect=ValueError('bad snippet: "New [Feature]"'), + ): + result = runner.invoke(app, ["workflow", "add", str(bad)]) + assert result.exit_code != 0 + assert 'bad snippet: "New [Feature]"' in result.output + + @pytest.mark.parametrize("mode", ["dev", "local", "from"]) + def test_reinstall_preserves_disabled_state( + self, project_dir, monkeypatch, mode + ): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + + if mode == "dev": + result = runner.invoke( + app, ["workflow", "add", str(src), "--dev"] + ) + elif mode == "local": + result = runner.invoke(app, ["workflow", "add", str(src)]) + else: + data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(data, url), + ): + result = runner.invoke( + app, + [ + "workflow", "add", "align-wf", + "--from", "https://example.com/workflow.yml", + ], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).get("align-wf")["enabled"] is False + + def test_add_from_url_rejects_oversized_content_length(self, project_dir, monkeypatch): + """A --from download must not trust an advertised Content-Length + alone by reading the whole body first -- it must reject a response + that declares a size over the workflow YAML limit before reading + the (potentially huge) body into memory at all.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + small_body = b"id: align-wf\n" # small actual body; Content-Length lies + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + small_body, url, headers={"Content-Length": "1000"} + ), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "exceedingthe100-byteworkflowsizelimit" in "".join(result.output.split()) + + def test_add_from_url_requires_default_deny_confirmation( + self, project_dir, monkeypatch + ): + from unittest.mock import patch + + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + with patch( + "specify_cli.authentication.http.open_url", + side_effect=AssertionError("download should not start"), + ): + result = CliRunner().invoke( + app, + [ + "workflow", + "add", + "align-wf", + "--from", + "https://example.com/workflow.yml", + ], + input="n\n", + ) + + assert result.exit_code == 0, result.output + assert "Untrusted Source" in result.output + assert "Cancelled" in result.output + + def test_add_from_url_rejects_oversized_streamed_body_without_content_length( + self, project_dir, monkeypatch + ): + """A chunked/no-Content-Length response must still be capped by + actually counting streamed bytes -- a malicious or misbehaving + server cannot bypass the limit merely by omitting or lying about + Content-Length.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + oversized_body = b"x" * 500 # no Content-Length header at all + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + oversized_body, url + ), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "exceedsthe100-byteworkflowsizelimit" in "".join(result.output.split()) + + def test_add_from_url_oversized_streamed_body_leaves_no_temp_file( + self, project_dir, monkeypatch, tmp_path + ): + """A rejected --from download (oversized streamed body, no + Content-Length) must not leave the 0-byte NamedTemporaryFile behind: + the file is created on disk as soon as it is opened (delete=False), + before any bytes are written, so a failure inside the size-limit + check must still clean it up rather than merely erroring out.""" + import tempfile as tempfile_mod + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + scratch_tmp = tmp_path / "scratch-tmp" + scratch_tmp.mkdir() + monkeypatch.setattr(tempfile_mod, "tempdir", str(scratch_tmp)) + oversized_body = b"x" * 500 # no Content-Length header at all + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + oversized_body, url + ), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert "exceedsthe100-byteworkflowsizelimit" in "".join(result.output.split()) + leaked = list(scratch_tmp.glob("*.yml")) + assert leaked == [], f"leaked temp files: {leaked}" + + def test_add_from_url_interrupt_during_read_leaves_no_temp_file( + self, project_dir, monkeypatch, tmp_path + ): + """A KeyboardInterrupt while streaming the response body must still + unlink the already-created (delete=False) temp file. Unlike a + download ``ValueError``, ``KeyboardInterrupt`` is a ``BaseException`` + and is not caught by ``except Exception`` -- only a ``BaseException`` + handler around the temp-file lifetime can clean it up.""" + import tempfile as tempfile_mod + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + + monkeypatch.chdir(project_dir) + scratch_tmp = tmp_path / "scratch-tmp" + scratch_tmp.mkdir() + monkeypatch.setattr(tempfile_mod, "tempdir", str(scratch_tmp)) + + def _boom(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(wf_commands, "_read_response_within_limit", _boom) + body = b"id: align-wf\n" + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + body, url + ), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + leaked = list(scratch_tmp.glob("*.yml")) + assert leaked == [], f"leaked temp files: {leaked}" + + def test_add_from_url_oversized_content_length_leaves_no_temp_file( + self, project_dir, monkeypatch, tmp_path + ): + """Same guarantee for the fail-fast Content-Length rejection path: + it must not even leave a 0-byte temp file behind.""" + import tempfile as tempfile_mod + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + scratch_tmp = tmp_path / "scratch-tmp" + scratch_tmp.mkdir() + monkeypatch.setattr(tempfile_mod, "tempdir", str(scratch_tmp)) + small_body = b"id: align-wf\n" # small actual body; Content-Length lies + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + small_body, url, headers={"Content-Length": "1000"} + ), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert "exceedingthe100-byteworkflowsizelimit" in "".join(result.output.split()) + leaked = list(scratch_tmp.glob("*.yml")) + assert leaked == [], f"leaked temp files: {leaked}" + + def test_add_from_url_download_failure_cleanup_error_preserves_original_error( + self, project_dir, monkeypatch, tmp_path + ): + """The --from download-failure branch's `tmp_path.unlink(missing_ok= + True)` can itself raise (e.g. read-only tempdir) before the clean + "Failed to download workflow" message is ever printed, replacing it + with a raw unhandled OSError. A cleanup failure there must be + guarded exactly like the later post-install finally cleanup: warn + about the cleanup failure, then still preserve/report the original + download error via a clean typer.Exit, never a raw traceback.""" + import tempfile as tempfile_mod + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + scratch_tmp = tmp_path / "scratch-tmp" + scratch_tmp.mkdir() + monkeypatch.setattr(tempfile_mod, "tempdir", str(scratch_tmp)) + oversized_body = b"x" * 500 # no Content-Length header at all + runner = CliRunner() + + real_unlink = Path.unlink + + def unlink_boom(self_path, *args, **kwargs): + if self_path.suffix == ".yml" and self_path.parent == scratch_tmp: + raise OSError("cleanup denied") + return real_unlink(self_path, *args, **kwargs) + + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + oversized_body, url + ), + ), pytest.MonkeyPatch.context() as mp: + mp.setattr(Path, "unlink", unlink_boom) + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + # Original download error remains present. Normalize whitespace so the + # assertion is robust to Rich line-wrapping at narrow terminal widths. + normalized_output = "".join(result.output.split()) + assert "exceedsthe100-byteworkflowsizelimit" in normalized_output + # Cleanup failure is reported too, not silently swallowed / crashing. + assert "cleanupdenied" in normalized_output + assert "Warning" in result.output + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_from_url_installs(self, project_dir, monkeypatch): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).is_installed("align-wf") + + @pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"]) + def test_add_from_url_installs_complete_archive_package( + self, project_dir, monkeypatch, suffix + ): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "assets").mkdir() + (source / "assets" / "remote.txt").write_text("remote\n") + archive_path = project_dir / f"remote{suffix}" + self._archive_workflow_dir(source, archive_path) + data = archive_path.read_bytes() + url = f"https://example.com/align-wf{suffix}" + + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda *_args, **_kwargs: self._FakeResponse(data, url), + ): + result = CliRunner().invoke( + app, + ["workflow", "add", "align-wf", "--from", url], + input="y\n", + ) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert (installed / "assets" / "remote.txt").read_text() == "remote\n" + + @pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"]) + def test_add_from_suffixless_url_sniffs_archive( + self, project_dir, monkeypatch, suffix + ): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "assets").mkdir() + (source / "assets" / "sniffed.txt").write_text("sniffed\n") + archive_path = project_dir / f"remote{suffix}" + self._archive_workflow_dir(source, archive_path) + data = archive_path.read_bytes() + url = "https://example.com/assets/12345" + + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda *_args, **_kwargs: self._FakeResponse( + data, + url, + {"Content-Type": "application/octet-stream"}, + ), + ): + result = CliRunner().invoke( + app, + ["workflow", "add", "align-wf", "--from", url], + input="y\n", + ) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert (installed / "assets" / "sniffed.txt").read_text() == "sniffed\n" + + @pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"]) + def test_add_catalog_installs_complete_archive_package_and_sha( + self, project_dir, monkeypatch, suffix + ): + import hashlib + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "assets").mkdir() + (source / "assets" / "catalog.txt").write_text("catalog\n") + archive_path = project_dir / f"catalog{suffix}" + self._archive_workflow_dir(source, archive_path, nested=True) + data = archive_path.read_bytes() + url = f"https://example.com/align-wf{suffix}" + info = { + "id": "align-wf", + "name": "Align Workflow", + "version": "1.0.0", + "url": url, + "sha256": hashlib.sha256(data).hexdigest(), + "_install_allowed": True, + "_catalog_name": "test", + } + + with patch.object( + WorkflowCatalog, + "get_workflow_info", + return_value=info, + ), patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda *_args, **_kwargs: self._FakeResponse(data, url), + ): + result = CliRunner().invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert (installed / "assets" / "catalog.txt").read_text() == "catalog\n" + + @pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"]) + def test_add_catalog_sniffs_suffixless_archive( + self, project_dir, monkeypatch, suffix + ): + import hashlib + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + (source / "assets").mkdir() + (source / "assets" / "sniffed.txt").write_text("catalog sniffed\n") + archive_path = project_dir / f"catalog{suffix}" + self._archive_workflow_dir(source, archive_path, nested=True) + data = archive_path.read_bytes() + url = "https://example.com/assets/67890" + info = { + "id": "align-wf", + "name": "Align Workflow", + "version": "1.0.0", + "url": url, + "sha256": hashlib.sha256(data).hexdigest(), + "_install_allowed": True, + "_catalog_name": "test", + } + + with patch.object( + WorkflowCatalog, + "get_workflow_info", + return_value=info, + ), patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda *_args, **_kwargs: self._FakeResponse( + data, + url, + {"Content-Type": "application/octet-stream"}, + ), + ): + result = CliRunner().invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code == 0, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert ( + installed / "assets" / "sniffed.txt" + ).read_text() == "catalog sniffed\n" + + def test_package_registry_failure_restores_before_failed_cleanup( + self, project_dir, monkeypatch + ): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir, version="1.0.0") + (source / "assets").mkdir() + (source / "assets" / "version.txt").write_text("old\n") + runner = CliRunner() + first = runner.invoke(app, ["workflow", "add", str(source)]) + assert first.exit_code == 0, first.output + + (source / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), + encoding="utf-8", + ) + (source / "assets" / "version.txt").write_text("new\n") + real_rmtree = shutil.rmtree + + def fail_failed_package_cleanup(path, *args, **kwargs): + if ".failed-" in Path(path).name: + raise OSError("cleanup denied") + return real_rmtree(path, *args, **kwargs) + + with patch.object( + WorkflowRegistry, + "add", + side_effect=OSError("registry save failed"), + ), patch( + "shutil.rmtree", + side_effect=fail_failed_package_cleanup, + ): + result = runner.invoke(app, ["workflow", "add", str(source)]) + + assert result.exit_code == 1, result.output + installed = project_dir / ".specify" / "workflows" / "align-wf" + assert "1.0.0" in (installed / "workflow.yml").read_text() + assert (installed / "assets" / "version.txt").read_text() == "old\n" + assert "registry save failed" in result.output + assert "cleanup denied" in result.output + + def test_add_from_url_temp_cleanup_failure_after_success_still_exits_zero( + self, project_dir, monkeypatch + ): + """An OSError while deleting the --from download's temp file after + _validate_and_install_local() has already committed the file and + registry entry must not surface as an unhandled failure for an + install that already succeeded -- it must be a warning, exit 0.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + + + real_unlink = Path.unlink + + def unlink_boom(self_path, *args, **kwargs): + if self_path.suffix == ".yml" and self_path.parent == Path(tempfile.gettempdir()): + raise OSError("permission denied") + return real_unlink(self_path, *args, **kwargs) + + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ), pytest.MonkeyPatch.context() as mp: + mp.setattr(Path, "unlink", unlink_boom) + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + + assert result.exit_code == 0, result.output + assert "Warning" in result.output + assert "permissiondenied" in "".join(result.output.split()) + assert WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_from_url_id_mismatch_errors(self, project_dir, monkeypatch): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ): + result = runner.invoke( + app, + ["workflow", "add", "other-id", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert "does not match" in result.output + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_from_empty_url_rejected_not_catalog_fallback(self, project_dir, monkeypatch): + """--from "" must fail URL validation, not silently install from the catalog.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", "align-wf", "--from", ""]) + assert result.exit_code != 0 + assert "HTTPS" in result.output + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_from_url_non_https_redirect_escapes_rich_markup(self, project_dir, monkeypatch): + """A redirect to a non-HTTPS IPv6 literal (legally bracketed) must not be parsed as Rich markup.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + redirected_url = "http://[2001:db8::1]/workflow.yml" + runner = CliRunner() + with patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(b"", redirected_url), + ): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code != 0 + assert redirected_url in result.output + + def test_add_from_rejects_invalid_source_id_without_fetch(self, project_dir, monkeypatch): + """--from with a non-workflow-id source (URL, path, uppercase) fails before any network fetch.""" + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + calls: list[str] = [] + + def _fake_open(url, timeout=None, extra_headers=None, redirect_validator=None): + calls.append(url) + raise AssertionError(f"network fetch attempted: {url}") + + runner = CliRunner() + with patch("specify_cli.authentication.http.open_url", side_effect=_fake_open): + for bad_source in ("https://x/y.yml", "./local.yml", "BadCase"): + result = runner.invoke( + app, + ["workflow", "add", bad_source, "--from", "https://example.com/workflow.yml"], + ) + assert result.exit_code != 0 + assert "Invalid workflow ID" in result.output + assert calls == [] + + def test_add_dev_dir_with_workflow_yml_directory_errors_cleanly(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + dev_dir = project_dir / "dev-wf" + (dev_dir / "workflow.yml").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", "--dev", str(dev_dir)]) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "No workflow.yml found" in result.output + + @pytest.mark.parametrize("mode", ["dev", "local", "from_url"]) + def test_add_save_failure_leaves_no_orphan_directory(self, project_dir, monkeypatch, mode): + """A registry.add() save failure during a fresh install must not leave + an orphaned workflow directory on disk, and must fail with a clean + escaped message instead of a raw OSError traceback. Shared by --dev, + the plain local-path fallback, and --from since all three funnel + through _validate_and_install_local's single install choke point.""" + import contextlib + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + + def boom(self): + raise OSError("disk full") + + if mode == "from_url": + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + args = ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"] + url_patch = patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ) + else: + src = self._write_workflow_dir(project_dir) + args = ["workflow", "add", str(src)] + (["--dev"] if mode == "dev" else []) + url_patch = contextlib.nullcontext() + + with url_patch, pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke( + app, args, input="y\n" if mode == "from_url" else None + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists() + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + @pytest.mark.parametrize("mode", ["dev", "catalog"]) + def test_add_non_json_description_rolls_back_transaction( + self, project_dir, monkeypatch, mode + ): + import contextlib + from unittest.mock import patch + + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + data = self.WORKFLOW_YAML.format(version="1.0.0").replace( + 'description: "CLI alignment test workflow"', + "description: 2026-01-02", + ).encode() + + if mode == "dev": + source = project_dir / "dated-description" + source.mkdir() + (source / "workflow.yml").write_bytes(data) + args = ["workflow", "add", str(source), "--dev"] + download = contextlib.nullcontext() + else: + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + args = ["workflow", "add", "align-wf"] + download = patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(data, url), + ) + + with download: + result = CliRunner().invoke(app, args) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Failed to update workflow registry" in result.output + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists() + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + @pytest.mark.parametrize("mode", ["dev", "local", "from_url"]) + def test_add_fresh_install_mkstemp_failure_leaves_no_orphan_directory( + self, project_dir, monkeypatch, mode + ): + """_stage_workflow_file() does mkdir(dest_dir) then mkstemp() inside + it. For a fresh install (no prior directory), if mkdir succeeds but + mkstemp then fails (disk full/EMFILE/quota), the freshly-created + empty dest_dir must not be left orphaned -- it must be removed, and + the original mkstemp error must still be reported cleanly.""" + import contextlib + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + + def boom(*args, **kwargs): + raise OSError("disk full") + + if mode == "from_url": + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + args = ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"] + url_patch = patch( + "specify_cli.authentication.http.open_url", + side_effect=lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ) + else: + src = self._write_workflow_dir(project_dir) + args = ["workflow", "add", str(src)] + (["--dev"] if mode == "dev" else []) + url_patch = contextlib.nullcontext() + + with url_patch, pytest.MonkeyPatch.context() as mp: + mp.setattr("tempfile.mkstemp", boom) + result = runner.invoke( + app, args, input="y\n" if mode == "from_url" else None + ) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists(), "fresh-install dest_dir left orphaned after mkstemp failure" + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_reinstall_mkstemp_failure_preserves_preexisting_directory( + self, project_dir, monkeypatch + ): + """A pre-existing (reinstall) dest_dir must never be removed by the + mkstemp-failure cleanup -- only a directory _stage_workflow_file + itself just created.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + installed_yaml = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml" + original_bytes = installed_yaml.read_bytes() + original_registry_entry = WorkflowRegistry(project_dir).get("align-wf") + + (src / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8" + ) + + def boom(*args, **kwargs): + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr("tempfile.mkstemp", boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + assert installed_yaml.parent.is_dir() + assert installed_yaml.read_bytes() == original_bytes + assert WorkflowRegistry(project_dir).get("align-wf") == original_registry_entry + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + @pytest.mark.parametrize("mode", ["dev", "catalog"]) + def test_stage_write_rejects_swapped_symlink( + self, project_dir, monkeypatch, mode + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + victim = project_dir / "victim.txt" + victim.write_text("untouched", encoding="utf-8") + + real_stage = _commands._stage_workflow_file + + def raced_stage(*args, **kwargs): + staged = real_stage(*args, **kwargs) + staged_path = getattr(staged, "path", staged) + staged_path.unlink() + staged_path.symlink_to(victim) + return staged + + monkeypatch.setattr(_commands, "_stage_workflow_file", raced_stage) + + if mode == "dev": + source = self._write_workflow_dir(project_dir) + args = ["workflow", "add", str(source), "--dev"] + else: + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(data, url), + ) + args = ["workflow", "add", "align-wf"] + + result = CliRunner().invoke(app, args) + + assert result.exit_code != 0 + assert victim.read_text(encoding="utf-8") == "untouched" + + def test_local_install_writes_the_same_bytes_it_validates( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + source = self._write_workflow_dir(project_dir) + source_file = source / "workflow.yml" + validated_content = source_file.read_text(encoding="utf-8") + replacement_content = self.WORKFLOW_YAML.format(version="9.9.9") + + real_stage = _commands._stage_workflow_file + + def replace_source_after_validation(*args, **kwargs): + staged = real_stage(*args, **kwargs) + source_file.write_text(replacement_content, encoding="utf-8") + return staged + + monkeypatch.setattr( + _commands, + "_stage_workflow_file", + replace_source_after_validation, + ) + + result = CliRunner().invoke( + app, ["workflow", "add", str(source), "--dev"] + ) + + assert result.exit_code == 0, result.output + installed_file = ( + project_dir + / ".specify" + / "workflows" + / "align-wf" + / "workflow.yml" + ) + assert installed_file.read_text(encoding="utf-8") == validated_content + assert WorkflowRegistry(project_dir).get("align-wf")["version"] == "1.0.0" + + def test_add_fresh_install_staged_discard_cleanup_failure_reports_warning( + self, project_dir, monkeypatch + ): + """A genuine fresh-directory rmdir failure must be reported while + the original copy failure remains the primary error.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._write_workflow_dir(project_dir) + + def copy_boom(self, data): + raise OSError("disk full") + + real_rmdir = Path.rmdir + + def rmdir_boom(path): + if path.name == "align-wf": + raise OSError("cleanup denied") + return real_rmdir(path) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(_commands._StagedWorkflowFile, "write_bytes", copy_boom) + mp.setattr(Path, "rmdir", rmdir_boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + # Original install error remains present and primary. + assert "disk full" in result.output + # Cleanup failure is now reported, not silently swallowed. + assert "cleanup denied" in result.output + assert "Warning" in result.output + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_fresh_install_registry_rollback_cleanup_failure_reports_warning( + self, project_dir, monkeypatch + ): + """A fresh-install rollback directory-removal failure must be + reported while the registry-update error remains primary.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._write_workflow_dir(project_dir) + + def save_boom(self): + raise OSError("registry disk full") + + real_rmdir = Path.rmdir + + def rmdir_boom(path): + if path.name == "align-wf": + raise OSError("cleanup denied") + return real_rmdir(path) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", save_boom) + mp.setattr(Path, "rmdir", rmdir_boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + # Original registry-update error remains present and primary. + assert "registry disk full" in result.output + # Cleanup failure is now reported, not silently swallowed. + assert "cleanup denied" in result.output + assert "Warning" in result.output + + def test_add_dev_reinstall_copy_failure_leaves_prior_file_untouched( + self, project_dir, monkeypatch + ): + """A staged descriptor-copy failure cannot touch the prior installed + workflow or leave a staging file behind.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + installed_yaml = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml" + original_bytes = installed_yaml.read_bytes() + original_registry_entry = WorkflowRegistry(project_dir).get("align-wf") + + # Point --dev at a new version of the same workflow to trigger a + # reinstall (overwrite) rather than a fresh install. + (src / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8" + ) + + def boom(staged, data): + # Simulate a truncating partial write followed by an OSError on + # the reserved staging inode, mirroring disk exhaustion. + os.ftruncate(staged.fd, 0) + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(_commands._StagedWorkflowFile, "write_bytes", boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + assert installed_yaml.read_bytes() == original_bytes + assert WorkflowRegistry(project_dir).get("align-wf") == original_registry_entry + # No orphaned staging file left behind in the workflow directory. + leftovers = [p.name for p in installed_yaml.parent.iterdir() if p.name != "workflow.yml"] + assert leftovers == [] + + def test_add_dev_successful_reinstall_leaves_no_backup_file( + self, project_dir, monkeypatch + ): + """Once registry.add() succeeds, the unique rollback backup must be + discarded rather than left as a permanent orphan sibling.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + workflow_dir = project_dir / ".specify" / "workflows" / "align-wf" + + # Reinstall (overwrite) with a new version -- a successful reinstall, + # not a failure path. + (src / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8" + ) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code == 0, result.output + registry = WorkflowRegistry(project_dir) + assert registry.is_installed("align-wf") + assert registry.get("align-wf")["version"] == "2.0.0" + assert (workflow_dir / "workflow.yml").read_text(encoding="utf-8") == ( + self.WORKFLOW_YAML.format(version="2.0.0") + ) + leftovers = [p.name for p in workflow_dir.iterdir() if p.name != "workflow.yml"] + assert leftovers == [], f"orphan sibling(s) left behind: {leftovers}" + + def test_add_dev_successful_reinstall_backup_cleanup_failure_still_succeeds( + self, project_dir, monkeypatch + ): + """A failure to clean up the now-unneeded backup file after a + successful registry.add() must not turn the already-successful + install into a reported failure: it must be a warning (exit 0), + consistent with workflow_remove's post-commit cleanup semantics.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + + (src / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8" + ) + + real_unlink = Path.unlink + + def unlink_boom(self_path, *args, **kwargs): + if self_path.name.endswith(".bak"): + raise OSError("permission denied") + return real_unlink(self_path, *args, **kwargs) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(Path, "unlink", unlink_boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code == 0, result.output + assert "Warning" in result.output + assert "permissiondenied" in "".join(result.output.split()) + registry = WorkflowRegistry(project_dir) + assert registry.is_installed("align-wf") + assert registry.get("align-wf")["version"] == "2.0.0" + + def test_add_dev_reinstall_restore_failure_reports_warning_and_original_error( + self, project_dir, monkeypatch + ): + """The prior file is now restored via an atomic rename (not a + content rewrite) when registry.add() fails on a reinstall. If that + restore rename itself also fails (e.g. a transient FS issue), it + must not silently claim success or crash with a raw traceback: it + must report a clear warning about the restore failure in addition + to the original clean registry error.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._install_dev(runner, app, project_dir) + + (src / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8" + ) + + def save_boom(self): + raise OSError("disk full") + + real_replace = os.replace + calls = {"n": 0} + + def replace_boom(src_path, dst_path): + # The commit swap for a reinstall makes exactly two os.replace + # calls (backup-aside, then staged-into-dest); let both succeed + # and only fail the third call -- the post-registry-failure + # restore-back rename. + calls["n"] += 1 + if calls["n"] <= 2: + return real_replace(src_path, dst_path) + raise OSError("permission denied") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", save_boom) + mp.setattr(os, "replace", replace_boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + output_compact = "".join(result.output.split()) + assert "Warning" in result.output + assert "diskfull" in output_compact + assert "permissiondenied" in output_compact + + def test_add_dev_fresh_install_into_preexisting_empty_dir_cleans_new_file( + self, project_dir, monkeypatch + ): + """When the destination directory already exists but has no + workflow.yml (e.g. an empty dir left over from elsewhere), a later + registry.add() failure must remove the newly copied file -- the + rollback previously did nothing in this case (existed_before=True + with no backup bytes), leaving the new file behind -- while leaving + the pre-existing directory itself intact.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + src = self._write_workflow_dir(project_dir) + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + dest_dir.mkdir(parents=True) # pre-existing, but empty: no workflow.yml + + def boom(self, *args, **kwargs): + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "add", boom) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + + assert result.exit_code != 0 + assert result.output.strip() != "" + assert dest_dir.is_dir() + assert not (dest_dir / "workflow.yml").exists() + + def test_add_catalog_save_failure_leaves_no_orphan_directory(self, project_dir, monkeypatch): + """Same guarantee as the local-install paths, but for a fresh catalog + install: a registry.add() failure must clean up the freshly-downloaded + directory and fail with a clean escaped message.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + + def boom(self): + raise OSError("disk full") + + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ) + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists() + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_catalog_fresh_install_mkstemp_failure_leaves_no_orphan_directory( + self, project_dir, monkeypatch + ): + """Same guarantee as the local-install fresh-install case, but for a + fresh catalog install: if _stage_workflow_file's mkdir succeeds but + its mkstemp then fails, the freshly-created empty directory must not + be left orphaned.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + + def boom(*args, **kwargs): + raise OSError("disk full") + + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ) + mp.setattr("tempfile.mkstemp", boom) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists(), "fresh-install dest_dir left orphaned after mkstemp failure" + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_catalog_rejects_oversized_content_length(self, project_dir, monkeypatch): + """Catalog installs must share the same size cap as --from: a + response that declares an oversized Content-Length is rejected + before its body is read into memory, and no orphan directory or + registry mutation is left behind.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + small_body = b"id: align-wf\n" # actual body is small; header lies + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + small_body, url, headers={"Content-Length": "1000"} + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "exceedingthe100-byteworkflowsizelimit" in "".join(result.output.split()) + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists() + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_catalog_rejects_oversized_streamed_body_without_content_length( + self, project_dir, monkeypatch + ): + """Catalog installs must also cap actual streamed bytes when + Content-Length is absent or understated, leaving no orphan + directory or registry mutation behind.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands as wf_commands + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(wf_commands, "_MAX_WORKFLOW_YAML_BYTES", 100) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + oversized_body = b"x" * 500 # no Content-Length header at all + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + oversized_body, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "exceedsthe100-byteworkflowsizelimit" in "".join(result.output.split()) + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + assert not dest_dir.exists() + assert not WorkflowRegistry(project_dir).is_installed("align-wf") + + def test_add_catalog_reinstall_save_failure_restores_prior_file(self, project_dir, monkeypatch): + """Re-adding an already-installed catalog workflow downloads the new + version over the existing install directory. If registry.add() then + fails to save, the prior working workflow.yml must be restored + byte-for-byte (not left overwritten with the new download, and not + deleted like a fresh install) and the registry must remain valid and + still point at the original version -- the update path's caller has + an outer backup/restore for this, but plain `workflow add` does not, + so _install_workflow_from_catalog must handle it itself.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + source_data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + source_data, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + assert result.exit_code == 0, result.output + + dest_file = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml" + original_data = dest_file.read_bytes() + + new_data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + + def boom(self): + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + new_data, url + ), + ) + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + # The prior working install must survive untouched, byte-for-byte. + assert dest_file.read_bytes() == original_data + registry = WorkflowRegistry(project_dir) + assert registry.is_installed("align-wf") + assert registry.get("align-wf")["version"] == "1.0.0" + + def test_add_catalog_successful_reinstall_leaves_no_backup_file( + self, project_dir, monkeypatch + ): + """Same orphan-backup gap as the local-install path: a successful + catalog reinstall must not leave its unique backup behind once + registry.add() durably succeeds.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + original_data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + original_data, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + assert result.exit_code == 0, result.output + + new_data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + new_data, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code == 0, result.output + workflow_dir = project_dir / ".specify" / "workflows" / "align-wf" + registry = WorkflowRegistry(project_dir) + assert registry.is_installed("align-wf") + assert registry.get("align-wf")["version"] == "2.0.0" + assert (workflow_dir / "workflow.yml").read_bytes() == new_data + leftovers = [p.name for p in workflow_dir.iterdir() if p.name != "workflow.yml"] + assert leftovers == [], f"orphan sibling(s) left behind: {leftovers}" + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") + def test_add_catalog_fresh_install_uses_project_file_mode( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + + previous_umask = os.umask(0o022) + try: + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(data, url), + ) + result = CliRunner().invoke( + app, ["workflow", "add", "align-wf"] + ) + finally: + os.umask(previous_umask) + + assert result.exit_code == 0, result.output + workflow_file = ( + project_dir + / ".specify" + / "workflows" + / "align-wf" + / "workflow.yml" + ) + assert stat.S_IMODE(workflow_file.stat().st_mode) == 0o644 + + def test_concurrent_catalog_reinstalls_keep_file_and_registry_aligned( + self, project_dir, monkeypatch + ): + import threading + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + from specify_cli.workflows.engine import WorkflowDefinition + + workflows_dir = project_dir / ".specify" / "workflows" + workflow_file = workflows_dir / "align-wf" / "workflow.yml" + workflow_file.parent.mkdir(parents=True) + workflow_file.write_text( + self.WORKFLOW_YAML.format(version="1.0.0"), encoding="utf-8" + ) + WorkflowRegistry(project_dir).add( + "align-wf", + { + "name": "Align Workflow", + "version": "1.0.0", + "source": "catalog", + }, + ) + + versions = {"install-a": "2.0.0", "install-b": "3.0.0"} + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": versions[threading.current_thread().name], + "url": ( + "https://example.com/" + f"{versions[threading.current_thread().name]}.yml" + ), + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse( + self.WORKFLOW_YAML.format( + version=url.rsplit("/", 1)[-1].removesuffix(".yml") + ).encode(), + url, + ), + ) + + a_committed = threading.Event() + b_committed = threading.Event() + a_saving = threading.Event() + b_saved = threading.Event() + real_commit = _commands._commit_workflow_file + real_save = WorkflowRegistry.save + + def coordinated_commit(*args, **kwargs): + backup = real_commit(*args, **kwargs) + if threading.current_thread().name == "install-a": + a_committed.set() + b_committed.wait(0.5) + else: + b_committed.set() + return backup + + def coordinated_save(registry): + if threading.current_thread().name == "install-a": + a_saving.set() + b_saved.wait(0.5) + return real_save(registry) + assert a_saving.wait(2) + real_save(registry) + b_saved.set() + + monkeypatch.setattr( + _commands, "_commit_workflow_file", coordinated_commit + ) + monkeypatch.setattr(WorkflowRegistry, "save", coordinated_save) + + errors = [] + + def install(): + try: + _commands._install_workflow_from_catalog( + project_dir, + workflows_dir, + "align-wf", + ) + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=install, name="install-a") + second = threading.Thread(target=install, name="install-b") + first.start() + assert a_committed.wait(2) + second.start() + first.join(5) + second.join(5) + + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + file_version = WorkflowDefinition.from_yaml(workflow_file).version + registry_version = WorkflowRegistry(project_dir).get("align-wf")[ + "version" + ] + assert file_version == registry_version + + def test_precommit_discard_preserves_concurrent_install(self, project_dir): + from specify_cli.workflows import _commands + + workflow_dir = ( + project_dir / ".specify" / "workflows" / "concurrent-wf" + ) + workflow_dir.mkdir(parents=True) + staged_file = workflow_dir / ".workflow.yml.staged.tmp" + staged_file.write_text("staged", encoding="utf-8") + committed_file = workflow_dir / "workflow.yml" + committed_file.write_text("committed", encoding="utf-8") + + _commands._discard_staged_workflow_file( + staged_file, workflow_dir, existed_before=False + ) + + assert committed_file.read_text(encoding="utf-8") == "committed" + assert not staged_file.exists() + + def test_fresh_install_rollback_preserves_concurrent_staged_file( + self, project_dir + ): + """A second installer stages before taking the transaction lock, so + the first installer's rollback must not recursively remove siblings.""" + from specify_cli.workflows import _commands + + workflow_dir = ( + project_dir / ".specify" / "workflows" / "concurrent-wf" + ) + workflow_dir.mkdir(parents=True) + committed_file = workflow_dir / "workflow.yml" + committed_file.write_text("failed install", encoding="utf-8") + concurrent_stage = workflow_dir / ".workflow.yml.concurrent.tmp" + concurrent_stage.write_text("next install", encoding="utf-8") + + _commands._rollback_committed_workflow_file( + committed_file, + workflow_dir, + existed_before=False, + backup_file=None, + ) + + assert not committed_file.exists() + assert concurrent_stage.read_text(encoding="utf-8") == "next install" + + def test_add_dev_registry_reopen_exit_discards_staged_file( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows import _commands + + monkeypatch.chdir(project_dir) + source_dir = self._write_workflow_dir(project_dir) + real_open_registry = _commands._open_workflow_registry + calls = 0 + + def fail_transaction_reopen(root): + nonlocal calls + calls += 1 + if calls == 2: + raise typer.Exit(1) + return real_open_registry(root) + + monkeypatch.setattr( + _commands, "_open_workflow_registry", fail_transaction_reopen + ) + result = CliRunner().invoke( + app, ["workflow", "add", str(source_dir), "--dev"] + ) + + assert result.exit_code != 0 + assert not ( + project_dir / ".specify" / "workflows" / "align-wf" + ).exists() + + def test_add_catalog_registry_reopen_exit_discards_staged_file( + self, project_dir, monkeypatch + ): + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowCatalog + + workflows_dir = project_dir / ".specify" / "workflows" + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(data, url), + ) + monkeypatch.setattr( + _commands, + "_open_workflow_registry", + lambda _root: (_ for _ in ()).throw(typer.Exit(1)), + ) + + with pytest.raises(typer.Exit): + _commands._install_workflow_from_catalog( + project_dir, + workflows_dir, + "align-wf", + ) + + assert not (workflows_dir / "align-wf").exists() + + def test_add_catalog_reinstall_restore_failure_reports_warning_and_original_error( + self, project_dir, monkeypatch + ): + """Same restore-rename boundary as the local-install path: the + prior file is restored via an atomic rename (not a content rewrite) + when registry.add() fails on a reinstall. If that restore rename + itself also fails, it must report a clear warning in addition to + the original clean registry error, never crash or silently claim + success.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + original_data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + original_data, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + assert result.exit_code == 0, result.output + + new_data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + + def save_boom(self): + raise OSError("disk full") + + real_replace = os.replace + calls = {"n": 0} + + def replace_boom(src_path, dst_path): + # The commit swap for a reinstall makes exactly two os.replace + # calls (backup-aside, then staged-into-dest); let both succeed + # and only fail the third call -- the post-registry-failure + # restore-back rename. + calls["n"] += 1 + if calls["n"] <= 2: + return real_replace(src_path, dst_path) + raise OSError("permission denied") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + new_data, url + ), + ) + mp.setattr(WorkflowRegistry, "save", save_boom) + mp.setattr(os, "replace", replace_boom) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + output_compact = "".join(result.output.split()) + assert "Warning" in result.output + assert "diskfull" in output_compact + assert "permissiondenied" in output_compact + + def test_add_catalog_fresh_install_into_preexisting_empty_dir_cleans_new_file( + self, project_dir, monkeypatch + ): + """Same rollback orphan gap as the local-install path, but for a + fresh catalog install: a pre-existing empty destination directory + (no workflow.yml) sets existed_before=True with no backup bytes, so + the rollback previously did nothing on a later failure -- leaving + the freshly downloaded workflow.yml behind. It must be removed, + leaving the pre-existing directory itself intact.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + dest_dir = project_dir / ".specify" / "workflows" / "align-wf" + dest_dir.mkdir(parents=True) # pre-existing, but empty: no workflow.yml + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + + def boom(self): + raise OSError("disk full") + + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url), + ) + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.output.strip() != "" + assert dest_dir.is_dir() + assert not (dest_dir / "workflow.yml").exists() + + @pytest.mark.parametrize( + "mode", ["redirect_rejected", "download_exception", "invalid_yaml", "id_mismatch"] + ) + def test_add_catalog_reinstall_early_failure_restores_prior_file( + self, project_dir, monkeypatch, mode + ): + """Every _install_workflow_from_catalog failure branch that runs after + the mkdir/download step -- not just the registry.add() OSError case + -- must route through the same existed-before/backup-aware cleanup: + on a reinstall, a redirect rejection, a download exception, invalid + YAML, or a workflow-id mismatch must restore the prior working + workflow.yml rather than deleting the whole directory. One shared + root cause (the cleanup helper), so parametrized over trigger point.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + source_data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + runner = CliRunner() + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse( + source_data, url + ), + ) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + assert result.exit_code == 0, result.output + + dest_file = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml" + original_data = dest_file.read_bytes() + + if mode == "redirect_rejected": + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return self._FakeResponse(b"irrelevant", "http://evil.example.com/workflow.yml") + elif mode == "download_exception": + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + raise OSError("network down") + elif mode == "invalid_yaml": + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return self._FakeResponse(b": : not valid yaml: [", url) + else: # id_mismatch + mismatched_yaml = self.WORKFLOW_YAML.format(version="2.0.0").replace( + 'id: "align-wf"', 'id: "different-workflow"' + ) + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return self._FakeResponse(mismatched_yaml.encode(), url) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr("specify_cli.authentication.http.open_url", fake_open_url) + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + assert dest_file.read_bytes() == original_data + registry = WorkflowRegistry(project_dir) + assert registry.is_installed("align-wf") + assert registry.get("align-wf")["version"] == "1.0.0" + + def test_download_redirect_validator_rejects_http_before_follow(self): + import urllib.error + + from specify_cli.workflows._commands import _reject_insecure_download_redirect + + with pytest.raises(urllib.error.URLError): + _reject_insecure_download_redirect( + "https://example.com/wf.yml", "http://evil.example.com/wf.yml" + ) + with pytest.raises(urllib.error.URLError): + _reject_insecure_download_redirect( + "https://example.com/wf.yml", "http://localhost:8000/wf.yml" + ) + with pytest.raises(urllib.error.URLError): + _reject_insecure_download_redirect( + "https://example.com/wf.yml", "https://127.0.0.2/wf.yml" + ) + # Allowed: HTTPS anywhere, or loopback HTTP that stays on loopback HTTP. + _reject_insecure_download_redirect( + "https://example.com/wf.yml", "https://cdn.example.com/wf.yml" + ) + _reject_insecure_download_redirect( + "http://localhost:7000/wf.yml", "http://localhost:8000/wf.yml" + ) + _reject_insecure_download_redirect( + "http://127.0.0.1/source.yml", "http://127.0.0.1/wf.yml" + ) + _reject_insecure_download_redirect( + "http://127.0.0.2/source.yml", "http://127.255.255.254/wf.yml" + ) + + def test_add_from_url_passes_redirect_validator(self, project_dir, monkeypatch): + from unittest.mock import patch + + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + data = self.WORKFLOW_YAML.format(version="1.0.0").encode() + seen: dict[str, object] = {} + + def fake_open(url, timeout=None, extra_headers=None, redirect_validator=None): + seen["validator"] = redirect_validator + return self._FakeResponse(data, url) + + runner = CliRunner() + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open): + result = runner.invoke( + app, + ["workflow", "add", "align-wf", "--from", "https://example.com/workflow.yml"], + input="y\n", + ) + assert result.exit_code == 0, result.output + from specify_cli.workflows._commands import _reject_insecure_download_redirect + + assert seen["validator"] is _reject_insecure_download_redirect + + def test_add_non_string_catalog_url_fails_cleanly(self, project_dir, monkeypatch): + """A truthy non-string catalog URL must hit the clean error path, not AttributeError.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.chdir(project_dir) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "1.0.0", + "url": 123, + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", "align-wf"]) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "malformed install URL" in result.output + + def test_commit_failure_reports_unrestored_backup_location( + self, tmp_path, monkeypatch + ): + from specify_cli.workflows import _commands + + dest_dir = tmp_path / "align-wf" + dest_dir.mkdir() + dest_file = dest_dir / "workflow.yml" + staged_file = dest_dir / ".workflow.yml.staged" + dest_file.write_text("original", encoding="utf-8") + staged_file.write_text("replacement", encoding="utf-8") + + real_replace = os.replace + calls = 0 + backup_file = None + + def fail_commit_and_restore(src, dst): + nonlocal backup_file, calls + calls += 1 + if calls == 1: + backup_file = Path(dst) + return real_replace(src, dst) + if calls == 2: + raise OSError("commit denied") + raise OSError("restore denied") + + monkeypatch.setattr(os, "replace", fail_commit_and_restore) + with pytest.raises(OSError) as exc_info: + _commands._commit_workflow_file( + staged_file, dest_file, existed_before=True + ) + + message = str(exc_info.value) + assert "commit denied" in message + assert "restore denied" in message + assert backup_file is not None + assert str(backup_file) in message + assert not dest_file.exists() + assert backup_file.read_text(encoding="utf-8") == "original" + + def test_commit_keyboard_interrupt_restores_prior_file( + self, tmp_path, monkeypatch + ): + from specify_cli.workflows import _commands + + dest_dir = tmp_path / "align-wf" + dest_dir.mkdir() + dest_file = dest_dir / "workflow.yml" + staged_file = dest_dir / ".workflow.yml.staged" + dest_file.write_text("original", encoding="utf-8") + staged_file.write_text("replacement", encoding="utf-8") + + real_replace = os.replace + calls = 0 + + def interrupt_commit(src, dst): + nonlocal calls + calls += 1 + if calls == 2: + raise KeyboardInterrupt + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", interrupt_commit) + with pytest.raises(KeyboardInterrupt): + _commands._commit_workflow_file( + staged_file, dest_file, existed_before=True + ) + + assert dest_file.read_text(encoding="utf-8") == "original" + assert staged_file.read_text(encoding="utf-8") == "replacement" + assert list(dest_dir.glob("*.bak")) == [] + + def test_commit_interrupt_after_first_rename_restores_prior_file( + self, tmp_path, monkeypatch + ): + from specify_cli.workflows import _commands + + dest_dir = tmp_path / "align-wf" + dest_dir.mkdir() + dest_file = dest_dir / "workflow.yml" + staged_file = dest_dir / ".workflow.yml.staged" + dest_file.write_text("original", encoding="utf-8") + staged_file.write_text("replacement", encoding="utf-8") + + real_replace = os.replace + calls = 0 + + def interrupt_after_replace(src, dst): + nonlocal calls + calls += 1 + result = real_replace(src, dst) + if calls == 1: + raise KeyboardInterrupt + return result + + monkeypatch.setattr(os, "replace", interrupt_after_replace) + with pytest.raises(KeyboardInterrupt): + _commands._commit_workflow_file( + staged_file, dest_file, existed_before=True + ) + + assert dest_file.read_text(encoding="utf-8") == "original" + assert staged_file.read_text(encoding="utf-8") == "replacement" + assert list(dest_dir.glob("*.bak")) == [] + + def test_commit_uses_unique_backup_without_overwriting_existing_sibling( + self, tmp_path + ): + from specify_cli.workflows import _commands + + dest_dir = tmp_path / "align-wf" + dest_dir.mkdir() + dest_file = dest_dir / "workflow.yml" + staged_file = dest_dir / ".workflow.yml.staged" + fixed_backup = dest_dir / "workflow.yml.bak" + dest_file.write_text("original", encoding="utf-8") + staged_file.write_text("replacement", encoding="utf-8") + fixed_backup.write_text("diagnostic copy", encoding="utf-8") + + backup_file = _commands._commit_workflow_file( + staged_file, dest_file, existed_before=True + ) + + assert backup_file is not None + assert backup_file != fixed_backup + assert backup_file.read_text(encoding="utf-8") == "original" + assert fixed_backup.read_text(encoding="utf-8") == "diagnostic copy" + assert dest_file.read_text(encoding="utf-8") == "replacement" + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_workflow_add_does_not_copy_overlays(self, project_dir, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + source_dir = tmp_path / "source-wf" + source_dir.mkdir() + (source_dir / "workflow.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + } + ), + encoding="utf-8", + ) + overlays_dir = source_dir / "overlays" + overlays_dir.mkdir() + (overlays_dir / "ov1.yml").write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "add", str(source_dir)]) + assert result.exit_code == 0, result.output + # Overlays in the source directory should NOT be copied — workflow add + # only installs the workflow.yml, not sibling overlays. + installed_overlay = ( + project_dir / ".specify" / "workflows" / "wf" / "overlays" / "ov1.yml" + ) + assert not installed_overlay.exists() diff --git a/tests/specify_cli/workflows/test_command_disable.py b/tests/specify_cli/workflows/test_command_disable.py new file mode 100644 index 0000000000..b95930d7c4 --- /dev/null +++ b/tests/specify_cli/workflows/test_command_disable.py @@ -0,0 +1,305 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import os + +import pytest + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "align-wf" + name: "Align Workflow" + version: "{version}" + description: "CLI alignment test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def _write_workflow_dir(self, base, version="1.0.0"): + d = base / "wf-src" + d.mkdir(parents=True, exist_ok=True) + (d / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version=version), encoding="utf-8" + ) + return d + + def _install_dev(self, runner, app, project_dir): + src = self._write_workflow_dir(project_dir) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + assert result.exit_code == 0, result.output + return src + + class _FakeResponse: + def __init__(self, data, url="https://example.com/workflow.yml", headers=None): + self._data = data + self._url = url + self._pos = 0 + self._headers = headers or {} + + def read(self, amt=None): + if amt is None: + chunk = self._data[self._pos :] + self._pos = len(self._data) + return chunk + chunk = self._data[self._pos : self._pos + amt] + self._pos += len(chunk) + return chunk + + def getheader(self, name, default=None): + return self._headers.get(name, default) + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + @pytest.mark.parametrize( + ("command_name", "initial_enabled", "expected_enabled"), + [ + ("enable", False, True), + ("disable", True, False), + ], + ) + def test_toggle_serializes_with_concurrent_catalog_update( + self, + project_dir, + monkeypatch, + command_name, + initial_enabled, + expected_enabled, + ): + import threading + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + from specify_cli.workflows.engine import WorkflowDefinition + + workflows_dir = project_dir / ".specify" / "workflows" + workflow_file = workflows_dir / "align-wf" / "workflow.yml" + workflow_file.parent.mkdir(parents=True) + workflow_file.write_text( + self.WORKFLOW_YAML.format(version="1.0.0"), encoding="utf-8" + ) + WorkflowRegistry(project_dir).add( + "align-wf", + { + "name": "Align Workflow", + "version": "1.0.0", + "source": "catalog", + "enabled": initial_enabled, + }, + ) + monkeypatch.setattr( + _commands, "_require_specify_project", lambda: project_dir + ) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "2.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + new_data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(new_data, url), + ) + + toggle_ready = threading.Event() + update_done = threading.Event() + real_add = WorkflowRegistry.add + + def coordinated_add(registry, workflow_id, metadata): + if threading.current_thread().name == "toggle": + toggle_ready.set() + update_done.wait(0.5) + return real_add(registry, workflow_id, metadata) + + monkeypatch.setattr(WorkflowRegistry, "add", coordinated_add) + errors = [] + + def toggle(): + try: + getattr(_commands, f"workflow_{command_name}")("align-wf") + except BaseException as exc: + errors.append(exc) + + def update(): + try: + _commands._install_workflow_from_catalog( + project_dir, + workflows_dir, + "align-wf", + ) + except BaseException as exc: + errors.append(exc) + finally: + update_done.set() + + toggle_thread = threading.Thread(target=toggle, name="toggle") + update_thread = threading.Thread(target=update, name="update") + toggle_thread.start() + assert toggle_ready.wait(2) + update_thread.start() + toggle_thread.join(5) + update_thread.join(5) + + assert not toggle_thread.is_alive() + assert not update_thread.is_alive() + assert errors == [] + assert WorkflowDefinition.from_yaml(workflow_file).version == "2.0.0" + metadata = WorkflowRegistry(project_dir).get("align-wf") + assert metadata["version"] == "2.0.0" + assert metadata.get("enabled", True) is expected_enabled + + def test_disable_blocks_run_enable_restores(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).get("align-wf")["enabled"] is False + + result = runner.invoke(app, ["workflow", "run", "align-wf"]) + assert result.exit_code != 0 + assert "disabled" in result.output + + result = runner.invoke(app, ["workflow", "enable", "align-wf"]) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).get("align-wf")["enabled"] is True + + result = runner.invoke(app, ["workflow", "run", "align-wf"]) + assert result.exit_code == 0, result.output + + def test_disable_blocks_case_variant_installed_path( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + + case_variant = ( + project_dir + / ".SPECIFY" + / "WORKFLOWS" + / "ALIGN-WF" + / "workflow.yml" + ) + if not case_variant.is_file(): + pytest.skip("filesystem is case-sensitive") + + result = runner.invoke( + app, ["workflow", "run", str(case_variant)] + ) + + assert result.exit_code != 0 + assert "disabled" in result.output + + def test_disable_blocks_run_via_path_equivalent_id(self, project_dir, monkeypatch): + """Path-equivalent and newline IDs must not dodge the registry lookup.""" + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + + for spelling in ("align-wf/", "align-wf/.", "align-wf\n"): + result = runner.invoke(app, ["workflow", "run", spelling]) + assert result.exit_code != 0, spelling + assert "Invalid workflow ID" in result.output, spelling + + # Direct path to the installed workflow's own YAML must also refuse. + installed_yaml = ".specify/workflows/align-wf/workflow.yml" + assert (project_dir / installed_yaml).is_file() + result = runner.invoke(app, ["workflow", "run", installed_yaml]) + assert result.exit_code != 0 + assert "disabled" in result.output + + # Same guard must hold when invoked from outside the project. + outside = project_dir.parent / "outside-cwd" + outside.mkdir(exist_ok=True) + monkeypatch.chdir(outside) + result = runner.invoke( + app, ["workflow", "run", str(project_dir / installed_yaml)] + ) + assert result.exit_code != 0 + assert "disabled" in result.output + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_disable_blocks_run_when_installed_yaml_is_symlinked( + self, project_dir, monkeypatch + ): + """A disabled workflow's own workflow.yml being replaced with a symlink + must not bypass the disabled check. Resolving the path before mapping + it back to its registry owner would follow the symlink out of + .specify/workflows, fail to find an owner, and let engine.load_workflow + run the original symlink target anyway -- ownership must be + determined from the normalized *lexical* path (not resolve()), and a + symlinked path component in the installed tree must be refused.""" + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + + installed_yaml = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml" + external_target = project_dir / "external-workflow.yml" + external_target.write_text( + self.WORKFLOW_YAML.format(version="9.9.9"), encoding="utf-8" + ) + installed_yaml.unlink() + installed_yaml.symlink_to(external_target) + + result = runner.invoke(app, ["workflow", "run", str(installed_yaml)]) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "disabled" in result.output or "symlink" in result.output.lower() + + def test_disable_shows_marker_in_list(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + runner.invoke(app, ["workflow", "disable", "align-wf"]) + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "[disabled]" in result.output diff --git a/tests/specify_cli/workflows/test_command_enable.py b/tests/specify_cli/workflows/test_command_enable.py new file mode 100644 index 0000000000..8c615af83a --- /dev/null +++ b/tests/specify_cli/workflows/test_command_enable.py @@ -0,0 +1,145 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import json + +import pytest + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "align-wf" + name: "Align Workflow" + version: "{version}" + description: "CLI alignment test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def _write_workflow_dir(self, base, version="1.0.0"): + d = base / "wf-src" + d.mkdir(parents=True, exist_ok=True) + (d / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version=version), encoding="utf-8" + ) + return d + + def _install_dev(self, runner, app, project_dir): + src = self._write_workflow_dir(project_dir) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + assert result.exit_code == 0, result.output + return src + + def test_enable_failed_save_leaves_workflow_disabled(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0, result.output + + def boom(self): + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke(app, ["workflow", "enable", "align-wf"]) + assert result.exit_code != 0 + + assert WorkflowRegistry(project_dir).get("align-wf")["enabled"] is False + result = runner.invoke(app, ["workflow", "enable", "align-wf"]) + assert result.exit_code == 0, result.output + assert WorkflowRegistry(project_dir).get("align-wf")["enabled"] is True + + @pytest.mark.parametrize("command", ["enable", "disable"]) + def test_enable_disable_save_failure_gives_clean_output( + self, project_dir, monkeypatch, command + ): + """A save() failure in enable/disable must produce a clean escaped CLI + error, not surface the raw OSError as an unhandled exception. Shared + root behavior: both call registry.add() with a fresh mapping and must + catch its deliberate OSError the same way.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + # disable starts from the enabled default; enable needs a prior disable. + starting_enabled = command == "disable" + if command == "enable": + pre = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert pre.exit_code == 0, pre.output + + def boom(self): + raise OSError("disk full") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", boom) + result = runner.invoke(app, ["workflow", command, "align-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + assert ( + WorkflowRegistry(project_dir).get("align-wf").get("enabled", True) + is starting_enabled + ) + + def test_enable_disable_corrupted_registry_entry_errors(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + registry_path = WorkflowRegistry(project_dir).registry_path + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps({"schema_version": "1.0", "workflows": {"broken": "not-a-dict"}}), + encoding="utf-8", + ) + runner = CliRunner() + for cmd in ("enable", "disable"): + result = runner.invoke(app, ["workflow", cmd, "broken"]) + assert result.exit_code != 0 + assert "corrupted" in result.output + + def test_enable_disable_not_installed_errors(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + for cmd in ("enable", "disable"): + result = runner.invoke(app, ["workflow", cmd, "ghost"]) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_enable_disable_idempotent_warnings(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + result = runner.invoke(app, ["workflow", "enable", "align-wf"]) + assert result.exit_code == 0 + assert "already enabled" in result.output + + runner.invoke(app, ["workflow", "disable", "align-wf"]) + result = runner.invoke(app, ["workflow", "disable", "align-wf"]) + assert result.exit_code == 0 + assert "already disabled" in result.output diff --git a/tests/specify_cli/workflows/test_command_info.py b/tests/specify_cli/workflows/test_command_info.py new file mode 100644 index 0000000000..2956c0b95a --- /dev/null +++ b/tests/specify_cli/workflows/test_command_info.py @@ -0,0 +1,143 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + + + + + +class TestWorkflowInfoStepGraph: + """`workflow info` must render each step as `→ []` with LITERAL + brackets. Rich parses an unescaped `[]` as a style tag and silently + swallows it, so the step type would vanish from the output.""" + + def test_step_type_rendered_in_literal_brackets(self, temp_dir, monkeypatch): + import types + + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.engine import WorkflowEngine + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + + fake = types.SimpleNamespace( + name="My WF", id="my-wf", version="1.0.0", author="", description="", + default_integration=None, inputs={}, + steps=[{"id": "step-one", "type": "gate"}], + ) + monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake) + monkeypatch.chdir(temp_dir) + + result = CliRunner().invoke(app, ["workflow", "info", "my-wf"]) + + assert result.exit_code == 0, result.output + assert "step-one" in result.output + # The step type must survive as a literal bracketed token, not be eaten + # by Rich as an unknown style tag. + assert "[gate]" in result.output + + def test_definition_metadata_fields_escaped(self, temp_dir, monkeypatch): + """Every metadata field printed from the workflow definition (name, + description, author, integration, input name/type) is untrusted + workflow.yml content. An unescaped `[...]` in any of them would be + parsed as a Rich style tag and silently swallowed, so bracketed text + must survive literally in the output.""" + import types + + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.engine import WorkflowEngine + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + + fake = types.SimpleNamespace( + name="My [WF]", + id="my-wf", + version="1.0.0 [beta]", + author="Jane [Doe]", + description="Does [stuff] nicely", + default_integration="claude [code]", + inputs={"in [put]": {"type": "str [ing]", "required": True}}, + steps=[], + ) + monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake) + monkeypatch.chdir(temp_dir) + + result = CliRunner().invoke(app, ["workflow", "info", "my-wf"]) + + assert result.exit_code == 0, result.output + # Each bracketed token must render literally rather than be consumed as + # an unknown Rich style tag. + assert "My [WF]" in result.output + assert "1.0.0 [beta]" in result.output + assert "Jane [Doe]" in result.output + assert "Does [stuff] nicely" in result.output + assert "claude [code]" in result.output + assert "in [put]" in result.output + assert "str [ing]" in result.output + + def test_catalog_metadata_fields_escaped(self, temp_dir, monkeypatch): + """When the workflow is only found in the catalog (not on disk), its + catalog-derived fields (name, description, tags) are untrusted too and + must be escaped so bracketed content renders literally.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.engine import WorkflowEngine + from specify_cli.workflows import catalog as catalog_mod + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + + def _not_on_disk(self, wid): + raise FileNotFoundError(wid) + + monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk) + monkeypatch.setattr( + catalog_mod.WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "name": "Cat [WF]", + "version": "2.0.0 [rc]", + "description": "From [catalog]", + "tags": ["a [b]", "c [d]"], + }, + ) + monkeypatch.chdir(temp_dir) + + result = CliRunner().invoke(app, ["workflow", "info", "cat-wf"]) + + assert result.exit_code == 0, result.output + assert "Cat [WF]" in result.output + assert "2.0.0 [rc]" in result.output + assert "From [catalog]" in result.output + assert "a [b]" in result.output + assert "c [d]" in result.output + + def test_not_found_id_escaped(self, temp_dir, monkeypatch): + """When the workflow is neither on disk nor in the catalog, the + not-found error echoes the requested ID. That ID is user input, so a + bracketed value must render literally instead of being parsed (and + swallowed) as a Rich style tag.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.engine import WorkflowEngine + from specify_cli.workflows import catalog as catalog_mod + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + + def _not_on_disk(self, wid): + raise FileNotFoundError(wid) + + monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk) + monkeypatch.setattr( + catalog_mod.WorkflowCatalog, + "get_workflow_info", + lambda self, wid: None, + ) + monkeypatch.chdir(temp_dir) + + result = CliRunner().invoke(app, ["workflow", "info", "ghost [wf]"]) + + assert result.exit_code == 1, result.output + assert "not found" in result.output + # The bracketed ID must survive literally, not be eaten as markup. + assert "ghost [wf]" in result.output diff --git a/tests/specify_cli/workflows/test_command_list.py b/tests/specify_cli/workflows/test_command_list.py new file mode 100644 index 0000000000..18e42cf5c1 --- /dev/null +++ b/tests/specify_cli/workflows/test_command_list.py @@ -0,0 +1,150 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + + +class TestWorkflowAddSymlinkGuard: + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_list_refuses_symlinked_runs_dir(self, temp_dir, monkeypatch): + """workflow commands using the project shim must refuse symlinked run storage.""" + from typer.testing import CliRunner + from specify_cli import app + + (temp_dir / ".specify" / "workflows").mkdir(parents=True) + outside = temp_dir.parent / "outside-runs-target" + outside.mkdir(parents=True, exist_ok=True) + (temp_dir / ".specify" / "workflows" / "runs").symlink_to( + outside, target_is_directory=True + ) + + monkeypatch.chdir(temp_dir) + result = CliRunner().invoke(app, ["workflow", "list"]) + + assert result.exit_code != 0 + assert "symlinked .specify/workflows/runs" in result.output + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "align-wf" + name: "Align Workflow" + version: "{version}" + description: "CLI alignment test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def _write_workflow_dir(self, base, version="1.0.0"): + d = base / "wf-src" + d.mkdir(parents=True, exist_ok=True) + (d / "workflow.yml").write_text( + self.WORKFLOW_YAML.format(version=version), encoding="utf-8" + ) + return d + + def _install_dev(self, runner, app, project_dir): + src = self._write_workflow_dir(project_dir) + result = runner.invoke(app, ["workflow", "add", str(src), "--dev"]) + assert result.exit_code == 0, result.output + return src + + def test_list_skips_corrupted_registry_entry(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + registry_path = WorkflowRegistry(project_dir).registry_path + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "schema_version": "1.0", + "workflows": { + "broken": "not-a-dict", + "ok": {"name": "OK Workflow", "version": "1.0.0"}, + }, + } + ), + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "corrupted" in result.output + assert "OK Workflow" in result.output + + def test_list_unreadable_registry_fails_closed_with_clean_error( + self, project_dir, monkeypatch + ): + """An unreadable registry file must produce a clean CLI error, not a + raw traceback and not a silent "nothing installed" list -- the latter + is exactly the fail-open state a caller could otherwise mistake for + "safe to (re)install", overwriting real files. Covers the read/query + boundary fix required at every WorkflowRegistry call site.""" + import builtins + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + runner = CliRunner() + self._install_dev(runner, app, project_dir) + + registry_path = WorkflowRegistry(project_dir).registry_path.resolve() + real_open = builtins.open + + def _raising_open(file, mode="r", *args, **kwargs): + if Path(file).resolve() == registry_path and "r" in mode: + raise OSError("simulated read failure") + return real_open(file, mode, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", _raising_open) + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Error" in result.output + + def test_list_escapes_rich_markup_in_registry_fields(self, project_dir, monkeypatch): + """User-editable name/description/id fields must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + monkeypatch.chdir(project_dir) + registry_path = WorkflowRegistry(project_dir).registry_path + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "schema_version": "1.0", + "workflows": { + "ok": { + "name": "Bracket [Test]", + "version": "1.0.0", + "description": "desc [with] brackets", + }, + }, + } + ), + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "Bracket [Test]" in result.output + assert "desc [with] brackets" in result.output diff --git a/tests/specify_cli/workflows/test_command_remove.py b/tests/specify_cli/workflows/test_command_remove.py new file mode 100644 index 0000000000..21b0347e36 --- /dev/null +++ b/tests/specify_cli/workflows/test_command_remove.py @@ -0,0 +1,371 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import os + +import pytest + + + +class TestWorkflowRemoveGuard: + def test_remove_rejects_traversal_registry_key(self, project_dir, monkeypatch): + """A corrupted registry key must not let remove delete outside workflows/.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("../outside", {"name": "Bad"}) + outside = project_dir / ".specify" / "outside" + outside.mkdir() + sentinel = outside / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke(app, ["workflow", "remove", "../outside"]) + + assert result.exit_code != 0 + assert "Invalid workflow ID" in result.output + assert sentinel.read_text(encoding="utf-8") == "keep" + + @pytest.mark.parametrize("workflow_id", ["overlays", "runs", "steps"]) + def test_remove_rejects_reserved_storage_ids( + self, project_dir, monkeypatch, workflow_id + ): + """Reserved workflow storage directories must never be removable workflows.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add(workflow_id, {"name": "Bad"}) + reserved_dir = project_dir / ".specify" / "workflows" / workflow_id + reserved_dir.mkdir(exist_ok=True) + sentinel = reserved_dir / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke(app, ["workflow", "remove", workflow_id]) + + assert result.exit_code != 0 + assert "Invalid workflow ID" in result.output + assert sentinel.read_text(encoding="utf-8") == "keep" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_remove_refuses_symlinked_workflow_dir(self, project_dir, monkeypatch): + """A symlinked workflow directory must not let remove delete its target.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test"}) + outside = project_dir / "outside-workflow-remove-target" + outside.mkdir(exist_ok=True) + sentinel = outside / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + (project_dir / ".specify" / "workflows" / "test-wf").symlink_to( + outside, target_is_directory=True + ) + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"]) + + assert result.exit_code != 0 + assert "symlinked .specify/workflows/test-wf" in result.output + assert sentinel.read_text(encoding="utf-8") == "keep" + assert WorkflowRegistry(project_dir).is_installed("test-wf") + + def test_remove_refuses_non_directory_workflow_path(self, project_dir, monkeypatch): + """A file at the workflow path must fail cleanly instead of crashing.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test"}) + workflow_path = project_dir / ".specify" / "workflows" / "test-wf" + workflow_path.write_text("not a directory", encoding="utf-8") + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"]) + + assert result.exit_code != 0 + assert "exists but is not a directory" in result.output + assert workflow_path.read_text(encoding="utf-8") == "not a directory" + assert WorkflowRegistry(project_dir).is_installed("test-wf") + + @pytest.mark.parametrize("error_type", [OSError, TypeError, ValueError]) + def test_remove_registry_save_failure_preserves_files_and_registry( + self, project_dir, monkeypatch, error_type + ): + """If persisting the registry removal fails, the workflow's files must + not have already been deleted: the CLI must not delete files before the + registry successfully records the removal, and it must fail cleanly.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + workflow_dir = project_dir / ".specify" / "workflows" / "test-wf" + workflow_dir.mkdir(parents=True, exist_ok=True) + (workflow_dir / "workflow.yml").write_text("keep-me", encoding="utf-8") + + def boom(self): + raise error_type("save failed") + + monkeypatch.chdir(project_dir) + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", boom) + result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert result.output.strip() != "" + # Files must survive a registry-save failure. + assert (workflow_dir / "workflow.yml").read_text(encoding="utf-8") == "keep-me" + # The on-disk registry must still claim the workflow installed. + assert WorkflowRegistry(project_dir).is_installed("test-wf") + # The directory must be restored to its exact original location, with + # no leftover staging directory from the stage/restore-on-failure + # sequence. + entries = [ + p.name + for p in (project_dir / ".specify" / "workflows").iterdir() + if p.name != "workflow-registry.json" + ] + assert entries == ["test-wf"] + + def test_remove_staged_cleanup_failure_reports_warning_not_error( + self, project_dir, monkeypatch + ): + """The directory is staged (atomically renamed out of + .specify/workflows/) *before* the registry write, and the actual + deletion of the staged directory only happens *after* the registry + has already durably recorded the removal. If that final deletion + fails, the registry write already succeeded and must stand -- an + "Error: Failed to remove..." message at that point would contradict + the registry, which is exactly the incoherent state this staging + order exists to prevent. It must be reported as a cleanup warning, + and the command must still succeed.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + workflow_dir = project_dir / ".specify" / "workflows" / "test-wf" + workflow_dir.mkdir(parents=True, exist_ok=True) + (workflow_dir / "workflow.yml").write_text("keep-me", encoding="utf-8") + + def boom(*args, **kwargs): + raise OSError("permission denied") + + monkeypatch.chdir(project_dir) + with pytest.MonkeyPatch.context() as mp: + mp.setattr("shutil.rmtree", boom) + result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"]) + + assert result.exit_code == 0 + assert "Warning" in result.output + # The registry write already committed -- it must stand. + assert not WorkflowRegistry(project_dir).is_installed("test-wf") + # The original install path is gone (staged away before the registry + # write ever ran); only a leftover staged directory remains, never + # at the original path the registry/CLI would treat as installed. + assert not workflow_dir.exists() + leftovers = [ + p + for p in (project_dir / ".specify" / "workflows").iterdir() + if p.name != "workflow-registry.json" + ] + assert len(leftovers) == 1 + assert (leftovers[0] / "workflow.yml").read_text(encoding="utf-8") == "keep-me" + + def test_remove_stage_restore_failure_escapes_rich_markup( + self, temp_dir, monkeypatch + ): + """When the registry write fails (already rolled back in-memory by + WorkflowRegistry.remove()) and the attempt to rename the staged + directory back to its original location also fails, both the + restore exception and the registry-update exception interpolated + into these warning/error messages must be escaped like every other + error path here.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.workflows.catalog import WorkflowRegistry + + project_dir = temp_dir / "weird[project]" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "workflows").mkdir() + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + workflow_dir = project_dir / ".specify" / "workflows" / "test-wf" + workflow_dir.mkdir(parents=True, exist_ok=True) + (workflow_dir / "workflow.yml").write_text("keep-me", encoding="utf-8") + + def save_boom(self): + raise OSError("[reg] disk full") + + real_rename = os.rename + rename_calls = {"n": 0} + + def rename_boom(src, dst): + rename_calls["n"] += 1 + if rename_calls["n"] == 1: + # Allow the initial stage-out rename to succeed so the + # restore-back rename (the second call) is what fails. + return real_rename(src, dst) + raise OSError("[stage] permission denied") + + monkeypatch.chdir(project_dir) + with pytest.MonkeyPatch.context() as mp: + mp.setattr(WorkflowRegistry, "save", save_boom) + mp.setattr(os, "rename", rename_boom) + result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit) + output_compact = "".join(result.output.split()) + assert "[stage]permissiondenied" in output_compact + assert "[reg]diskfull" in output_compact + + + +class TestWorkflowCliAlignment: + """CLI alignment with extension/preset commands (#2342).""" + + WORKFLOW_YAML = """ +schema_version: "1.0" +workflow: + id: "align-wf" + name: "Align Workflow" + version: "{version}" + description: "CLI alignment test workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + class _FakeResponse: + def __init__(self, data, url="https://example.com/workflow.yml", headers=None): + self._data = data + self._url = url + self._pos = 0 + self._headers = headers or {} + + def read(self, amt=None): + if amt is None: + chunk = self._data[self._pos :] + self._pos = len(self._data) + return chunk + chunk = self._data[self._pos : self._pos + amt] + self._pos += len(chunk) + return chunk + + def getheader(self, name, default=None): + return self._headers.get(name, default) + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def test_remove_serializes_with_concurrent_catalog_install( + self, project_dir, monkeypatch + ): + import threading + from specify_cli.workflows import _commands + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry + from specify_cli.workflows.engine import WorkflowDefinition + + workflows_dir = project_dir / ".specify" / "workflows" + workflow_file = workflows_dir / "align-wf" / "workflow.yml" + workflow_file.parent.mkdir(parents=True) + workflow_file.write_text( + self.WORKFLOW_YAML.format(version="1.0.0"), encoding="utf-8" + ) + WorkflowRegistry(project_dir).add( + "align-wf", + { + "name": "Align Workflow", + "version": "1.0.0", + "source": "catalog", + }, + ) + monkeypatch.setattr( + _commands, "_require_specify_project", lambda: project_dir + ) + monkeypatch.setattr( + WorkflowCatalog, + "get_workflow_info", + lambda self, wid: { + "id": wid, + "name": "Align Workflow", + "version": "2.0.0", + "url": "https://example.com/workflow.yml", + "_install_allowed": True, + "_catalog_name": "test-catalog", + }, + ) + new_data = self.WORKFLOW_YAML.format(version="2.0.0").encode() + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, + redirect_validator=None: self._FakeResponse(new_data, url), + ) + + removal_ready = threading.Event() + install_done = threading.Event() + real_remove = WorkflowRegistry.remove + + def coordinated_remove(registry, workflow_id): + if threading.current_thread().name == "remove": + removal_ready.set() + install_done.wait(0.5) + return real_remove(registry, workflow_id) + + monkeypatch.setattr(WorkflowRegistry, "remove", coordinated_remove) + errors = [] + + def remove(): + try: + _commands.workflow_remove("align-wf") + except BaseException as exc: + errors.append(exc) + + def install(): + try: + _commands._install_workflow_from_catalog( + project_dir, + workflows_dir, + "align-wf", + ) + except BaseException as exc: + errors.append(exc) + finally: + install_done.set() + + remove_thread = threading.Thread(target=remove, name="remove") + install_thread = threading.Thread(target=install, name="install") + remove_thread.start() + assert removal_ready.wait(2) + install_thread.start() + remove_thread.join(5) + install_thread.join(5) + + assert not remove_thread.is_alive() + assert not install_thread.is_alive() + assert errors == [] + assert WorkflowDefinition.from_yaml(workflow_file).version == "2.0.0" + metadata = WorkflowRegistry(project_dir).get("align-wf") + assert metadata["version"] == "2.0.0" diff --git a/tests/specify_cli/workflows/test_command_resolve.py b/tests/specify_cli/workflows/test_command_resolve.py new file mode 100644 index 0000000000..0e4cdf37fe --- /dev/null +++ b/tests/specify_cli/workflows/test_command_resolve.py @@ -0,0 +1,197 @@ +"""Command-focused workflow overlay tests.""" + +from __future__ import annotations + + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.workflows.helpers import ( + write_overlay as _write_overlay, + write_workflow as _write_workflow, +) + +runner = CliRunner() + + +class TestOverlayCli: + """CLI-level tests for ``specify workflow overlay *``.""" + + def test_workflow_resolve(self, project_dir, monkeypatch): + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "resolve", "wf"]) + assert result.exit_code == 0, result.output + assert "base" in result.output + assert "project:ov1" in result.output + assert "new" in result.output + assert "priority=n/a" in result.output + + from specify_cli.workflows.overlay.operations import workflow_resolve + + payload = workflow_resolve(project_dir, "wf") + assert payload is not None + assert payload["layers"][-1]["tier"] == "base" + assert payload["layers"][-1]["priority"] is None + + def test_workflow_resolve_prints_tier_labels(self, project_dir, monkeypatch): + """Layer tiers render literally; an unescaped ``[base]`` is eaten as markup.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "new", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "resolve", "wf"]) + assert result.exit_code == 0, result.output + assert "[base]" in result.output + assert "[project-overlay]" in result.output + + @pytest.mark.parametrize( + "step_id", + [ + # Balanced tag: silently swallowed, so the step vanishes from output. + "new[stuff]", + # Unbalanced closer: raises MarkupError -> traceback and exit 1. + "new[/red]", + ], + ) + def test_workflow_resolve_escapes_rich_markup_in_step_id( + self, project_dir, monkeypatch, step_id + ): + """Step IDs are unvalidated for brackets, so they must be escaped.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": step_id, "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "resolve", "wf"]) + assert result.exit_code == 0, result.output + assert step_id in result.output + + def test_workflow_resolve_equal_priority_layers_sort_by_source(self, project_dir, monkeypatch): + """Equal-priority overlays are listed alphabetically by source.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + # "zzz" sorts last alphabetically, so the composer applies it last and wins. + # Resolver layer output follows the common priority/source sort order. + _write_overlay( + project_dir, + "wf", + "aaa", + { + "id": "aaa", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "aaa-step", "type": "command", "command": "echo"}, + } + ], + }, + ) + _write_overlay( + project_dir, + "wf", + "zzz", + { + "id": "zzz", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "insert_after", + "anchor": "a", + "step": {"id": "zzz-step", "type": "command", "command": "echo"}, + } + ], + }, + ) + + result = runner.invoke(app, ["workflow", "resolve", "wf"]) + assert result.exit_code == 0, result.output + zzz_pos = result.output.index("project:zzz") + aaa_pos = result.output.index("project:aaa") + assert aaa_pos < zzz_pos diff --git a/tests/specify_cli/workflows/test_command_resume.py b/tests/specify_cli/workflows/test_command_resume.py new file mode 100644 index 0000000000..c4f0c050c0 --- /dev/null +++ b/tests/specify_cli/workflows/test_command_resume.py @@ -0,0 +1,652 @@ +"""Command-focused workflow tests.""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest + + + +class TestWorkflowJsonOutput: + """Test the --json machine-readable output for run/resume/status.""" + + _WF = """ +schema_version: "1.0" +workflow: + id: "json-wf" + name: "JSON WF" + version: "1.0.0" +steps: + - id: ask + type: gate + message: "Review" + options: [approve, reject] + - id: after + type: shell + run: "echo done" +""" + + def _write_wf(self, project_dir, text, name): + path = project_dir / f"{name}.yml" + path.write_text(text, encoding="utf-8") + return path + + def _invoke(self, project_dir, args): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + return runner.invoke(app, args, catch_exceptions=False) + + def test_resume_json(self, project_dir): + wf = self._write_wf(project_dir, self._WF, "gated3") + rid = json.loads( + self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout + )["run_id"] + # Non-interactive resume re-runs the gate, which pauses again. + resumed = json.loads( + self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout + ) + assert resumed["run_id"] == rid + assert resumed["status"] == "paused" + + + +class TestResumeWithInputs: + """Test that `workflow resume` can accept updated workflow inputs.""" + + _WF_NUM = """ +schema_version: "1.0" +workflow: + id: "resume-num-wf" + name: "Resume Num WF" + version: "1.0.0" +inputs: + count: + type: number + default: 1 +steps: + - id: gate + type: gate + message: "Review" + options: [approve, reject] +""" + + def _engine(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + return WorkflowEngine(project_dir) + + def test_cli_resume_input_invalid_format_errors(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(self._WF_NUM) + state = self._engine(project_dir).execute(definition) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["workflow", "resume", state.run_id, "--input", "bogus"] + ) + assert result.exit_code == 1 + assert "Invalid input format" in result.stdout + + + +class TestWorkflowStepStartProgressLine: + """The `run`/`resume` step-progress line must render the step id literally. + + The line is built as ` ▸ []