Skip to content

Commit 3e30520

Browse files
Copilotmnriem
andauthored
Address PR review: atomic install, hostname validation, cache resilience, no dynamic imports in list/info
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/3e18fef0-e2e6-4b3e-9e8d-9adb1e5e464e Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
1 parent df061e0 commit 3e30520

3 files changed

Lines changed: 71 additions & 94 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 52 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5346,65 +5346,31 @@ def workflow_step_list():
53465346
project_root = Path.cwd()
53475347
specify_dir = project_root / ".specify"
53485348

5349-
# Load custom steps if in a spec-kit project
5350-
custom_keys: set[str] = set()
5349+
# Read installed custom steps from registry only — no dynamic imports
5350+
installed: dict = {}
53515351
if specify_dir.exists():
5352-
from .workflows import load_custom_steps
5353-
loaded = load_custom_steps(project_root)
5354-
custom_keys.update(loaded)
5355-
# Also read registry for metadata
53565352
registry = StepRegistry(project_root)
53575353
installed = registry.list()
5358-
custom_keys.update(installed.keys())
53595354

53605355
console.print("\n[bold cyan]Installed Step Types:[/bold cyan]\n")
53615356

5362-
built_in: list[str] = []
5363-
custom: list[str] = []
5364-
for key in sorted(STEP_REGISTRY.keys()):
5365-
if key in custom_keys:
5366-
custom.append(key)
5367-
else:
5368-
built_in.append(key)
5369-
5357+
built_in = sorted(k for k in STEP_REGISTRY if k not in installed)
53705358
if built_in:
53715359
console.print(" [bold]Built-in:[/bold]")
53725360
for key in built_in:
53735361
console.print(f" • {key}")
53745362
console.print()
53755363

5376-
if custom:
5364+
if installed:
53775365
console.print(" [bold]Custom (installed):[/bold]")
5378-
if specify_dir.exists():
5379-
registry = StepRegistry(project_root)
5380-
for key in custom:
5381-
meta = registry.get(key) or {}
5382-
name = meta.get("name", key)
5383-
version = meta.get("version", "?")
5384-
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
5385-
else:
5386-
for key in custom:
5387-
console.print(f" • {key}")
5366+
for key in sorted(installed):
5367+
meta = installed[key] or {}
5368+
name = meta.get("name", key)
5369+
version = meta.get("version", "?")
5370+
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
53885371
console.print()
53895372

5390-
# Show custom steps that are installed but failed to load
5391-
if specify_dir.exists():
5392-
registry = StepRegistry(project_root)
5393-
installed = registry.list()
5394-
failed = [
5395-
k for k in sorted(installed.keys())
5396-
if k not in STEP_REGISTRY
5397-
]
5398-
if failed:
5399-
console.print(" [bold yellow]Custom (installed, failed to load):[/bold yellow]")
5400-
for key in failed:
5401-
meta = installed.get(key, {})
5402-
name = meta.get("name", key)
5403-
version = meta.get("version", "?")
5404-
console.print(f" • [dim]{name}[/dim] ({key}) v{version}")
5405-
console.print()
5406-
5407-
if not built_in and not custom:
5373+
if not built_in and not installed:
54085374
console.print("[yellow]No step types found.[/yellow]")
54095375

54105376
if specify_dir.exists():
@@ -5505,48 +5471,55 @@ def _safe_fetch(url: str) -> bytes:
55055471
except ValueError:
55065472
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
55075473
raise typer.Exit(1)
5508-
step_dir.mkdir(parents=True, exist_ok=True)
55095474

5510-
try:
5511-
step_yml_content = _safe_fetch(step_yml_url)
5512-
init_py_content = _safe_fetch(init_url)
5513-
except Exception as exc:
5514-
import shutil
5515-
shutil.rmtree(step_dir, ignore_errors=True)
5516-
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
5517-
raise typer.Exit(1)
5475+
import shutil
5476+
import tempfile
55185477

5519-
# Validate step.yml
5478+
# Download and validate in a temp directory first; only move to the final
5479+
# location on success so a transient failure can never corrupt or delete a
5480+
# pre-existing directory at step_dir.
5481+
tmp_path = Path(tempfile.mkdtemp(prefix="speckit_step_"))
5482+
_install_success = False
55205483
try:
5521-
import yaml as _yaml
5484+
try:
5485+
step_yml_content = _safe_fetch(step_yml_url)
5486+
init_py_content = _safe_fetch(init_url)
5487+
except Exception as exc:
5488+
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
5489+
raise typer.Exit(1)
55225490

5523-
meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {}
5524-
except Exception as exc:
5525-
import shutil
5526-
shutil.rmtree(step_dir, ignore_errors=True)
5527-
console.print(f"[red]Error:[/red] Invalid step.yml: {exc}")
5528-
raise typer.Exit(1)
5491+
# Validate step.yml
5492+
try:
5493+
import yaml as _yaml
55295494

5530-
step_meta = meta.get("step", {})
5531-
type_key = step_meta.get("type_key", "")
5532-
if not type_key:
5533-
import shutil
5534-
shutil.rmtree(step_dir, ignore_errors=True)
5535-
console.print("[red]Error:[/red] step.yml missing 'step.type_key' field")
5536-
raise typer.Exit(1)
5495+
meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {}
5496+
except Exception as exc:
5497+
console.print(f"[red]Error:[/red] Invalid step.yml: {exc}")
5498+
raise typer.Exit(1)
55375499

5538-
if type_key != step_id:
5539-
import shutil
5540-
shutil.rmtree(step_dir, ignore_errors=True)
5541-
console.print(
5542-
f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match "
5543-
f"catalog ID ({step_id!r})"
5544-
)
5545-
raise typer.Exit(1)
5500+
step_meta = meta.get("step", {})
5501+
type_key = step_meta.get("type_key", "")
5502+
if not type_key:
5503+
console.print("[red]Error:[/red] step.yml missing 'step.type_key' field")
5504+
raise typer.Exit(1)
55465505

5547-
# Write files
5548-
(step_dir / "step.yml").write_bytes(step_yml_content)
5549-
(step_dir / "__init__.py").write_bytes(init_py_content)
5506+
if type_key != step_id:
5507+
console.print(
5508+
f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match "
5509+
f"catalog ID ({step_id!r})"
5510+
)
5511+
raise typer.Exit(1)
5512+
5513+
# Write validated files to temp location, then move atomically
5514+
(tmp_path / "step.yml").write_bytes(step_yml_content)
5515+
(tmp_path / "__init__.py").write_bytes(init_py_content)
5516+
5517+
steps_base_dir.mkdir(parents=True, exist_ok=True)
5518+
shutil.move(str(tmp_path), str(step_dir))
5519+
_install_success = True
5520+
finally:
5521+
if not _install_success:
5522+
shutil.rmtree(tmp_path, ignore_errors=True)
55505523

55515524
# Register in step registry
55525525
registry = StepRegistry(project_root)
@@ -5659,10 +5632,6 @@ def workflow_step_info(
56595632
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
56605633
raise typer.Exit(1)
56615634

5662-
# Load custom steps so registry is up to date
5663-
from .workflows import load_custom_steps
5664-
load_custom_steps(project_root)
5665-
56665635
registry = StepRegistry(project_root)
56675636
installed_meta = registry.get(step_id)
56685637

src/specify_cli/workflows/catalog.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,14 @@ def _validate_catalog_url(url: str) -> None:
359359
)
360360

361361
# Write cache
362-
self.cache_dir.mkdir(parents=True, exist_ok=True)
363-
with open(cache_file, "w", encoding="utf-8") as f:
364-
json.dump(data, f, indent=2)
365-
with open(meta_file, "w", encoding="utf-8") as f:
366-
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
362+
try:
363+
self.cache_dir.mkdir(parents=True, exist_ok=True)
364+
with open(cache_file, "w", encoding="utf-8") as f:
365+
json.dump(data, f, indent=2)
366+
with open(meta_file, "w", encoding="utf-8") as f:
367+
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
368+
except OSError:
369+
pass # Proceed without caching if disk write fails
367370

368371
return data
369372

@@ -692,7 +695,7 @@ def _validate_catalog_url(self, url: str) -> None:
692695
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
693696
"HTTP is only allowed for localhost."
694697
)
695-
if not parsed.netloc:
698+
if not parsed.hostname:
696699
raise StepValidationError(
697700
"Catalog URL must be a valid URL with a host."
698701
)
@@ -880,11 +883,14 @@ def _validate_url(url: str) -> None:
880883
f"Catalog from {entry.url} is not a valid JSON object."
881884
)
882885

883-
self.cache_dir.mkdir(parents=True, exist_ok=True)
884-
with open(cache_file, "w", encoding="utf-8") as f:
885-
json.dump(data, f, indent=2)
886-
with open(meta_file, "w", encoding="utf-8") as f:
887-
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
886+
try:
887+
self.cache_dir.mkdir(parents=True, exist_ok=True)
888+
with open(cache_file, "w", encoding="utf-8") as f:
889+
json.dump(data, f, indent=2)
890+
with open(meta_file, "w", encoding="utf-8") as f:
891+
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
892+
except OSError:
893+
pass # Proceed without caching if disk write fails
888894

889895
return data
890896

tests/test_workflows.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import json
1616
import shutil
17+
import sys
1718
import tempfile
1819
from pathlib import Path
1920

@@ -1937,6 +1938,7 @@ def test_registry_missing_steps_key_resets(self, project_dir):
19371938
registry2.add("deploy", {"name": "Deploy", "type_key": "deploy"})
19381939
assert registry2.is_installed("deploy")
19391940

1941+
@pytest.mark.skipif(sys.platform == "win32", reason="chmod not reliable on Windows")
19401942
def test_registry_unreadable_file_resets(self, project_dir):
19411943
"""OSError reading the registry file should fall back to default."""
19421944
from specify_cli.workflows.catalog import StepRegistry

0 commit comments

Comments
 (0)