@@ -2431,8 +2431,15 @@ def extension_update(
24312431 extension : str = typer .Argument (None , help = "Extension ID to update (or all)" ),
24322432):
24332433 """Update extension(s) to latest version."""
2434- from .extensions import ExtensionManager , ExtensionCatalog , ExtensionError
2434+ from .extensions import (
2435+ ExtensionManager ,
2436+ ExtensionCatalog ,
2437+ ExtensionError ,
2438+ CommandRegistrar ,
2439+ HookExecutor ,
2440+ )
24352441 from packaging import version as pkg_version
2442+ import shutil
24362443
24372444 project_root = Path .cwd ()
24382445
@@ -2445,6 +2452,7 @@ def extension_update(
24452452
24462453 manager = ExtensionManager (project_root )
24472454 catalog = ExtensionCatalog (project_root )
2455+ speckit_version = get_speckit_version ()
24482456
24492457 try :
24502458 # Get list of extensions to update
@@ -2509,24 +2517,163 @@ def extension_update(
25092517 console .print ("Cancelled" )
25102518 raise typer .Exit (0 )
25112519
2512- # Perform updates
2520+ # Perform updates with atomic backup/restore
25132521 console .print ()
2522+ updated_extensions = []
2523+ failed_updates = []
2524+ registrar = CommandRegistrar ()
2525+ hook_executor = HookExecutor (project_root )
2526+
25142527 for update in updates_available :
2515- ext_id = update ["id" ]
2516- console .print (f"📦 Updating { ext_id } ..." )
2528+ extension_id = update ["id" ]
2529+ ext_name = update ["id" ]
2530+ console .print (f"📦 Updating { ext_name } ..." )
25172531
2518- # TODO: Implement download and reinstall from URL
2519- # For now, just show message
2520- console .print (
2521- "[yellow]Note:[/yellow] Automatic update not yet implemented. "
2522- "Please update manually:"
2523- )
2524- console .print (f" specify extension remove { ext_id } --keep-config" )
2525- console .print (f" specify extension add { ext_id } " )
2532+ # Backup paths
2533+ backup_base = manager .extensions_dir / ".backup" / f"{ extension_id } -update"
2534+ backup_ext_dir = backup_base / "extension"
2535+ backup_commands_dir = backup_base / "commands"
25262536
2527- console .print (
2528- "\n [cyan]Tip:[/cyan] Automatic updates will be available in a future version"
2529- )
2537+ # Store backup state
2538+ backup_registry_entry = None
2539+ backup_hooks = None
2540+ backed_up_command_files = {}
2541+
2542+ try :
2543+ # 1. Backup registry entry (always, even if extension dir doesn't exist)
2544+ backup_registry_entry = manager .registry .get (extension_id )
2545+
2546+ # 2. Backup extension directory
2547+ extension_dir = manager .extensions_dir / extension_id
2548+ if extension_dir .exists ():
2549+ backup_base .mkdir (parents = True , exist_ok = True )
2550+ if backup_ext_dir .exists ():
2551+ shutil .rmtree (backup_ext_dir )
2552+ shutil .copytree (extension_dir , backup_ext_dir )
2553+
2554+ # 3. Backup command files for all agents
2555+ registered_commands = backup_registry_entry .get ("registered_commands" , {})
2556+ for agent_name , cmd_names in registered_commands .items ():
2557+ if agent_name not in registrar .AGENT_CONFIGS :
2558+ continue
2559+ agent_config = registrar .AGENT_CONFIGS [agent_name ]
2560+ commands_dir = project_root / agent_config ["dir" ]
2561+
2562+ for cmd_name in cmd_names :
2563+ cmd_file = commands_dir / f"{ cmd_name } { agent_config ['extension' ]} "
2564+ if cmd_file .exists ():
2565+ backup_cmd_path = backup_commands_dir / agent_name / cmd_file .name
2566+ backup_cmd_path .parent .mkdir (parents = True , exist_ok = True )
2567+ shutil .copy2 (cmd_file , backup_cmd_path )
2568+ backed_up_command_files [str (cmd_file )] = str (backup_cmd_path )
2569+
2570+ # Also backup copilot prompt files
2571+ if agent_name == "copilot" :
2572+ prompt_file = project_root / ".github" / "prompts" / f"{ cmd_name } .prompt.md"
2573+ if prompt_file .exists ():
2574+ backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file .name
2575+ backup_prompt_path .parent .mkdir (parents = True , exist_ok = True )
2576+ shutil .copy2 (prompt_file , backup_prompt_path )
2577+ backed_up_command_files [str (prompt_file )] = str (backup_prompt_path )
2578+
2579+ # 4. Backup hooks from extensions.yml
2580+ config = hook_executor .get_project_config ()
2581+ if "hooks" in config :
2582+ backup_hooks = {}
2583+ for hook_name , hook_list in config ["hooks" ].items ():
2584+ ext_hooks = [h for h in hook_list if h .get ("extension" ) == extension_id ]
2585+ if ext_hooks :
2586+ backup_hooks [hook_name ] = ext_hooks
2587+
2588+ # 5. Remove old extension (handles command file cleanup and registry removal)
2589+ manager .remove (extension_id , keep_config = True )
2590+
2591+ # 6. Download and install new version
2592+ zip_path = catalog .download_extension (extension_id )
2593+ try :
2594+ installed_manifest = manager .install_from_zip (zip_path , speckit_version )
2595+
2596+ # 7. Verify extension ID matches
2597+ if installed_manifest .id != extension_id :
2598+ raise ValueError (
2599+ f"Extension ID mismatch: expected '{ extension_id } ', got '{ installed_manifest .id } '"
2600+ )
2601+ finally :
2602+ # Clean up downloaded ZIP
2603+ if zip_path .exists ():
2604+ zip_path .unlink ()
2605+
2606+ # 8. Clean up backup on success
2607+ if backup_base .exists ():
2608+ shutil .rmtree (backup_base )
2609+
2610+ console .print (f" [green]✓[/green] Updated to v{ update ['available' ]} " )
2611+ updated_extensions .append (ext_name )
2612+
2613+ except KeyboardInterrupt :
2614+ raise
2615+ except Exception as e :
2616+ console .print (f" [red]✗[/red] Failed: { e } " )
2617+ failed_updates .append ((ext_name , str (e )))
2618+
2619+ # Rollback on failure
2620+ console .print (f" [yellow]↩[/yellow] Rolling back { ext_name } ..." )
2621+
2622+ try :
2623+ # Restore extension directory
2624+ if backup_ext_dir .exists ():
2625+ extension_dir = manager .extensions_dir / extension_id
2626+ if extension_dir .exists ():
2627+ shutil .rmtree (extension_dir )
2628+ shutil .copytree (backup_ext_dir , extension_dir )
2629+
2630+ # Restore command files
2631+ for original_path , backup_path in backed_up_command_files .items ():
2632+ backup_file = Path (backup_path )
2633+ if backup_file .exists ():
2634+ original_file = Path (original_path )
2635+ original_file .parent .mkdir (parents = True , exist_ok = True )
2636+ shutil .copy2 (backup_file , original_file )
2637+
2638+ # Restore hooks in extensions.yml
2639+ if backup_hooks :
2640+ config = hook_executor .get_project_config ()
2641+ if "hooks" not in config :
2642+ config ["hooks" ] = {}
2643+ for hook_name , hooks in backup_hooks .items ():
2644+ if hook_name not in config ["hooks" ]:
2645+ config ["hooks" ][hook_name ] = []
2646+ # Remove any existing hooks for this extension first
2647+ config ["hooks" ][hook_name ] = [
2648+ h for h in config ["hooks" ][hook_name ]
2649+ if h .get ("extension" ) != extension_id
2650+ ]
2651+ # Add back the backed up hooks
2652+ config ["hooks" ][hook_name ].extend (hooks )
2653+ hook_executor .save_project_config (config )
2654+
2655+ # Restore registry entry
2656+ if backup_registry_entry :
2657+ manager .registry .data ["extensions" ][extension_id ] = backup_registry_entry
2658+ manager .registry ._save ()
2659+
2660+ console .print (f" [green]✓[/green] Rollback successful" )
2661+ except Exception as rollback_error :
2662+ console .print (f" [red]✗[/red] Rollback failed: { rollback_error } " )
2663+
2664+ # Clean up backup directory after rollback attempt
2665+ if backup_base .exists ():
2666+ shutil .rmtree (backup_base )
2667+
2668+ # Summary
2669+ console .print ()
2670+ if updated_extensions :
2671+ console .print (f"[green]✓[/green] Successfully updated { len (updated_extensions )} extension(s)" )
2672+ if failed_updates :
2673+ console .print (f"[red]✗[/red] Failed to update { len (failed_updates )} extension(s):" )
2674+ for ext_name , error in failed_updates :
2675+ console .print (f" • { ext_name } : { error } " )
2676+ raise typer .Exit (1 )
25302677
25312678 except ExtensionError as e :
25322679 console .print (f"\n [red]Error:[/red] { e } " )
@@ -2563,7 +2710,9 @@ def extension_enable(
25632710 raise typer .Exit (0 )
25642711
25652712 metadata ["enabled" ] = True
2566- manager .registry .add (extension , metadata )
2713+ # Update registry directly to preserve installed_at (add() would overwrite it)
2714+ manager .registry .data ["extensions" ][extension ] = metadata
2715+ manager .registry ._save ()
25672716
25682717 # Enable hooks in extensions.yml
25692718 config = hook_executor .get_project_config ()
@@ -2607,7 +2756,9 @@ def extension_disable(
26072756 raise typer .Exit (0 )
26082757
26092758 metadata ["enabled" ] = False
2610- manager .registry .add (extension , metadata )
2759+ # Update registry directly to preserve installed_at (add() would overwrite it)
2760+ manager .registry .data ["extensions" ][extension ] = metadata
2761+ manager .registry ._save ()
26112762
26122763 # Disable hooks in extensions.yml
26132764 config = hook_executor .get_project_config ()
0 commit comments