Files
github-spec-kit/src/specify_cli/workflows/_commands.py
Manfred Riem a3e183d069 feat: support tar archives for installs (#3874)
* feat: support tar archives for installs

Add secure .tar.gz and .tgz parity with ZIP installation for extensions, presets, and workflows, including full workflow package preservation.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* chore: clean rebased archive imports

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: preserve hardened archive install behavior

Keep malformed ZIP diagnostics, filesystem-independent manifest selection, and reserved workflow overlays consistent after adding generic archive support.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: extract staged workflow archives by descriptor

Avoid reopening a held staging path on Windows while retaining authoritative-inode archive validation.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: extract catalog archives from verified bytes

Use the already bounded and SHA-verified response bytes directly so Windows file-sharing semantics cannot affect archive detection.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: address archive install review feedback

Preserve forced preset reinstalls, sniff suffixless workflow archives without weakening YAML limits, and restore prior workflow packages before failed-install cleanup.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

---------

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd
2026-07-31 07:23:00 -05:00

3857 lines
151 KiB
Python

"""specify workflow * command handlers — app objects and register().
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.
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import sys
from pathlib import Path, PurePosixPath
from typing import Any
import typer
import yaml
from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
archive_format_from_content_type,
archive_format_from_name,
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 ..shared_infra import verify_archive_sha256
workflow_app = typer.Typer(
name="workflow",
help="Manage and run automation workflows",
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
stream stays parseable, the normal console otherwise. Mirrors the
stderr-only error routing already used by ``specify bundle``.
"""
return err_console if json_output else console
def _open_workflow_registry(project_root: Path, out=None):
"""Construct a WorkflowRegistry, exiting cleanly on an unreadable file.
WorkflowRegistry fails closed (raises OSError) at construction when its
file can't be read, rather than falling back to an empty registry a
caller could mistake for "nothing installed". Every CLI command that
opens a registry needs this same clean-error boundary.
"""
from .catalog import WorkflowRegistry
try:
return WorkflowRegistry(project_root)
except OSError as exc:
(out or console).print(
f"[red]Error:[/red] Failed to read workflow registry: {_escape_markup(str(exc))}"
)
raise typer.Exit(1)
def _require_enabled_workflow(
registry_root: Path, workflow_id: str, out: Any
) -> bool:
"""Fail closed for corrupted or explicitly disabled registry entries."""
metadata = _open_workflow_registry(registry_root, out).get(workflow_id)
if metadata is not None and not isinstance(metadata, dict):
out.print(
f"[red]Error:[/red] Registry entry for "
f"'{_escape_markup(workflow_id)}' is corrupted"
)
raise typer.Exit(1)
if isinstance(metadata, dict) and not metadata.get("enabled", True):
out.print(
f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is disabled. "
f"Enable with: specify workflow enable {_escape_markup(workflow_id)}"
)
raise typer.Exit(1)
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
def _parse_input_values(
input_values: list[str] | None, *, json_output: bool = False
) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
on any entry missing ``=``.
"""
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
_error_console(json_output).print(
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
)
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
return inputs
def _reject_unsafe_dir(path: Path, label: str) -> None:
"""Refuse to proceed when *path* is a symlink or an existing non-directory.
A symlinked ``.specify`` (or ``.specify/workflows``) could redirect
workflow writes outside the project root, so any command that creates or
writes files beneath it must bail first. Absence is tolerated — the caller
creates the directory — only an existing-but-wrong target is rejected.
"""
if path.is_symlink():
err_console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
raise typer.Exit(1)
if path.exists() and not path.is_dir():
err_console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
raise typer.Exit(1)
def _reject_unsafe_workflow_storage(project_root: Path) -> None:
"""Refuse symlinked workflow storage directories before workflow commands run."""
_reject_unsafe_dir(project_root / ".specify", ".specify")
_reject_unsafe_dir(project_root / ".specify" / "workflows", ".specify/workflows")
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "runs",
".specify/workflows/runs",
)
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "overlays",
".specify/workflows/overlays",
)
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
"""Find the *nearest* (innermost) ``.specify/workflows/<id>`` 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/<id>`` -- collapsing ``..``/``.`` but
never resolving symlinks, so a symlinked ``workflow.yml`` leaf (or
symlinked ``<id>`` 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/<id>`` 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)
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
if is_safe_download_redirect(old_url, new_url):
return
raise urllib.error.URLError(
"redirect target must use HTTPS without entering a local target; "
"loopback HTTP may only redirect from another loopback URL"
)
# Workflow YAML definitions are small step/metadata text, not binaries, so
# this is generous headroom against a malicious or misbehaving server -- not
# 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:
"""Read *response* fully, enforcing *max_bytes* via bounded streaming.
A ``Content-Length`` header is checked up front to fail fast, but it is
never trusted alone: the actual bytes read are also counted as they
stream in, so a chunked or ``Content-Length``-less response that lies
about (or omits) its size still cannot exceed the limit.
``max_bytes`` defaults to ``None`` (resolved to the module-level
``_MAX_WORKFLOW_YAML_BYTES`` at call time, not at function-definition
time) so tests can override the effective limit via monkeypatching the
module attribute.
"""
if max_bytes is None:
max_bytes = _MAX_WORKFLOW_YAML_BYTES
content_length = None
getheader = getattr(response, "getheader", None)
if callable(getheader):
try:
raw_length = getheader("Content-Length")
except Exception:
raw_length = None
if raw_length is not None:
try:
content_length = int(raw_length)
except (TypeError, ValueError):
content_length = None
if content_length is not None and content_length > max_bytes:
raise ValueError(
f"response declared {content_length} bytes, exceeding the "
f"{max_bytes}-byte workflow size limit"
)
chunks: list[bytes] = []
total = 0
while True:
chunk = response.read(_DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise ValueError(f"response exceeds the {max_bytes}-byte workflow size limit")
chunks.append(chunk)
return b"".join(chunks)
def _workflow_yaml_is_declared(
source_name: str, content_type: str | None
) -> bool:
"""Return whether response metadata explicitly identifies workflow YAML."""
from urllib.parse import urlparse
path = urlparse(source_name).path.casefold()
media_type = (content_type or "").split(";", 1)[0].strip().casefold()
return path.endswith((".yml", ".yaml")) or media_type in {
"application/yaml",
"application/x-yaml",
"text/yaml",
"text/x-yaml",
}
def _sniff_workflow_archive_format(data: bytes):
"""Return a supported archive format when suffixless response bytes match."""
from io import BytesIO
try:
return detect_archive_format(
Path("workflow-download"),
archive_file=BytesIO(data),
)
except ValueError:
return None
def _enforce_workflow_yaml_size(data: bytes) -> None:
if len(data) > _MAX_WORKFLOW_YAML_BYTES:
raise ValueError(
f"response exceeds the {_MAX_WORKFLOW_YAML_BYTES}-byte workflow size limit"
)
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
if (
workflow_id in _RESERVED_WORKFLOW_IDS
or not _WORKFLOW_ID_PATTERN.fullmatch(workflow_id)
):
console.print(
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
)
raise typer.Exit(1)
def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path:
"""Validate the per-id install directory before any write and return it.
Installs write to ``workflows_dir / <id> / workflow.yml``. The ``<id>``
segment comes from a workflow YAML or catalog key, so it must be checked
before ``mkdir``/copy/download follows a symlink outside the project root.
Rejects, with a clean ``typer.Exit``:
- an ``<id>`` that is a symlink or an existing non-directory
(the latter would otherwise make ``mkdir`` raise);
- an ``<id>`` that is not a single workflow-id path segment or collides
with internal workflow storage directories;
- an ``<id>`` that escapes ``workflows_dir`` (path traversal);
- an ``<id>/workflow.yml`` leaf that is a symlink or an existing
non-file (either would otherwise make the later write/copy raise).
The symlink/non-directory check runs *before* ``resolve()`` so a symlinked
``<id>`` reports as a symlink rather than misleadingly as path traversal.
``workflow_id`` is markup-escaped in output to avoid Rich markup injection.
"""
safe_id = _escape_markup(workflow_id)
_validate_workflow_id_or_exit(workflow_id)
dest_dir = workflows_dir / workflow_id
_reject_unsafe_dir(dest_dir, f".specify/workflows/{safe_id}")
try:
dest_dir.resolve().relative_to(workflows_dir.resolve())
except ValueError:
# Escape the repr (not the raw id) so backslashes added by repr cannot
# re-expose markup brackets to Rich.
console.print(
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
)
raise typer.Exit(1)
workflow_yml = dest_dir / "workflow.yml"
if workflow_yml.is_symlink():
console.print(
"[red]Error:[/red] Refusing to write through symlinked "
f".specify/workflows/{safe_id}/workflow.yml"
)
raise typer.Exit(1)
if workflow_yml.exists() and not workflow_yml.is_file():
console.print(
"[red]Error:[/red] "
f".specify/workflows/{safe_id}/workflow.yml exists but is not a file"
)
raise typer.Exit(1)
return dest_dir
class _StagedWorkflowFile:
"""Exclusive staging inode kept open until its atomic commit."""
def __init__(self, path: Path, fd: int) -> None:
self.path = path
self.fd = fd
def _write(self, chunks) -> None:
os.lseek(self.fd, 0, os.SEEK_SET)
os.ftruncate(self.fd, 0)
for chunk in chunks:
view = memoryview(chunk)
while view:
written = os.write(self.fd, view)
if written <= 0:
raise OSError("Failed to write staged workflow file")
view = view[written:]
def write_bytes(self, data: bytes) -> None:
self._write((data,))
def verify_path(self) -> None:
import stat
try:
path_stat = self.path.stat(follow_symlinks=False)
open_stat = os.fstat(self.fd)
except OSError as exc:
raise OSError(
"Staged workflow file changed before commit"
) from exc
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError("Staged workflow file changed before commit")
def set_mode(self, mode: int) -> None:
if hasattr(os, "fchmod"):
os.fchmod(self.fd, mode)
def close(self) -> None:
if self.fd < 0:
return
fd, self.fd = self.fd, -1
try:
os.close(fd)
except OSError:
pass
def _stage_workflow_file(
dest_dir: Path, *, use_project_file_mode: bool = False
) -> _StagedWorkflowFile:
"""Reserve a same-directory staging file so new/updated workflow.yml
content can be written and validated without ever touching (and risking
truncating) an existing destination file before the final atomic swap.
Shared by the local-install and catalog-install paths.
If dest_dir did not already exist, this call creates it; if mkstemp then
fails (disk full/EMFILE/quota), the freshly-created directory is removed
again via a guarded rmdir (never a broad rmtree, so any concurrently
written content is left untouched) before the original OSError is
re-raised unchanged. A pre-existing dest_dir (reinstall) is never
touched by this cleanup. For catalog-created files,
``use_project_file_mode`` recreates the reserved path exclusively with
mode 0666 so the process umask supplies the normal project-file mode.
The final descriptor remains open so callers write to and verify the
reserved inode rather than reopening a replaceable pathname."""
import tempfile
created_dir = not dest_dir.exists()
dest_dir.mkdir(parents=True, exist_ok=True)
fd = -1
staged_file: Path | None = None
try:
fd, tmp_name = tempfile.mkstemp(dir=dest_dir, prefix=".workflow.yml.", suffix=".tmp")
staged_file = Path(tmp_name)
if use_project_file_mode:
os.close(fd)
fd = -1
staged_file.unlink()
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(staged_file, flags, 0o666)
except OSError:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if staged_file is not None:
try:
staged_file.unlink(missing_ok=True)
except OSError:
pass
if created_dir:
try:
dest_dir.rmdir()
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Failed to remove incomplete "
f"workflow directory: {_escape_markup(str(cleanup_exc))}"
)
raise
assert staged_file is not None
return _StagedWorkflowFile(staged_file, fd)
@contextlib.contextmanager
def _workflow_install_transaction(project_root: Path):
"""Serialize workflow file swaps with their registry updates."""
from ..shared_infra import _ensure_safe_shared_directory
lock_dir = project_root / ".specify"
try:
_ensure_safe_shared_directory(
project_root, lock_dir, context="workflow install lock directory"
)
except ValueError as exc:
raise OSError(str(exc)) from exc
lock_file = lock_dir / ".workflow-install.lock"
if lock_file.is_symlink():
raise OSError(f"Refusing to use symlinked workflow install lock: {lock_file}")
flags = os.O_RDWR | os.O_CREAT
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
fd = os.open(lock_file, flags, 0o600)
try:
if lock_file.is_symlink():
raise OSError(
f"Refusing to use symlinked workflow install lock: {lock_file}"
)
if os.name == "nt":
import errno
import msvcrt
import time
if os.fstat(fd).st_size == 0:
os.write(fd, b"\0")
while True:
os.lseek(fd, 0, os.SEEK_SET)
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
break
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EDEADLK):
raise
time.sleep(0.05)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def _commit_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_file: Path,
existed_before: bool,
) -> Path | None:
"""Atomically swap ``staged_file`` onto ``dest_file``. If a prior file
existed, it is first renamed to a unique sibling (path returned) so a
later failure (e.g. registry.add()) can restore it via rename instead
of a content rewrite -- the destination is never truncated/overwritten
in place. If the second rename fails after the first succeeded, the
prior file is put back immediately so dest_file is never left simply
missing."""
staged_path = (
staged_file.path
if isinstance(staged_file, _StagedWorkflowFile)
else staged_file
)
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
if existed_before and dest_file.exists():
import tempfile
dest_state = dest_file.stat(follow_symlinks=False)
mode = dest_state.st_mode & 0o7777
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.set_mode(mode)
else:
staged_path.chmod(mode)
fd, backup_name = tempfile.mkstemp(
dir=dest_file.parent,
prefix=f".{dest_file.name}.",
suffix=".bak",
)
try:
placeholder_state = os.fstat(fd)
finally:
os.close(fd)
backup_file = Path(backup_name)
try:
os.replace(dest_file, backup_file)
except BaseException as move_exc:
backup_state = None
try:
backup_state = backup_file.stat(follow_symlinks=False)
except OSError:
pass
if (
backup_state is not None
and os.path.samestat(dest_state, backup_state)
):
try:
os.replace(backup_file, dest_file)
except OSError as restore_exc:
raise OSError(
f"Failed to stage prior workflow ({move_exc}); failed "
f"to restore it from {backup_file} ({restore_exc}). "
f"The prior workflow remains at {backup_file}."
) from restore_exc
elif (
backup_state is not None
and os.path.samestat(placeholder_state, backup_state)
):
try:
backup_file.unlink(missing_ok=True)
except OSError:
pass
raise
try:
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
# Windows cannot replace an open file. Verify through the
# exclusive descriptor, then close immediately before rename.
staged_file.close()
os.replace(staged_path, dest_file)
except BaseException as commit_exc:
try:
os.replace(backup_file, dest_file)
except OSError as restore_exc:
raise OSError(
f"Failed to commit workflow file ({commit_exc}); failed "
f"to restore the prior workflow from {backup_file} "
f"({restore_exc}). The prior workflow remains at "
f"{backup_file}."
) from restore_exc
raise
return backup_file
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
staged_file.close()
os.replace(staged_path, dest_file)
return None
def _discard_staged_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_dir: Path,
existed_before: bool,
) -> None:
"""Clean up after a pre-commit failure (staged_file was never swapped
onto dest_file): remove the staged file, and for a fresh install (no
prior directory) remove the now-orphaned dest_dir too. A genuine
removal failure must propagate (not be swallowed) so the safe wrapper
below can warn instead of silently leaving an orphan; a dest_dir
already absent is not itself an error."""
staged_path = (
staged_file.path
if isinstance(staged_file, _StagedWorkflowFile)
else staged_file
)
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.close()
staged_path.unlink(missing_ok=True)
if not existed_before and dest_dir.exists():
import errno
try:
dest_dir.rmdir()
except OSError as exc:
# Another concurrent install may already have committed content
# into this once-fresh directory. Never recursively delete it.
if exc.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
def _rollback_committed_workflow_file(
dest_file: Path, dest_dir: Path, existed_before: bool, backup_file: Path | None
) -> None:
"""Undo a successful _commit_workflow_file swap after a later failure
(registry.add()): restore the prior file via rename, remove the newly
committed file for a reinstall over a pre-existing empty directory
(no backup), or remove the new file and then its directory when empty
for a fresh install. A genuine removal failure must propagate (not be
swallowed) so the safe wrapper below can warn instead of silently
leaving an orphan; a dest_dir already absent is not itself an error."""
if backup_file is not None:
os.replace(backup_file, dest_file)
else:
dest_file.unlink(missing_ok=True)
if not existed_before and dest_dir.exists():
import errno
try:
dest_dir.rmdir()
except OSError as exc:
# Another installer may have staged a sibling before taking
# the transaction lock. Preserve it rather than recursively
# deleting the shared directory during this rollback.
if exc.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
def _safe_discard_staged_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_dir: Path,
existed_before: bool,
) -> None:
"""Guarded wrapper: a cleanup failure must be reported, never crash or
silently mask the original install error that triggered it."""
try:
_discard_staged_workflow_file(staged_file, dest_dir, existed_before)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Failed to clean up incomplete workflow "
f"install: {_escape_markup(str(exc))}"
)
def _safe_rollback_committed_workflow_file(
dest_file: Path, dest_dir: Path, existed_before: bool, backup_file: Path | None
) -> None:
"""Guarded wrapper: a rollback failure must be reported, never crash or
silently claim the prior workflow file was restored when it wasn't."""
try:
_rollback_committed_workflow_file(dest_file, dest_dir, existed_before, backup_file)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Failed to restore prior workflow file "
f"after registry update failure: {_escape_markup(str(exc))}"
)
def _discard_committed_backup_file(backup_file: Path | None) -> None:
"""Once registry.add()/registry.remove() has durably succeeded after a
_commit_workflow_file() swap, the renamed-aside prior file is no longer
needed for rollback -- it must be discarded, not left as a permanent
orphan sibling that every future reinstall would silently accumulate or
clobber. A cleanup failure here must not turn an already-successful
install into a reported failure; it's reported as a warning, consistent
with workflow_remove's post-commit cleanup semantics. A fresh install
(backup_file is None) is a no-op."""
if backup_file is None:
return
try:
backup_file.unlink(missing_ok=True)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Workflow installed, but its backup file "
f"could not be cleaned up: {_escape_markup(str(exc))}. Remove it "
f"manually: {_escape_markup(str(backup_file))}"
)
def _workflow_package_root(extracted_root: Path) -> Path:
"""Resolve a root-level or single-nested workflow package."""
if (extracted_root / "workflow.yml").is_file():
return extracted_root
entries = list(extracted_root.iterdir())
if (
len(entries) == 1
and entries[0].is_dir()
and not entries[0].is_symlink()
and (entries[0] / "workflow.yml").is_file()
):
return entries[0]
raise ValueError(
"Archive must contain workflow.yml at its root or in exactly one "
"top-level directory"
)
def _validate_local_workflow_package(package_dir: Path) -> None:
"""Reject links and special files before copying a local package."""
import stat
for root, dirnames, filenames in os.walk(package_dir, followlinks=False):
root_path = Path(root)
for name in [*dirnames, *filenames]:
path = root_path / name
mode = path.lstat().st_mode
if stat.S_ISLNK(mode):
raise ValueError(f"Workflow package contains symlink: {path}")
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
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,
package_dir: Path,
source_label: str,
*,
expected_id: str | None = None,
expected_version: str | None = None,
expected_installed_version: str | None = None,
catalog_info: dict[str, Any] | None = None,
) -> None:
"""Validate and atomically install a complete workflow package directory."""
import shutil
import tempfile
from .engine import WorkflowDefinition, validate_workflow
workflow_file = package_dir / "workflow.yml"
try:
_validate_local_workflow_package(package_dir)
workflow_bytes = workflow_file.read_bytes()
definition = WorkflowDefinition.from_string(workflow_bytes.decode("utf-8"))
except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc:
console.print(
f"[red]Error:[/red] Invalid workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
errors = validate_workflow(definition)
if errors:
console.print("[red]Error:[/red] Workflow validation failed:")
for error in errors:
console.print(f"{_escape_markup(str(error))}")
raise typer.Exit(1)
if not isinstance(definition.id, str) or not definition.id.strip():
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
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 "
f"({_escape_markup(repr(definition.id))}) does not match the requested "
f"workflow ID ({_escape_markup(repr(expected_id))})."
)
raise typer.Exit(1)
if expected_version is not None and str(definition.version) != expected_version:
console.print(
f"[red]Error:[/red] Downloaded workflow version "
f"({_escape_markup(str(definition.version))}) does not match the catalog "
f"version ({_escape_markup(expected_version)})."
)
raise typer.Exit(1)
dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id)
staged_dir = Path(
tempfile.mkdtemp(prefix=f".{definition.id}.installing-", dir=workflows_dir)
)
try:
package_root = package_dir.resolve()
def ignore_reserved_package_entries(
source: str, names: list[str]
) -> set[str]:
if Path(source).resolve() == package_root and "overlays" in names:
return {"overlays"}
return set()
shutil.copytree(
package_dir,
staged_dir,
dirs_exist_ok=True,
ignore=ignore_reserved_package_entries,
)
except OSError as exc:
shutil.rmtree(staged_dir, ignore_errors=True)
console.print(
f"[red]Error:[/red] Failed to stage workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
backup_dir: Path | None = None
try:
with _workflow_install_transaction(project_root):
registry = _open_workflow_registry(project_root)
existing = registry.get(definition.id)
if expected_installed_version is not None and (
not isinstance(existing, dict)
or existing.get("source") != "catalog"
or str(existing.get("version")) != expected_installed_version
):
console.print(
f"[yellow]Warning:[/yellow] Workflow "
f"'{_escape_markup(definition.id)}' changed during update; "
"rerun the command."
)
raise typer.Exit(1)
if dest_dir.exists():
backup_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.backup-",
dir=workflows_dir,
)
)
backup_dir.rmdir()
os.replace(dest_dir, backup_dir)
try:
os.replace(staged_dir, dest_dir)
except BaseException:
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
raise
entry = {
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": source_label,
}
if catalog_info is not None:
entry.update(
{
"source": "catalog",
"catalog_name": catalog_info.get("_catalog_name", ""),
"url": catalog_info.get("url", ""),
}
)
if isinstance(existing, dict) and not existing.get("enabled", True):
entry["enabled"] = False
try:
registry.add(definition.id, entry)
except (OSError, TypeError, ValueError):
failed_dir: Path | None = None
try:
failed_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.failed-",
dir=workflows_dir,
)
)
failed_dir.rmdir()
os.replace(dest_dir, failed_dir)
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
except OSError as rollback_exc:
console.print(
"[yellow]Warning:[/yellow] Failed to fully restore the prior "
f"workflow package: {_escape_markup(str(rollback_exc))}"
)
finally:
if failed_dir is not None and failed_dir.exists():
try:
shutil.rmtree(failed_dir)
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove failed "
f"workflow package: {_escape_markup(str(cleanup_exc))}"
)
raise
except typer.Exit:
raise
except (OSError, TypeError, ValueError) as exc:
console.print(
f"[red]Error:[/red] Failed to install workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
finally:
if staged_dir.exists():
shutil.rmtree(staged_dir, ignore_errors=True)
if backup_dir is not None:
try:
shutil.rmtree(backup_dir)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Workflow installed, but its backup "
f"directory could not be removed: {_escape_markup(str(exc))}"
)
console.print(
f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' "
f"({_escape_markup(definition.id)}) installed"
)
# Root helper re-fetched at call time so test monkeypatching of
# `specify_cli._require_specify_project` keeps working after the move.
def _require_specify_project(*args, **kwargs):
from .. import _require_specify_project as _f
project_root = _f(*args, **kwargs)
_reject_unsafe_workflow_storage(project_root)
return project_root
def _failed_step_error(state: Any) -> str | None:
"""Terminal error for a failed/aborted run, if any.
Returns the run-level error persisted by the engine at the moment
the run terminated. Returns ``None`` for non-terminal statuses so
the caller can print unconditionally.
"""
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
return None
return getattr(state, "error", None)
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
payload = {
"run_id": state.run_id,
"workflow_id": state.workflow_id,
"status": state.status.value,
"current_step_id": state.current_step_id,
"current_step_index": state.current_step_index,
}
gate = _gate_outcome(state)
if gate is not None:
payload["gate"] = gate
error = _failed_step_error(state)
if error is not None:
payload["error"] = error
return payload
def _is_gate_step(step: dict[str, Any]) -> bool:
"""Whether a recorded step result is a gate.
Prefers the persisted ``type`` field, but when it is absent — a run paused
by an older version, whose step record predates ``type`` being stored —
falls back to the gate's unique output signature: only ``GateStep`` writes
an ``on_reject`` key. A record carrying a *different* known ``type`` is not
a gate, so the fallback applies only when ``type`` is missing entirely.
"""
step_type = step.get("type")
if step_type == "gate":
return True
if step_type:
return False
output = step.get("output")
return isinstance(output, dict) and "on_reject" in output
def _gate_outcome(state: Any) -> dict[str, Any] | None:
"""Gate detail for the structured outcome, when the run rests at a gate.
A paused or gate-aborted run is otherwise indistinguishable from any
other pause/abort in the machine-readable payload; surfacing the gate's
prompt, options, and (after an interactive choice) the decision lets
orchestrators drive review gates without parsing the human-facing stream.
"""
# Two run states rest *on* a gate: `paused` (awaiting a decision) and
# `aborted` (a gate rejected with `on_reject: abort` — the only path that
# sets ABORTED, leaving current_step_id on that gate). Any other status —
# notably `completed`/`failed` — must be suppressed: current_step_id is
# not cleared when a run whose last executed step was a gate moves on, so
# without this guard it would surface stale detail (run/resume/status).
if getattr(state.status, "value", state.status) not in ("paused", "aborted"):
return None
step = (getattr(state, "step_results", None) or {}).get(state.current_step_id)
if not isinstance(step, dict) or not _is_gate_step(step):
return None
output = step.get("output") or {}
# `message`, `options`, and `choice` may be non-string YAML literals in an
# unvalidated workflow (GateStep coerces none of them for the payload), so
# normalise all three for a stable JSON schema: message → str, options →
# list[str] | None, choice → str | None (None means no decision yet).
message = output.get("message")
choice = output.get("choice")
return {
"step_id": state.current_step_id,
"message": None if message is None else str(message),
"options": _normalize_gate_options(output.get("options")),
"choice": None if choice is None else str(choice),
}
def _normalize_gate_options(options: Any) -> list[str] | None:
"""Normalise a gate's ``options`` to a stable ``list[str]`` (or ``None``).
A valid gate stores a list, but an unvalidated workflow could leave a
scalar or tuple. ``None`` stays ``None`` (no options); a list/tuple maps
each element through ``str``; any other scalar becomes a single-element
list — so the emitted JSON schema is always ``list[str] | None``. A bare
string is treated as one option, never iterated character-by-character.
"""
if options is None:
return None
if isinstance(options, (list, tuple)):
return [str(o) for o in options]
return [str(options)]
def _run_outcome_exit_code(status_value: str) -> int:
"""Exit code for a finished run/resume: non-zero on terminal failure.
``failed`` and ``aborted`` map to 1 so scripts and orchestrators can
rely on the process exit code; ``completed`` and ``paused`` map to 0
(paused is a legitimate waiting state, not a failure).
"""
return 1 if status_value in ("failed", "aborted") else 0
def _emit_workflow_json(payload: dict[str, Any]) -> None:
"""Write a workflow payload as machine-readable JSON to stdout.
Uses the builtin ``print`` rather than ``console.print`` so Rich
markup interpretation, syntax highlighting, and line-wrapping can
never alter the emitted JSON.
"""
print(json.dumps(payload, indent=2))
@contextlib.contextmanager
def _stdout_to_stderr_when(active: bool):
"""Redirect everything written to stdout onto stderr while *active*.
Suppressing the banner and the step-start callback is not enough to
keep a ``--json`` stream clean: individual steps may still write to
stdout while the engine runs — the gate step prints its prompt,
and the prompt step runs a subprocess that inherits the process's
stdout file descriptor. Either would corrupt the single JSON object.
Redirecting at the file-descriptor level (``dup2``) captures both
Python-level writes and inherited-fd subprocess output, so step
progress lands on stderr (still visible to a human) while stdout
carries only the emitted JSON. A no-op when *active* is false.
"""
if not active:
yield
return
sys.stdout.flush()
saved_stdout_fd = os.dup(1)
try:
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
with contextlib.redirect_stdout(sys.stderr):
yield
finally:
sys.stdout.flush()
os.dup2(saved_stdout_fd, 1) # restore the real stdout
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 `[<step id>]` 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 `\[<type>]` 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)
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: {exc}")
raise typer.Exit(1)
# 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)
# Parse inputs
inputs = _parse_input_values(input_values, json_output=json_output)
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")
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] {exc}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Workflow failed:[/red] {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 `[<step id>]` 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 `\[<type>]` 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)
# 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 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:
err.print(f"[red]Resume failed:[/red] {_escape_markup(str(exc))}")
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)
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))}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Resume failed:[/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}]")
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)
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()
if not installed:
console.print("[yellow]No workflows installed.[/yellow]")
console.print("\nInstall a workflow with:")
console.print(" [cyan]specify workflow add <workflow-id>[/cyan]")
return
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()
@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 .engine import WorkflowDefinition
project_root = _require_specify_project()
_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")
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")
)
except OSError as exc:
console.print(
f"[red]Error:[/red] Failed to read workflow YAML: "
f"{_escape_markup(str(exc))}"
)
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),
)
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)}"
)
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
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),
)
)
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:
raise
except Exception as exc:
if tmp_path is not None:
# 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.
try:
tmp_path.unlink(missing_ok=True)
except OSError as cleanup_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))})"
)
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
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
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),
)
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),
)
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 pkg_version.InvalidVersion:
return str(actual) == expected
safe_wf_id = _escape_markup(workflow_id)
catalog = WorkflowCatalog(project_root)
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")
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 <id>, 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))}")
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 `[<type>]`
# 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 <id>[/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
meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {}
except Exception as exc:
console.print(f"[red]Error:[/red] Invalid step.yml: {exc}")
raise typer.Exit(1)
if 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)
if type_key != step_id:
console.print(
f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match "
f"catalog ID ({step_id!r})"
)
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}"
)
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()
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"
)
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}"
)
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"
)
raise typer.Exit(1)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_content)
except OSError as exc:
console.print(
f"[red]Error:[/red] Failed to write extra file '{rel_path}': {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,
},
)
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."
)
@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
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.
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
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
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}"
)
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]")
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]"
)
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")
@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
project_root = _require_specify_project()
if workflow_overlay_add(project_root, source, priority) is None:
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority):
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if not workflow_overlay_enable(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if not workflow_overlay_disable(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if not workflow_overlay_remove(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if workflow_overlay_list(project_root, workflow_id) is None:
raise typer.Exit(1)
@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
project_root = _require_specify_project()
if workflow_resolve(project_root, workflow_id) is None:
raise typer.Exit(1)
def register(app: typer.Typer) -> None:
"""Attach the workflow command group to the root Typer app."""
app.add_typer(workflow_app, name="workflow")