@@ -4294,53 +4294,87 @@ def workflow_add(
42944294 registry = WorkflowRegistry (project_root )
42954295 workflows_dir = project_root / ".specify" / "workflows"
42964296
4297+ def _validate_and_install_local (yaml_path : Path , source_label : str ) -> None :
4298+ """Validate and install a workflow from a local YAML file."""
4299+ try :
4300+ definition = WorkflowDefinition .from_yaml (yaml_path )
4301+ except (ValueError , yaml .YAMLError ) as exc :
4302+ console .print (f"[red]Error:[/red] Invalid workflow YAML: { exc } " )
4303+ raise typer .Exit (1 )
4304+ if not definition .id or not definition .id .strip ():
4305+ console .print ("[red]Error:[/red] Workflow definition has an empty or missing 'id'" )
4306+ raise typer .Exit (1 )
4307+ dest_dir = workflows_dir / definition .id
4308+ dest_dir .mkdir (parents = True , exist_ok = True )
4309+ import shutil
4310+ shutil .copy2 (yaml_path , dest_dir / "workflow.yml" )
4311+ registry .add (definition .id , {
4312+ "name" : definition .name ,
4313+ "version" : definition .version ,
4314+ "description" : definition .description ,
4315+ "source" : source_label ,
4316+ })
4317+ console .print (f"[green]✓[/green] Workflow '{ definition .name } ' ({ definition .id } ) installed" )
4318+
4319+ # Try as URL (http/https)
4320+ if source .startswith ("http://" ) or source .startswith ("https://" ):
4321+ from ipaddress import ip_address
4322+ from urllib .parse import urlparse
4323+ from urllib .request import urlopen # noqa: S310
4324+
4325+ parsed_src = urlparse (source )
4326+ src_host = parsed_src .hostname or ""
4327+ src_loopback = src_host == "localhost"
4328+ if not src_loopback :
4329+ try :
4330+ src_loopback = ip_address (src_host ).is_loopback
4331+ except ValueError :
4332+ pass
4333+ if parsed_src .scheme != "https" and not (parsed_src .scheme == "http" and src_loopback ):
4334+ console .print ("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost." )
4335+ raise typer .Exit (1 )
4336+
4337+ import tempfile
4338+ try :
4339+ with urlopen (source , timeout = 30 ) as resp : # noqa: S310
4340+ final_url = resp .geturl ()
4341+ final_parsed = urlparse (final_url )
4342+ final_host = final_parsed .hostname or ""
4343+ final_lb = final_host == "localhost"
4344+ if not final_lb :
4345+ try :
4346+ final_lb = ip_address (final_host ).is_loopback
4347+ except ValueError :
4348+ pass
4349+ if final_parsed .scheme != "https" and not (final_parsed .scheme == "http" and final_lb ):
4350+ console .print (f"[red]Error:[/red] URL redirected to non-HTTPS: { final_url } " )
4351+ raise typer .Exit (1 )
4352+ with tempfile .NamedTemporaryFile (suffix = ".yml" , delete = False ) as tmp :
4353+ tmp .write (resp .read ())
4354+ tmp_path = Path (tmp .name )
4355+ except typer .Exit :
4356+ raise
4357+ except Exception as exc :
4358+ console .print (f"[red]Error:[/red] Failed to download workflow: { exc } " )
4359+ raise typer .Exit (1 )
4360+ try :
4361+ _validate_and_install_local (tmp_path , source )
4362+ finally :
4363+ tmp_path .unlink (missing_ok = True )
4364+ return
4365+
42974366 # Try as a local file/directory
42984367 source_path = Path (source )
42994368 if source_path .exists ():
43004369 if source_path .is_file () and source_path .suffix in (".yml" , ".yaml" ):
4301- # Install from local YAML file
4302- try :
4303- definition = WorkflowDefinition .from_yaml (source_path )
4304- except (ValueError , yaml .YAMLError ) as exc :
4305- console .print (f"[red]Error:[/red] Invalid workflow YAML: { exc } " )
4306- raise typer .Exit (1 )
4307-
4308- dest_dir = workflows_dir / definition .id
4309- dest_dir .mkdir (parents = True , exist_ok = True )
4310- import shutil
4311- shutil .copy2 (source_path , dest_dir / "workflow.yml" )
4312-
4313- registry .add (definition .id , {
4314- "name" : definition .name ,
4315- "version" : definition .version ,
4316- "description" : definition .description ,
4317- "source" : str (source_path ),
4318- })
4319- console .print (f"[green]✓[/green] Workflow '{ definition .name } ' ({ definition .id } ) installed" )
4370+ _validate_and_install_local (source_path , str (source_path ))
43204371 return
43214372 elif source_path .is_dir ():
43224373 wf_file = source_path / "workflow.yml"
43234374 if not wf_file .exists ():
43244375 console .print (f"[red]Error:[/red] No workflow.yml found in { source } " )
43254376 raise typer .Exit (1 )
4326- try :
4327- definition = WorkflowDefinition .from_yaml (wf_file )
4328- except (ValueError , yaml .YAMLError ) as exc :
4329- console .print (f"[red]Error:[/red] Invalid workflow YAML: { exc } " )
4330- raise typer .Exit (1 )
4331-
4332- dest_dir = workflows_dir / definition .id
4333- dest_dir .mkdir (parents = True , exist_ok = True )
4334- import shutil
4335- shutil .copy2 (wf_file , dest_dir / "workflow.yml" )
4336-
4337- registry .add (definition .id , {
4338- "name" : definition .name ,
4339- "version" : definition .version ,
4340- "description" : definition .description ,
4341- "source" : str (source_path ),
4342- })
4343- console .print (f"[green]✓[/green] Workflow '{ definition .name } ' ({ definition .id } ) installed" )
4377+ _validate_and_install_local (wf_file , str (source_path ))
43444378 return
43454379
43464380 # Try from catalog
0 commit comments