Skip to content

Commit 317830c

Browse files
committed
fix: address PR review feedback from Copilot
- Defensive author field handling in WorkflowCatalog.search(): coerce non-string author values to empty string before comparison - Make 'source' argument optional in workflow add when --from is used - Fix type hint: workflow_id in update command is Optional[str] - Add workflow ID mismatch check after download in workflow update - Use temp dir for atomic update: download+validate in staging dir, then swap into place (no partial state on failure) - WorkflowRegistry.update() now sets updated_at timestamp and preserves installed_at - Add 9 CLI integration tests via CliRunner (enable/disable roundtrip, update error paths, add validation, list disabled status)
1 parent 89a62b2 commit 317830c

3 files changed

Lines changed: 184 additions & 47 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 41 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4855,14 +4855,19 @@ def workflow_list():
48554855

48564856
@workflow_app.command("add")
48574857
def workflow_add(
4858-
source: str = typer.Argument(..., help="Workflow ID, URL, or local path"),
4858+
source: Optional[str] = typer.Argument(None, help="Workflow ID, URL, or local path"),
48594859
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
48604860
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
48614861
):
48624862
"""Install a workflow from catalog, URL, or local path."""
48634863
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
48644864
from .workflows.engine import WorkflowDefinition
48654865

4866+
# Validate that at least one source is provided
4867+
if not source and not from_url:
4868+
console.print("[red]Error:[/red] Provide a workflow ID/URL/path, or use --from <url>")
4869+
raise typer.Exit(1)
4870+
48664871
project_root = Path.cwd()
48674872
specify_dir = project_root / ".specify"
48684873
if not specify_dir.exists():
@@ -5304,13 +5309,14 @@ def workflow_info(
53045309

53055310
@workflow_app.command("update")
53065311
def workflow_update(
5307-
workflow_id: str = typer.Argument(None, help="Workflow ID to update (or all)"),
5312+
workflow_id: Optional[str] = typer.Argument(None, help="Workflow ID to update (or all)"),
53085313
):
53095314
"""Update workflow(s) to latest version."""
53105315
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
53115316
from .workflows.engine import WorkflowDefinition, validate_workflow
53125317
from packaging import version as pkg_version
53135318
import shutil
5319+
import tempfile
53145320

53155321
project_root = Path.cwd()
53165322
specify_dir = project_root / ".specify"
@@ -5420,37 +5426,41 @@ def workflow_update(
54205426
continue
54215427

54225428
wf_dir = workflows_dir / wf_id
5423-
backup_dir = workflows_dir / ".backup" / wf_id
54245429
try:
5425-
# Backup existing
5426-
if wf_dir.exists():
5427-
backup_dir.parent.mkdir(parents=True, exist_ok=True)
5428-
if backup_dir.exists():
5429-
shutil.rmtree(backup_dir)
5430-
shutil.copytree(wf_dir, backup_dir)
5431-
5432-
# Download new version
5433-
wf_dir.mkdir(parents=True, exist_ok=True)
5434-
with urlopen(wf_url, timeout=30) as resp: # noqa: S310
5435-
final_url = resp.geturl()
5436-
final_parsed = urlparse(final_url)
5437-
final_host = final_parsed.hostname or ""
5438-
final_lb = final_host == "localhost"
5439-
if not final_lb:
5440-
try:
5441-
final_lb = ip_address(final_host).is_loopback
5442-
except ValueError:
5443-
pass
5444-
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
5445-
raise ValueError(f"Redirected to non-HTTPS: {final_url}")
5446-
wf_file = wf_dir / "workflow.yml"
5447-
wf_file.write_bytes(resp.read())
5430+
# Download and validate in a temporary directory so failures do not
5431+
# leave a partially-created workflow directory behind.
5432+
with tempfile.TemporaryDirectory(prefix=f"{wf_id}-", dir=workflows_dir) as tmp_dir:
5433+
staged_dir = Path(tmp_dir) / wf_id
5434+
staged_dir.mkdir(parents=True, exist_ok=True)
5435+
with urlopen(wf_url, timeout=30) as resp: # noqa: S310
5436+
final_url = resp.geturl()
5437+
final_parsed = urlparse(final_url)
5438+
final_host = final_parsed.hostname or ""
5439+
final_lb = final_host == "localhost"
5440+
if not final_lb:
5441+
try:
5442+
final_lb = ip_address(final_host).is_loopback
5443+
except ValueError:
5444+
pass
5445+
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
5446+
raise ValueError(f"Redirected to non-HTTPS: {final_url}")
5447+
wf_file = staged_dir / "workflow.yml"
5448+
wf_file.write_bytes(resp.read())
5449+
5450+
# Validate staged contents before replacing the live workflow
5451+
definition = WorkflowDefinition.from_yaml(wf_file)
5452+
if definition.id != wf_id:
5453+
raise ValueError(
5454+
f"Workflow ID mismatch: expected '{wf_id}', got '{definition.id}'"
5455+
)
5456+
errors = validate_workflow(definition)
5457+
if errors:
5458+
raise ValueError(f"Validation failed: {'; '.join(errors)}")
54485459

5449-
# Validate
5450-
definition = WorkflowDefinition.from_yaml(wf_file)
5451-
errors = validate_workflow(definition)
5452-
if errors:
5453-
raise ValueError(f"Validation failed: {'; '.join(errors)}")
5460+
# Swap: remove old, move staged into place
5461+
if wf_dir.exists():
5462+
shutil.rmtree(wf_dir)
5463+
shutil.move(str(staged_dir), str(wf_dir))
54545464

54555465
# Update registry
54565466
registry.update(wf_id, {
@@ -5460,22 +5470,8 @@ def workflow_update(
54605470
})
54615471
console.print(f" [green]✓[/green] {wf_id}: {update['installed']}{update['available']}")
54625472

5463-
# Clean up backup
5464-
if backup_dir.exists():
5465-
shutil.rmtree(backup_dir)
5466-
54675473
except Exception as exc:
54685474
console.print(f" [red]✗[/red] {wf_id}: {exc}")
5469-
# Restore from backup
5470-
if backup_dir.exists():
5471-
if wf_dir.exists():
5472-
shutil.rmtree(wf_dir)
5473-
shutil.move(str(backup_dir), str(wf_dir))
5474-
5475-
# Clean up backup directory if empty
5476-
backup_parent = workflows_dir / ".backup"
5477-
if backup_parent.exists() and not any(backup_parent.iterdir()):
5478-
backup_parent.rmdir()
54795475

54805476

54815477
@workflow_app.command("enable")

src/specify_cli/workflows/catalog.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,18 @@ def remove(self, workflow_id: str) -> bool:
109109

110110
def update(self, workflow_id: str, fields: dict[str, Any]) -> None:
111111
"""Update specific fields on an installed workflow entry."""
112+
from datetime import datetime, timezone
113+
112114
if workflow_id not in self.data["workflows"]:
113115
return
114-
self.data["workflows"][workflow_id].update(fields)
116+
117+
existing = self.data["workflows"][workflow_id]
118+
installed_at = existing.get("installed_at")
119+
120+
existing.update(fields)
121+
if installed_at is not None and "installed_at" not in fields:
122+
existing["installed_at"] = installed_at
123+
existing["updated_at"] = datetime.now(timezone.utc).isoformat()
115124
self.save()
116125

117126
def get(self, workflow_id: str) -> dict[str, Any] | None:
@@ -428,7 +437,9 @@ def search(
428437
for wf_id, wf_data in merged.items():
429438
wf_data.setdefault("id", wf_id)
430439
if author:
431-
if wf_data.get("author", "").lower() != author.lower():
440+
raw_author = wf_data.get("author", "")
441+
normalized_author = raw_author if isinstance(raw_author, str) else ""
442+
if normalized_author.lower() != author.lower():
432443
continue
433444
if query:
434445
q = query.lower()

tests/test_workflows.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,13 +1645,19 @@ def test_update(self, project_dir):
16451645
registry = WorkflowRegistry(project_dir)
16461646
registry.add("test-wf", {"name": "Test", "version": "1.0.0"})
16471647

1648+
original_entry = registry.get("test-wf")
1649+
original_installed_at = original_entry["installed_at"]
1650+
16481651
registry.update("test-wf", {"version": "2.0.0", "enabled": False})
16491652

16501653
entry = registry.get("test-wf")
16511654
assert entry["version"] == "2.0.0"
16521655
assert entry["enabled"] is False
16531656
# Original fields preserved
16541657
assert entry["name"] == "Test"
1658+
# installed_at preserved, updated_at set
1659+
assert entry["installed_at"] == original_installed_at
1660+
assert "updated_at" in entry
16551661

16561662
def test_update_nonexistent(self, project_dir):
16571663
from specify_cli.workflows.catalog import WorkflowRegistry
@@ -1914,3 +1920,127 @@ def test_switch_workflow(self, project_dir):
19141920
assert state.status == RunStatus.COMPLETED
19151921
assert "do-plan" in state.step_results
19161922
assert "do-specify" not in state.step_results
1923+
1924+
1925+
# ===== CLI Integration Tests =====
1926+
1927+
1928+
class TestWorkflowCLI:
1929+
"""CLI integration tests for workflow commands via CliRunner."""
1930+
1931+
def test_enable_not_installed(self, project_dir, monkeypatch):
1932+
from typer.testing import CliRunner
1933+
from specify_cli import app
1934+
1935+
runner = CliRunner()
1936+
monkeypatch.chdir(project_dir)
1937+
result = runner.invoke(app, ["workflow", "enable", "nonexistent"])
1938+
assert result.exit_code != 0
1939+
assert "not installed" in result.output
1940+
1941+
def test_disable_not_installed(self, project_dir, monkeypatch):
1942+
from typer.testing import CliRunner
1943+
from specify_cli import app
1944+
1945+
runner = CliRunner()
1946+
monkeypatch.chdir(project_dir)
1947+
result = runner.invoke(app, ["workflow", "disable", "nonexistent"])
1948+
assert result.exit_code != 0
1949+
assert "not installed" in result.output
1950+
1951+
def test_enable_disable_roundtrip(self, project_dir, monkeypatch):
1952+
from typer.testing import CliRunner
1953+
from specify_cli import app
1954+
from specify_cli.workflows.catalog import WorkflowRegistry
1955+
1956+
runner = CliRunner()
1957+
monkeypatch.chdir(project_dir)
1958+
1959+
# Pre-install a workflow in the registry
1960+
registry = WorkflowRegistry(project_dir)
1961+
registry.add("test-wf", {"name": "Test", "version": "1.0.0"})
1962+
1963+
# Disable
1964+
result = runner.invoke(app, ["workflow", "disable", "test-wf"])
1965+
assert result.exit_code == 0
1966+
assert "disabled" in result.output
1967+
1968+
# Already disabled
1969+
result = runner.invoke(app, ["workflow", "disable", "test-wf"])
1970+
assert result.exit_code == 0
1971+
assert "already disabled" in result.output
1972+
1973+
# Enable
1974+
result = runner.invoke(app, ["workflow", "enable", "test-wf"])
1975+
assert result.exit_code == 0
1976+
assert "enabled" in result.output
1977+
1978+
# Already enabled
1979+
result = runner.invoke(app, ["workflow", "enable", "test-wf"])
1980+
assert result.exit_code == 0
1981+
assert "already enabled" in result.output
1982+
1983+
def test_update_no_project(self, tmp_path, monkeypatch):
1984+
from typer.testing import CliRunner
1985+
from specify_cli import app
1986+
1987+
runner = CliRunner()
1988+
monkeypatch.chdir(tmp_path)
1989+
result = runner.invoke(app, ["workflow", "update"])
1990+
assert result.exit_code != 0
1991+
assert "Not a spec-kit project" in result.output
1992+
1993+
def test_update_not_installed(self, project_dir, monkeypatch):
1994+
from typer.testing import CliRunner
1995+
from specify_cli import app
1996+
1997+
runner = CliRunner()
1998+
monkeypatch.chdir(project_dir)
1999+
result = runner.invoke(app, ["workflow", "update", "nonexistent"])
2000+
assert result.exit_code != 0
2001+
assert "not installed" in result.output
2002+
2003+
def test_add_no_source(self, project_dir, monkeypatch):
2004+
from typer.testing import CliRunner
2005+
from specify_cli import app
2006+
2007+
runner = CliRunner()
2008+
monkeypatch.chdir(project_dir)
2009+
result = runner.invoke(app, ["workflow", "add"])
2010+
assert result.exit_code != 0
2011+
2012+
def test_add_from_url_without_source(self, project_dir, monkeypatch):
2013+
from typer.testing import CliRunner
2014+
from specify_cli import app
2015+
2016+
runner = CliRunner()
2017+
monkeypatch.chdir(project_dir)
2018+
# --from with non-HTTPS should be rejected with URL scheme error, not missing source
2019+
result = runner.invoke(app, ["workflow", "add", "--from", "http://evil.example.com/wf.yml"])
2020+
assert result.exit_code != 0
2021+
assert "HTTPS" in result.output
2022+
2023+
def test_add_dev_missing_path(self, project_dir, monkeypatch):
2024+
from typer.testing import CliRunner
2025+
from specify_cli import app
2026+
2027+
runner = CliRunner()
2028+
monkeypatch.chdir(project_dir)
2029+
result = runner.invoke(app, ["workflow", "add", "--dev", "/nonexistent/path"])
2030+
assert result.exit_code != 0
2031+
assert "not found" in result.output.lower() or "Error" in result.output
2032+
2033+
def test_list_shows_disabled(self, project_dir, monkeypatch):
2034+
from typer.testing import CliRunner
2035+
from specify_cli import app
2036+
from specify_cli.workflows.catalog import WorkflowRegistry
2037+
2038+
runner = CliRunner()
2039+
monkeypatch.chdir(project_dir)
2040+
2041+
registry = WorkflowRegistry(project_dir)
2042+
registry.add("test-wf", {"name": "Test WF", "version": "1.0.0", "enabled": False})
2043+
2044+
result = runner.invoke(app, ["workflow", "list"])
2045+
assert result.exit_code == 0
2046+
assert "disabled" in result.output

0 commit comments

Comments
 (0)