Skip to content

Commit 9ff9c5d

Browse files
iamaeroplaneclaude
andcommitted
feat(extensions): implement automatic updates with atomic backup/restore
- Implement automatic extension updates with download from catalog - Add comprehensive backup/restore mechanism for failed updates: - Backup registry entry before update - Backup extension directory - Backup command files for all AI agents - Backup hooks from extensions.yml - Add extension ID verification after install - Add KeyboardInterrupt handling to allow clean cancellation - Fix enable/disable to preserve installed_at timestamp by using direct registry manipulation instead of registry.add() - Add rollback on any update failure with command file, hook, and registry restoration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 56095f0 commit 9ff9c5d

File tree

1 file changed

+168
-17
lines changed

1 file changed

+168
-17
lines changed

src/specify_cli/__init__.py

Lines changed: 168 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)