mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* 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
3857 lines
151 KiB
Python
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")
|