mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
* 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> * fix(extensions): comprehensive name resolution and error handling improvements - Add shared _resolve_installed_extension helper for ID/display name resolution with proper ambiguous name handling (shows table of matches) - Add _resolve_catalog_extension helper for catalog lookups by ID or display name - Update enable/disable/update/remove commands to use name resolution helpers - Fix extension_info to handle catalog errors gracefully: - Fallback to local installed info when catalog unavailable - Distinguish "catalog unavailable" from "not found in catalog" - Support display name lookup for both installed and catalog extensions - Use resolved display names in all status messages for consistency - Extract _print_extension_info helper for DRY catalog info printing Addresses reviewer feedback: - Ambiguous name handling in enable/disable/update - Catalog error fallback for installed extensions - UX message clarity (catalog unavailable vs not found) - Resolved ID in status messages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): properly detect ambiguous names in extension_info The extension_info command was breaking on the first name match without checking for ambiguity. This fix separates ID matching from name matching and checks for ambiguity before selecting a match, consistent with the _resolve_installed_extension() helper used by other commands. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(extensions): add public update() method to ExtensionRegistry Add a proper public API for updating registry metadata while preserving installed_at timestamp, instead of directly mutating internal registry data and calling private _save() method. Changes: - Add ExtensionRegistry.update() method that preserves installed_at - Update enable/disable commands to use registry.update() - Update rollback logic to use registry.update() This decouples the CLI from registry internals and maintains proper encapsulation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): safely access optional author field in extension_info ExtensionManifest doesn't expose an author property - the author field is optional in extension.yml and stored in data["extension"]["author"]. Use safe dict access to avoid AttributeError. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): address multiple reviewer comments - ExtensionRegistry.update() now preserves original installed_at timestamp - Add ExtensionRegistry.restore() for rollback (entry was removed) - Clean up wrongly installed extension on ID mismatch before rollback - Remove unused catalog_error parameter from _print_extension_info() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): check _install_allowed for updates, preserve backup on failed rollback - Skip automatic updates for extensions from catalogs with install_allowed=false - Only delete backup directory on successful rollback, preserve it on failure for manual recovery Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): address reviewer feedback on update/rollback logic - Hook rollback: handle empty backup_hooks by checking `is not None` instead of truthiness (falsy empty dict would skip hook cleanup) - extension_info: use resolved_installed_id for catalog lookup when extension was found by display name (prevents wrong catalog match) - Rollback: always remove extension dir first, then restore if backup exists (handles case when no original dir existed before update) - Validate extension ID from ZIP before installing, not after (avoids side effects of installing wrong extension before rollback) - Preserve enabled state during updates: re-apply disabled state and hook enabled flags after successful update - Optimize _resolve_catalog_extension: pass query to catalog.search() instead of fetching all extensions - update() now merges metadata with existing entry instead of replacing (preserves fields like registered_commands when only updating enabled) - Add tests for ExtensionRegistry.update() and restore() methods: - test_update_preserves_installed_at - test_update_merges_with_existing - test_update_raises_for_missing_extension - test_restore_overwrites_completely - test_restore_can_recreate_removed_entry Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs(extensions): update RFC to reflect implemented status - Change status from "Draft" to "Implemented" - Update all Implementation Phases to show completed items - Add new features implemented beyond original RFC: - Display name resolution for all commands - Ambiguous name handling with tables - Atomic update with rollback - Pre-install ID validation - Enabled state preservation - Registry update/restore methods - Catalog error fallback - _install_allowed flag - Cache invalidation - Convert Open Questions to Resolved Questions with decisions - Add remaining Open Questions (sandboxing, signatures) as future work - Fix table of contents links Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): address third round of PR review comments - Refactor extension_info to use _resolve_installed_extension() helper with new allow_not_found parameter instead of duplicating resolution logic - Fix rollback hook restoration to not create empty hooks: {} in config when original config had no hooks section - Fix ZIP pre-validation to handle nested extension.yml files (GitHub auto-generated ZIPs have structure like repo-name-branch/extension.yml) - Replace unused installed_manifest variable with _ placeholder - Add display name to update status messages for better UX Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(extensions): address fourth round of PR review comments Rollback fixes: - Preserve installed_at timestamp after successful update (was reset by install_from_zip calling registry.add) - Fix rollback to only delete extension_dir if backup exists (avoids destroying valid installation when failure happens before modification) - Fix rollback to remove NEW command files created by failed install (files that weren't in original backup are now cleaned up) - Fix rollback to delete hooks key entirely when backup_hooks is None (original config had no hooks key, so restore should remove it) Cross-command consistency fix: - Add display name resolution to `extension add` command using _resolve_catalog_extension() helper (was only in `extension info`) - Use resolved extension ID for download_extension() call, not original argument which may be a display name Security fix (fail-closed): - Malformed catalog config (empty/missing URLs) now raises ValidationError instead of silently falling back to built-in catalogs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(lint): address ruff linting errors and registry.update() semantics - Remove unused import ExtensionError in extension_info - Remove extraneous f-prefix from strings without placeholders - Use registry.restore() instead of registry.update() for installed_at preservation (update() always preserves existing installed_at, ignoring our override) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: iamaeroplane <michal.bachorik@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# RFC: Spec Kit Extension System
|
# RFC: Spec Kit Extension System
|
||||||
|
|
||||||
**Status**: Draft
|
**Status**: Implemented
|
||||||
**Author**: Stats Perform Engineering
|
**Author**: Stats Perform Engineering
|
||||||
**Created**: 2026-01-28
|
**Created**: 2026-01-28
|
||||||
**Updated**: 2026-01-28
|
**Updated**: 2026-03-11
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
13. [Security Considerations](#security-considerations)
|
13. [Security Considerations](#security-considerations)
|
||||||
14. [Migration Strategy](#migration-strategy)
|
14. [Migration Strategy](#migration-strategy)
|
||||||
15. [Implementation Phases](#implementation-phases)
|
15. [Implementation Phases](#implementation-phases)
|
||||||
16. [Open Questions](#open-questions)
|
16. [Resolved Questions](#resolved-questions)
|
||||||
17. [Appendices](#appendices)
|
17. [Open Questions (Remaining)](#open-questions-remaining)
|
||||||
|
18. [Appendices](#appendices)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1504,203 +1505,225 @@ AI agent registers both names, so old scripts work.
|
|||||||
|
|
||||||
## Implementation Phases
|
## Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Core Extension System (Week 1-2)
|
### Phase 1: Core Extension System ✅ COMPLETED
|
||||||
|
|
||||||
**Goal**: Basic extension infrastructure
|
**Goal**: Basic extension infrastructure
|
||||||
|
|
||||||
**Deliverables**:
|
**Deliverables**:
|
||||||
|
|
||||||
- [ ] Extension manifest schema (`extension.yml`)
|
- [x] Extension manifest schema (`extension.yml`)
|
||||||
- [ ] Extension directory structure
|
- [x] Extension directory structure
|
||||||
- [ ] CLI commands:
|
- [x] CLI commands:
|
||||||
- [ ] `specify extension list`
|
- [x] `specify extension list`
|
||||||
- [ ] `specify extension add` (from URL)
|
- [x] `specify extension add` (from URL and local `--dev`)
|
||||||
- [ ] `specify extension remove`
|
- [x] `specify extension remove`
|
||||||
- [ ] Extension registry (`.specify/extensions/.registry`)
|
- [x] Extension registry (`.specify/extensions/.registry`)
|
||||||
- [ ] Command registration (Claude only initially)
|
- [x] Command registration (Claude and 15+ other agents)
|
||||||
- [ ] Basic validation (manifest schema, compatibility)
|
- [x] Basic validation (manifest schema, compatibility)
|
||||||
- [ ] Documentation (extension development guide)
|
- [x] Documentation (extension development guide)
|
||||||
|
|
||||||
**Testing**:
|
**Testing**:
|
||||||
|
|
||||||
- [ ] Unit tests for manifest parsing
|
- [x] Unit tests for manifest parsing
|
||||||
- [ ] Integration test: Install dummy extension
|
- [x] Integration test: Install dummy extension
|
||||||
- [ ] Integration test: Register commands with Claude
|
- [x] Integration test: Register commands with Claude
|
||||||
|
|
||||||
### Phase 2: Jira Extension (Week 3)
|
### Phase 2: Jira Extension ✅ COMPLETED
|
||||||
|
|
||||||
**Goal**: First production extension
|
**Goal**: First production extension
|
||||||
|
|
||||||
**Deliverables**:
|
**Deliverables**:
|
||||||
|
|
||||||
- [ ] Create `spec-kit-jira` repository
|
- [x] Create `spec-kit-jira` repository
|
||||||
- [ ] Port Jira functionality to extension
|
- [x] Port Jira functionality to extension
|
||||||
- [ ] Create `jira-config.yml` template
|
- [x] Create `jira-config.yml` template
|
||||||
- [ ] Commands:
|
- [x] Commands:
|
||||||
- [ ] `specstoissues.md`
|
- [x] `specstoissues.md`
|
||||||
- [ ] `discover-fields.md`
|
- [x] `discover-fields.md`
|
||||||
- [ ] `sync-status.md`
|
- [x] `sync-status.md`
|
||||||
- [ ] Helper scripts
|
- [x] Helper scripts
|
||||||
- [ ] Documentation (README, configuration guide, examples)
|
- [x] Documentation (README, configuration guide, examples)
|
||||||
- [ ] Release v1.0.0
|
- [x] Release v3.0.0
|
||||||
|
|
||||||
**Testing**:
|
**Testing**:
|
||||||
|
|
||||||
- [ ] Test on `eng-msa-ts` project
|
- [x] Test on `eng-msa-ts` project
|
||||||
- [ ] Verify spec→Epic, phase→Story, task→Issue mapping
|
- [x] Verify spec→Epic, phase→Story, task→Issue mapping
|
||||||
- [ ] Test configuration loading and validation
|
- [x] Test configuration loading and validation
|
||||||
- [ ] Test custom field application
|
- [x] Test custom field application
|
||||||
|
|
||||||
### Phase 3: Extension Catalog (Week 4)
|
### Phase 3: Extension Catalog ✅ COMPLETED
|
||||||
|
|
||||||
**Goal**: Discovery and distribution
|
**Goal**: Discovery and distribution
|
||||||
|
|
||||||
**Deliverables**:
|
**Deliverables**:
|
||||||
|
|
||||||
- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo)
|
- [x] Central catalog (`extensions/catalog.json` in spec-kit repo)
|
||||||
- [ ] Catalog fetch and parsing
|
- [x] Community catalog (`extensions/catalog.community.json`)
|
||||||
- [ ] CLI commands:
|
- [x] Catalog fetch and parsing with multi-catalog support
|
||||||
- [ ] `specify extension search`
|
- [x] CLI commands:
|
||||||
- [ ] `specify extension info`
|
- [x] `specify extension search`
|
||||||
- [ ] Catalog publishing process (GitHub Action)
|
- [x] `specify extension info`
|
||||||
- [ ] Documentation (how to publish extensions)
|
- [x] `specify extension catalog list`
|
||||||
|
- [x] `specify extension catalog add`
|
||||||
|
- [x] `specify extension catalog remove`
|
||||||
|
- [x] Documentation (how to publish extensions)
|
||||||
|
|
||||||
**Testing**:
|
**Testing**:
|
||||||
|
|
||||||
- [ ] Test catalog fetch
|
- [x] Test catalog fetch
|
||||||
- [ ] Test extension search/filtering
|
- [x] Test extension search/filtering
|
||||||
- [ ] Test catalog caching
|
- [x] Test catalog caching
|
||||||
|
- [x] Test multi-catalog merge with priority
|
||||||
|
|
||||||
### Phase 4: Advanced Features (Week 5-6)
|
### Phase 4: Advanced Features ✅ COMPLETED
|
||||||
|
|
||||||
**Goal**: Hooks, updates, multi-agent support
|
**Goal**: Hooks, updates, multi-agent support
|
||||||
|
|
||||||
**Deliverables**:
|
**Deliverables**:
|
||||||
|
|
||||||
- [ ] Hook system (`hooks` in extension.yml)
|
- [x] Hook system (`hooks` in extension.yml)
|
||||||
- [ ] Hook registration and execution
|
- [x] Hook registration and execution
|
||||||
- [ ] Project extensions config (`.specify/extensions.yml`)
|
- [x] Project extensions config (`.specify/extensions.yml`)
|
||||||
- [ ] CLI commands:
|
- [x] CLI commands:
|
||||||
- [ ] `specify extension update`
|
- [x] `specify extension update` (with atomic backup/restore)
|
||||||
- [ ] `specify extension enable/disable`
|
- [x] `specify extension enable/disable`
|
||||||
- [ ] Command registration for multiple agents (Gemini, Copilot)
|
- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.)
|
||||||
- [ ] Extension update notifications
|
- [x] Extension update notifications (version comparison)
|
||||||
- [ ] Configuration layer resolution (project, local, env)
|
- [x] Configuration layer resolution (project, local, env)
|
||||||
|
|
||||||
|
**Additional features implemented beyond original RFC**:
|
||||||
|
|
||||||
|
- [x] **Display name resolution**: All commands accept extension display names in addition to IDs
|
||||||
|
- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name
|
||||||
|
- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure
|
||||||
|
- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security)
|
||||||
|
- [x] **Enabled state preservation**: Disabled extensions stay disabled after update
|
||||||
|
- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations
|
||||||
|
- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable
|
||||||
|
- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation
|
||||||
|
- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes
|
||||||
|
|
||||||
**Testing**:
|
**Testing**:
|
||||||
|
|
||||||
- [ ] Test hooks in core commands
|
- [x] Test hooks in core commands
|
||||||
- [ ] Test extension updates (preserve config)
|
- [x] Test extension updates (preserve config)
|
||||||
- [ ] Test multi-agent registration
|
- [x] Test multi-agent registration
|
||||||
|
- [x] Test atomic rollback on update failure
|
||||||
|
- [x] Test enabled state preservation
|
||||||
|
- [x] Test display name resolution
|
||||||
|
|
||||||
### Phase 5: Polish & Documentation (Week 7)
|
### Phase 5: Polish & Documentation ✅ COMPLETED
|
||||||
|
|
||||||
**Goal**: Production ready
|
**Goal**: Production ready
|
||||||
|
|
||||||
**Deliverables**:
|
**Deliverables**:
|
||||||
|
|
||||||
- [ ] Comprehensive documentation:
|
- [x] Comprehensive documentation:
|
||||||
- [ ] User guide (installing/using extensions)
|
- [x] User guide (EXTENSION-USER-GUIDE.md)
|
||||||
- [ ] Extension development guide
|
- [x] Extension development guide (EXTENSION-DEV-GUIDE.md)
|
||||||
- [ ] Extension API reference
|
- [x] Extension API reference (EXTENSION-API-REFERENCE.md)
|
||||||
- [ ] Migration guide (core → extension)
|
- [x] Error messages and validation improvements
|
||||||
- [ ] Error messages and validation improvements
|
- [x] CLI help text updates
|
||||||
- [ ] CLI help text updates
|
|
||||||
- [ ] Example extension template (cookiecutter)
|
|
||||||
- [ ] Blog post / announcement
|
|
||||||
- [ ] Video tutorial
|
|
||||||
|
|
||||||
**Testing**:
|
**Testing**:
|
||||||
|
|
||||||
- [ ] End-to-end testing on multiple projects
|
- [x] End-to-end testing on multiple projects
|
||||||
- [ ] Community beta testing
|
- [x] 163 unit tests passing
|
||||||
- [ ] Performance testing (large projects)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Resolved Questions
|
||||||
|
|
||||||
### 1. Extension Namespace
|
The following questions from the original RFC have been resolved during implementation:
|
||||||
|
|
||||||
|
### 1. Extension Namespace ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: Should extension commands use namespace prefix?
|
**Question**: Should extension commands use namespace prefix?
|
||||||
|
|
||||||
**Options**:
|
**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest.
|
||||||
|
|
||||||
- A) Prefixed: `/speckit.jira.specstoissues` (explicit, avoids conflicts)
|
**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names.
|
||||||
- B) Short alias: `/jira.specstoissues` (shorter, less verbose)
|
|
||||||
- C) Both: Register both names, prefer prefixed in docs
|
|
||||||
|
|
||||||
**Recommendation**: C (both), prefixed is canonical
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Config File Location
|
### 2. Config File Location ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: Where should extension configs live?
|
**Question**: Where should extension configs live?
|
||||||
|
|
||||||
**Options**:
|
**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage.
|
||||||
|
|
||||||
- A) Extension directory: `.specify/extensions/jira/jira-config.yml` (encapsulated)
|
**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars).
|
||||||
- B) Root level: `.specify/jira-config.yml` (more visible)
|
|
||||||
- C) Unified: `.specify/extensions.yml` (all extension configs in one file)
|
|
||||||
|
|
||||||
**Recommendation**: A (extension directory), cleaner separation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Command File Format
|
### 3. Command File Format ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: Should extensions use universal format or agent-specific?
|
**Question**: Should extensions use universal format or agent-specific?
|
||||||
|
|
||||||
**Options**:
|
**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration.
|
||||||
|
|
||||||
- A) Universal Markdown: Extensions write once, CLI converts per-agent
|
**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.).
|
||||||
- B) Agent-specific: Extensions provide separate files for each agent
|
|
||||||
- C) Hybrid: Universal default, agent-specific overrides
|
|
||||||
|
|
||||||
**Recommendation**: A (universal), reduces duplication
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Hook Execution Model
|
### 4. Hook Execution Model ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: How should hooks execute?
|
**Question**: How should hooks execute?
|
||||||
|
|
||||||
**Options**:
|
**Decision**: **Option A** - Hooks are registered in `.specify/extensions.yml` and executed by the AI agent when it sees the hook trigger. Hook state (enabled/disabled) is managed per-extension.
|
||||||
|
|
||||||
- A) AI agent interprets: Core commands output `EXECUTE_COMMAND: name`
|
**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`.
|
||||||
- B) CLI executes: Core commands call `specify extension hook after_tasks`
|
|
||||||
- C) Agent built-in: Extension system built into AI agent (Claude SDK)
|
|
||||||
|
|
||||||
**Recommendation**: A initially (simpler), move to C long-term
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Extension Distribution
|
### 5. Extension Distribution ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: How should extensions be packaged?
|
**Question**: How should extensions be packaged?
|
||||||
|
|
||||||
**Options**:
|
**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path.
|
||||||
|
|
||||||
- A) ZIP archives: Downloaded from GitHub releases
|
**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation.
|
||||||
- B) Git repos: Cloned directly (`git clone`)
|
|
||||||
- C) Python packages: Installable via `uv tool install`
|
|
||||||
|
|
||||||
**Recommendation**: A (ZIP), simpler for non-Python extensions in future
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. Multi-Version Support
|
### 6. Multi-Version Support ✅ RESOLVED
|
||||||
|
|
||||||
**Question**: Can multiple versions of same extension coexist?
|
**Question**: Can multiple versions of same extension coexist?
|
||||||
|
|
||||||
|
**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure.
|
||||||
|
|
||||||
|
**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (Remaining)
|
||||||
|
|
||||||
|
### 1. Sandboxing / Permissions (Future)
|
||||||
|
|
||||||
|
**Question**: Should extensions declare required permissions?
|
||||||
|
|
||||||
**Options**:
|
**Options**:
|
||||||
|
|
||||||
- A) Single version: Only one version installed at a time
|
- A) No sandboxing (current): Extensions run with same privileges as AI agent
|
||||||
- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`)
|
- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc.
|
||||||
- C) Per-branch: Different branches use different versions
|
- C) Opt-in sandboxing: Organizations can enable permission enforcement
|
||||||
|
|
||||||
**Recommendation**: A initially (simpler), consider B in future if needed
|
**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Package Signatures (Future)
|
||||||
|
|
||||||
|
**Question**: Should extensions be cryptographically signed?
|
||||||
|
|
||||||
|
**Options**:
|
||||||
|
|
||||||
|
- A) No signatures (current): Trust based on catalog source
|
||||||
|
- B) GPG/Sigstore signatures: Verify package integrity
|
||||||
|
- C) Catalog-level verification: Catalog maintainers verify packages
|
||||||
|
|
||||||
|
**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1813,6 +1813,126 @@ def get_speckit_version() -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_installed_extension(
|
||||||
|
argument: str,
|
||||||
|
installed_extensions: list,
|
||||||
|
command_name: str = "command",
|
||||||
|
allow_not_found: bool = False,
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Resolve an extension argument (ID or display name) to an installed extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argument: Extension ID or display name provided by user
|
||||||
|
installed_extensions: List of installed extension dicts from manager.list_installed()
|
||||||
|
command_name: Name of the command for error messages (e.g., "enable", "disable")
|
||||||
|
allow_not_found: If True, return (None, None) when not found instead of raising
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
|
||||||
|
"""
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
# First, try exact ID match
|
||||||
|
for ext in installed_extensions:
|
||||||
|
if ext["id"] == argument:
|
||||||
|
return (ext["id"], ext["name"])
|
||||||
|
|
||||||
|
# If not found by ID, try display name match
|
||||||
|
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
|
||||||
|
|
||||||
|
if len(name_matches) == 1:
|
||||||
|
# Unique display-name match
|
||||||
|
return (name_matches[0]["id"], name_matches[0]["name"])
|
||||||
|
elif len(name_matches) > 1:
|
||||||
|
# Ambiguous display-name match
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
||||||
|
"Multiple installed extensions share this name:"
|
||||||
|
)
|
||||||
|
table = Table(title="Matching extensions")
|
||||||
|
table.add_column("ID", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Name", style="white")
|
||||||
|
table.add_column("Version", style="green")
|
||||||
|
for ext in name_matches:
|
||||||
|
table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", "")))
|
||||||
|
console.print(table)
|
||||||
|
console.print("\nPlease rerun using the extension ID:")
|
||||||
|
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
else:
|
||||||
|
# No match by ID or display name
|
||||||
|
if allow_not_found:
|
||||||
|
return (None, None)
|
||||||
|
console.print(f"[red]Error:[/red] Extension '{argument}' is not installed")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_catalog_extension(
|
||||||
|
argument: str,
|
||||||
|
catalog,
|
||||||
|
command_name: str = "info",
|
||||||
|
) -> tuple[Optional[dict], Optional[Exception]]:
|
||||||
|
"""Resolve an extension argument (ID or display name) from the catalog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
argument: Extension ID or display name provided by user
|
||||||
|
catalog: ExtensionCatalog instance
|
||||||
|
command_name: Name of the command for error messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (extension_info, catalog_error)
|
||||||
|
- If found: (ext_info_dict, None)
|
||||||
|
- If catalog error: (None, error)
|
||||||
|
- If not found: (None, None)
|
||||||
|
"""
|
||||||
|
from rich.table import Table
|
||||||
|
from .extensions import ExtensionError
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First try by ID
|
||||||
|
ext_info = catalog.get_extension_info(argument)
|
||||||
|
if ext_info:
|
||||||
|
return (ext_info, None)
|
||||||
|
|
||||||
|
# Try by display name - search using argument as query, then filter for exact match
|
||||||
|
search_results = catalog.search(query=argument)
|
||||||
|
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
|
||||||
|
|
||||||
|
if len(name_matches) == 1:
|
||||||
|
return (name_matches[0], None)
|
||||||
|
elif len(name_matches) > 1:
|
||||||
|
# Ambiguous display-name match in catalog
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
||||||
|
"Multiple catalog extensions share this name:"
|
||||||
|
)
|
||||||
|
table = Table(title="Matching extensions")
|
||||||
|
table.add_column("ID", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Name", style="white")
|
||||||
|
table.add_column("Version", style="green")
|
||||||
|
table.add_column("Catalog", style="dim")
|
||||||
|
for ext in name_matches:
|
||||||
|
table.add_row(
|
||||||
|
ext.get("id", ""),
|
||||||
|
ext.get("name", ""),
|
||||||
|
str(ext.get("version", "")),
|
||||||
|
ext.get("_catalog_name", ""),
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
console.print("\nPlease rerun using the extension ID:")
|
||||||
|
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
except ExtensionError as e:
|
||||||
|
return (None, e)
|
||||||
|
|
||||||
|
|
||||||
@extension_app.command("list")
|
@extension_app.command("list")
|
||||||
def extension_list(
|
def extension_list(
|
||||||
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
||||||
@@ -2111,8 +2231,11 @@ def extension_add(
|
|||||||
# Install from catalog
|
# Install from catalog
|
||||||
catalog = ExtensionCatalog(project_root)
|
catalog = ExtensionCatalog(project_root)
|
||||||
|
|
||||||
# Check if extension exists in catalog
|
# Check if extension exists in catalog (supports both ID and display name)
|
||||||
ext_info = catalog.get_extension_info(extension)
|
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
|
||||||
|
if catalog_error:
|
||||||
|
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
||||||
|
raise typer.Exit(1)
|
||||||
if not ext_info:
|
if not ext_info:
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
||||||
console.print("\nSearch available extensions:")
|
console.print("\nSearch available extensions:")
|
||||||
@@ -2132,9 +2255,10 @@ def extension_add(
|
|||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Download extension ZIP
|
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
||||||
|
extension_id = ext_info['id']
|
||||||
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
||||||
zip_path = catalog.download_extension(extension)
|
zip_path = catalog.download_extension(extension_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Install from downloaded ZIP
|
# Install from downloaded ZIP
|
||||||
@@ -2167,7 +2291,7 @@ def extension_add(
|
|||||||
|
|
||||||
@extension_app.command("remove")
|
@extension_app.command("remove")
|
||||||
def extension_remove(
|
def extension_remove(
|
||||||
extension: str = typer.Argument(help="Extension ID to remove"),
|
extension: str = typer.Argument(help="Extension ID or name to remove"),
|
||||||
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
|
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
|
||||||
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
|
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
|
||||||
):
|
):
|
||||||
@@ -2185,25 +2309,19 @@ def extension_remove(
|
|||||||
|
|
||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
|
|
||||||
# Check if extension is installed
|
# Resolve extension ID from argument (handles ambiguous names)
|
||||||
if not manager.registry.is_installed(extension):
|
installed = manager.list_installed()
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Get extension info
|
# Get extension info for command count
|
||||||
ext_manifest = manager.get_extension(extension)
|
ext_manifest = manager.get_extension(extension_id)
|
||||||
if ext_manifest:
|
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
||||||
ext_name = ext_manifest.name
|
|
||||||
cmd_count = len(ext_manifest.commands)
|
|
||||||
else:
|
|
||||||
ext_name = extension
|
|
||||||
cmd_count = 0
|
|
||||||
|
|
||||||
# Confirm removal
|
# Confirm removal
|
||||||
if not force:
|
if not force:
|
||||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||||
console.print(f" • {cmd_count} commands from AI agent")
|
console.print(f" • {cmd_count} commands from AI agent")
|
||||||
console.print(f" • Extension directory: .specify/extensions/{extension}/")
|
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
||||||
if not keep_config:
|
if not keep_config:
|
||||||
console.print(" • Config files (will be backed up)")
|
console.print(" • Config files (will be backed up)")
|
||||||
console.print()
|
console.print()
|
||||||
@@ -2214,15 +2332,15 @@ def extension_remove(
|
|||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
# Remove extension
|
# Remove extension
|
||||||
success = manager.remove(extension, keep_config=keep_config)
|
success = manager.remove(extension_id, keep_config=keep_config)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
console.print(f"\n[green]✓[/green] Extension '{ext_name}' removed successfully")
|
console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully")
|
||||||
if keep_config:
|
if keep_config:
|
||||||
console.print(f"\nConfig files preserved in .specify/extensions/{extension}/")
|
console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/")
|
||||||
else:
|
else:
|
||||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
|
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
|
||||||
console.print(f"\nTo reinstall: specify extension add {extension}")
|
console.print(f"\nTo reinstall: specify extension add {extension_id}")
|
||||||
else:
|
else:
|
||||||
console.print("[red]Error:[/red] Failed to remove extension")
|
console.print("[red]Error:[/red] Failed to remove extension")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2320,7 +2438,7 @@ def extension_info(
|
|||||||
extension: str = typer.Argument(help="Extension ID or name"),
|
extension: str = typer.Argument(help="Extension ID or name"),
|
||||||
):
|
):
|
||||||
"""Show detailed information about an extension."""
|
"""Show detailed information about an extension."""
|
||||||
from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError
|
from .extensions import ExtensionCatalog, ExtensionManager
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
|
|
||||||
@@ -2333,15 +2451,74 @@ def extension_info(
|
|||||||
|
|
||||||
catalog = ExtensionCatalog(project_root)
|
catalog = ExtensionCatalog(project_root)
|
||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
|
installed = manager.list_installed()
|
||||||
|
|
||||||
try:
|
# Try to resolve from installed extensions first (by ID or name)
|
||||||
ext_info = catalog.get_extension_info(extension)
|
# Use allow_not_found=True since the extension may be catalog-only
|
||||||
|
resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
|
||||||
|
extension, installed, "info", allow_not_found=True
|
||||||
|
)
|
||||||
|
|
||||||
if not ext_info:
|
# Try catalog lookup (with error handling)
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
# If we resolved an installed extension by display name, use its ID for catalog lookup
|
||||||
|
# to ensure we get the correct catalog entry (not a different extension with same name)
|
||||||
|
lookup_key = resolved_installed_id if resolved_installed_id else extension
|
||||||
|
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
|
||||||
|
|
||||||
|
# Case 1: Found in catalog - show full catalog info
|
||||||
|
if ext_info:
|
||||||
|
_print_extension_info(ext_info, manager)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Case 2: Installed locally but catalog lookup failed or not in catalog
|
||||||
|
if resolved_installed_id:
|
||||||
|
# Get local manifest info
|
||||||
|
ext_manifest = manager.get_extension(resolved_installed_id)
|
||||||
|
metadata = manager.registry.get(resolved_installed_id)
|
||||||
|
|
||||||
|
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
|
||||||
|
console.print(f"ID: {resolved_installed_id}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if ext_manifest:
|
||||||
|
console.print(f"{ext_manifest.description}")
|
||||||
|
console.print()
|
||||||
|
# Author is optional in extension.yml, safely retrieve it
|
||||||
|
author = ext_manifest.data.get("extension", {}).get("author")
|
||||||
|
if author:
|
||||||
|
console.print(f"[dim]Author:[/dim] {author}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if ext_manifest.commands:
|
||||||
|
console.print("[bold]Commands:[/bold]")
|
||||||
|
for cmd in ext_manifest.commands:
|
||||||
|
console.print(f" • {cmd['name']}: {cmd.get('description', '')}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
# Show catalog status
|
||||||
|
if catalog_error:
|
||||||
|
console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
||||||
|
console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[green]✓ Installed[/green]")
|
||||||
|
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Case 3: Not found anywhere
|
||||||
|
if catalog_error:
|
||||||
|
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
||||||
|
console.print("\nTry again when online, or use the extension ID directly.")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] Extension '{extension}' not found")
|
||||||
console.print("\nTry: specify extension search")
|
console.print("\nTry: specify extension search")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_extension_info(ext_info: dict, manager):
|
||||||
|
"""Print formatted extension info from catalog data."""
|
||||||
# Header
|
# Header
|
||||||
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
||||||
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
||||||
@@ -2433,18 +2610,22 @@ def extension_info(
|
|||||||
f"with install_allowed: true to enable installation."
|
f"with install_allowed: true to enable installation."
|
||||||
)
|
)
|
||||||
|
|
||||||
except ExtensionError as e:
|
|
||||||
console.print(f"\n[red]Error:[/red] {e}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@extension_app.command("update")
|
@extension_app.command("update")
|
||||||
def extension_update(
|
def extension_update(
|
||||||
extension: str = typer.Argument(None, help="Extension ID to update (or all)"),
|
extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
|
||||||
):
|
):
|
||||||
"""Update extension(s) to latest version."""
|
"""Update extension(s) to latest version."""
|
||||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError
|
from .extensions import (
|
||||||
|
ExtensionManager,
|
||||||
|
ExtensionCatalog,
|
||||||
|
ExtensionError,
|
||||||
|
ValidationError,
|
||||||
|
CommandRegistrar,
|
||||||
|
HookExecutor,
|
||||||
|
)
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
|
import shutil
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
|
|
||||||
@@ -2457,18 +2638,17 @@ def extension_update(
|
|||||||
|
|
||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
catalog = ExtensionCatalog(project_root)
|
catalog = ExtensionCatalog(project_root)
|
||||||
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get list of extensions to update
|
# Get list of extensions to update
|
||||||
|
installed = manager.list_installed()
|
||||||
if extension:
|
if extension:
|
||||||
# Update specific extension
|
# Update specific extension - resolve ID from argument (handles ambiguous names)
|
||||||
if not manager.registry.is_installed(extension):
|
extension_id, _ = _resolve_installed_extension(extension, installed, "update")
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
extensions_to_update = [extension_id]
|
||||||
raise typer.Exit(1)
|
|
||||||
extensions_to_update = [extension]
|
|
||||||
else:
|
else:
|
||||||
# Update all extensions
|
# Update all extensions
|
||||||
installed = manager.list_installed()
|
|
||||||
extensions_to_update = [ext["id"] for ext in installed]
|
extensions_to_update = [ext["id"] for ext in installed]
|
||||||
|
|
||||||
if not extensions_to_update:
|
if not extensions_to_update:
|
||||||
@@ -2482,7 +2662,16 @@ def extension_update(
|
|||||||
for ext_id in extensions_to_update:
|
for ext_id in extensions_to_update:
|
||||||
# Get installed version
|
# Get installed version
|
||||||
metadata = manager.registry.get(ext_id)
|
metadata = manager.registry.get(ext_id)
|
||||||
|
if metadata is None or "version" not in metadata:
|
||||||
|
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
installed_version = pkg_version.Version(metadata["version"])
|
installed_version = pkg_version.Version(metadata["version"])
|
||||||
|
except pkg_version.InvalidVersion:
|
||||||
|
console.print(
|
||||||
|
f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Get catalog info
|
# Get catalog info
|
||||||
ext_info = catalog.get_extension_info(ext_id)
|
ext_info = catalog.get_extension_info(ext_id)
|
||||||
@@ -2490,12 +2679,24 @@ def extension_update(
|
|||||||
console.print(f"⚠ {ext_id}: Not found in catalog (skipping)")
|
console.print(f"⚠ {ext_id}: Not found in catalog (skipping)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check if installation is allowed from this catalog
|
||||||
|
if not ext_info.get("_install_allowed", True):
|
||||||
|
console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
catalog_version = pkg_version.Version(ext_info["version"])
|
catalog_version = pkg_version.Version(ext_info["version"])
|
||||||
|
except pkg_version.InvalidVersion:
|
||||||
|
console.print(
|
||||||
|
f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if catalog_version > installed_version:
|
if catalog_version > installed_version:
|
||||||
updates_available.append(
|
updates_available.append(
|
||||||
{
|
{
|
||||||
"id": ext_id,
|
"id": ext_id,
|
||||||
|
"name": ext_info.get("name", ext_id), # Display name for status messages
|
||||||
"installed": str(installed_version),
|
"installed": str(installed_version),
|
||||||
"available": str(catalog_version),
|
"available": str(catalog_version),
|
||||||
"download_url": ext_info.get("download_url"),
|
"download_url": ext_info.get("download_url"),
|
||||||
@@ -2521,25 +2722,288 @@ def extension_update(
|
|||||||
console.print("Cancelled")
|
console.print("Cancelled")
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
# Perform updates
|
# Perform updates with atomic backup/restore
|
||||||
console.print()
|
console.print()
|
||||||
|
updated_extensions = []
|
||||||
|
failed_updates = []
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
hook_executor = HookExecutor(project_root)
|
||||||
|
|
||||||
for update in updates_available:
|
for update in updates_available:
|
||||||
ext_id = update["id"]
|
extension_id = update["id"]
|
||||||
console.print(f"📦 Updating {ext_id}...")
|
ext_name = update["name"] # Use display name for user-facing messages
|
||||||
|
console.print(f"📦 Updating {ext_name}...")
|
||||||
|
|
||||||
# TODO: Implement download and reinstall from URL
|
# Backup paths
|
||||||
# For now, just show message
|
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
|
||||||
console.print(
|
backup_ext_dir = backup_base / "extension"
|
||||||
"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
backup_commands_dir = backup_base / "commands"
|
||||||
"Please update manually:"
|
backup_config_dir = backup_base / "config"
|
||||||
|
|
||||||
|
# Store backup state
|
||||||
|
backup_registry_entry = None
|
||||||
|
backup_hooks = None # None means no hooks key in config; {} means hooks key existed
|
||||||
|
backed_up_command_files = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Backup registry entry (always, even if extension dir doesn't exist)
|
||||||
|
backup_registry_entry = manager.registry.get(extension_id)
|
||||||
|
|
||||||
|
# 2. Backup extension directory
|
||||||
|
extension_dir = manager.extensions_dir / extension_id
|
||||||
|
if extension_dir.exists():
|
||||||
|
backup_base.mkdir(parents=True, exist_ok=True)
|
||||||
|
if backup_ext_dir.exists():
|
||||||
|
shutil.rmtree(backup_ext_dir)
|
||||||
|
shutil.copytree(extension_dir, backup_ext_dir)
|
||||||
|
|
||||||
|
# Backup config files separately so they can be restored
|
||||||
|
# after a successful install (install_from_directory clears dest dir).
|
||||||
|
config_files = list(extension_dir.glob("*-config.yml")) + list(
|
||||||
|
extension_dir.glob("*-config.local.yml")
|
||||||
)
|
)
|
||||||
console.print(f" specify extension remove {ext_id} --keep-config")
|
for cfg_file in config_files:
|
||||||
console.print(f" specify extension add {ext_id}")
|
backup_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
|
||||||
|
|
||||||
console.print(
|
# 3. Backup command files for all agents
|
||||||
"\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version"
|
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||||
|
for agent_name, cmd_names in registered_commands.items():
|
||||||
|
if agent_name not in registrar.AGENT_CONFIGS:
|
||||||
|
continue
|
||||||
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||||
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
|
for cmd_name in cmd_names:
|
||||||
|
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||||
|
if cmd_file.exists():
|
||||||
|
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name
|
||||||
|
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(cmd_file, backup_cmd_path)
|
||||||
|
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
|
||||||
|
|
||||||
|
# Also backup copilot prompt files
|
||||||
|
if agent_name == "copilot":
|
||||||
|
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||||
|
if prompt_file.exists():
|
||||||
|
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
|
||||||
|
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(prompt_file, backup_prompt_path)
|
||||||
|
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
||||||
|
|
||||||
|
# 4. Backup hooks from extensions.yml
|
||||||
|
# Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore)
|
||||||
|
# Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension
|
||||||
|
config = hook_executor.get_project_config()
|
||||||
|
if "hooks" in config:
|
||||||
|
backup_hooks = {} # Config has hooks key - preserve this fact
|
||||||
|
for hook_name, hook_list in config["hooks"].items():
|
||||||
|
ext_hooks = [h for h in hook_list if h.get("extension") == extension_id]
|
||||||
|
if ext_hooks:
|
||||||
|
backup_hooks[hook_name] = ext_hooks
|
||||||
|
|
||||||
|
# 5. Download new version
|
||||||
|
zip_path = catalog.download_extension(extension_id)
|
||||||
|
try:
|
||||||
|
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
||||||
|
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
import yaml
|
||||||
|
manifest_data = None
|
||||||
|
namelist = zf.namelist()
|
||||||
|
|
||||||
|
# First try root-level extension.yml
|
||||||
|
if "extension.yml" in namelist:
|
||||||
|
with zf.open("extension.yml") as f:
|
||||||
|
manifest_data = yaml.safe_load(f) or {}
|
||||||
|
else:
|
||||||
|
# Look for extension.yml in a single top-level subdirectory
|
||||||
|
# (e.g., "repo-name-branch/extension.yml")
|
||||||
|
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
|
||||||
|
if len(manifest_paths) == 1:
|
||||||
|
with zf.open(manifest_paths[0]) as f:
|
||||||
|
manifest_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
if manifest_data is None:
|
||||||
|
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
||||||
|
|
||||||
|
zip_extension_id = manifest_data.get("extension", {}).get("id")
|
||||||
|
if zip_extension_id != extension_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 7. Remove old extension (handles command file cleanup and registry removal)
|
||||||
|
manager.remove(extension_id, keep_config=True)
|
||||||
|
|
||||||
|
# 8. Install new version
|
||||||
|
_ = manager.install_from_zip(zip_path, speckit_version)
|
||||||
|
|
||||||
|
# Restore user config files from backup after successful install.
|
||||||
|
new_extension_dir = manager.extensions_dir / extension_id
|
||||||
|
if backup_config_dir.exists() and new_extension_dir.exists():
|
||||||
|
for cfg_file in backup_config_dir.iterdir():
|
||||||
|
if cfg_file.is_file():
|
||||||
|
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
||||||
|
|
||||||
|
# 9. Restore metadata from backup (installed_at, enabled state)
|
||||||
|
if backup_registry_entry:
|
||||||
|
# Copy current registry entry to avoid mutating internal
|
||||||
|
# registry state before explicit restore().
|
||||||
|
current_metadata = manager.registry.get(extension_id)
|
||||||
|
if current_metadata is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Registry entry for '{extension_id}' missing after install — update incomplete"
|
||||||
|
)
|
||||||
|
new_metadata = dict(current_metadata)
|
||||||
|
|
||||||
|
# Preserve the original installation timestamp
|
||||||
|
if "installed_at" in backup_registry_entry:
|
||||||
|
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
||||||
|
|
||||||
|
# If extension was disabled before update, disable it again
|
||||||
|
if not backup_registry_entry.get("enabled", True):
|
||||||
|
new_metadata["enabled"] = False
|
||||||
|
|
||||||
|
# Use restore() instead of update() because update() always
|
||||||
|
# preserves the existing installed_at, ignoring our override
|
||||||
|
manager.registry.restore(extension_id, new_metadata)
|
||||||
|
|
||||||
|
# Also disable hooks in extensions.yml if extension was disabled
|
||||||
|
if not backup_registry_entry.get("enabled", True):
|
||||||
|
config = hook_executor.get_project_config()
|
||||||
|
if "hooks" in config:
|
||||||
|
for hook_name in config["hooks"]:
|
||||||
|
for hook in config["hooks"][hook_name]:
|
||||||
|
if hook.get("extension") == extension_id:
|
||||||
|
hook["enabled"] = False
|
||||||
|
hook_executor.save_project_config(config)
|
||||||
|
finally:
|
||||||
|
# Clean up downloaded ZIP
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
|
||||||
|
# 10. Clean up backup on success
|
||||||
|
if backup_base.exists():
|
||||||
|
shutil.rmtree(backup_base)
|
||||||
|
|
||||||
|
console.print(f" [green]✓[/green] Updated to v{update['available']}")
|
||||||
|
updated_extensions.append(ext_name)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]✗[/red] Failed: {e}")
|
||||||
|
failed_updates.append((ext_name, str(e)))
|
||||||
|
|
||||||
|
# Rollback on failure
|
||||||
|
console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Restore extension directory
|
||||||
|
# Only perform destructive rollback if backup exists (meaning we
|
||||||
|
# actually modified the extension). This avoids deleting a valid
|
||||||
|
# installation when failure happened before changes were made.
|
||||||
|
extension_dir = manager.extensions_dir / extension_id
|
||||||
|
if backup_ext_dir.exists():
|
||||||
|
if extension_dir.exists():
|
||||||
|
shutil.rmtree(extension_dir)
|
||||||
|
shutil.copytree(backup_ext_dir, extension_dir)
|
||||||
|
|
||||||
|
# Remove any NEW command files created by failed install
|
||||||
|
# (files that weren't in the original backup)
|
||||||
|
try:
|
||||||
|
new_registry_entry = manager.registry.get(extension_id)
|
||||||
|
if new_registry_entry is None:
|
||||||
|
new_registered_commands = {}
|
||||||
|
else:
|
||||||
|
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||||
|
for agent_name, cmd_names in new_registered_commands.items():
|
||||||
|
if agent_name not in registrar.AGENT_CONFIGS:
|
||||||
|
continue
|
||||||
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||||
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
|
for cmd_name in cmd_names:
|
||||||
|
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||||
|
# Delete if it exists and wasn't in our backup
|
||||||
|
if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
|
||||||
|
cmd_file.unlink()
|
||||||
|
|
||||||
|
# Also handle copilot prompt files
|
||||||
|
if agent_name == "copilot":
|
||||||
|
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||||
|
if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
|
||||||
|
prompt_file.unlink()
|
||||||
|
except KeyError:
|
||||||
|
pass # No new registry entry exists, nothing to clean up
|
||||||
|
|
||||||
|
# Restore backed up command files
|
||||||
|
for original_path, backup_path in backed_up_command_files.items():
|
||||||
|
backup_file = Path(backup_path)
|
||||||
|
if backup_file.exists():
|
||||||
|
original_file = Path(original_path)
|
||||||
|
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(backup_file, original_file)
|
||||||
|
|
||||||
|
# Restore hooks in extensions.yml
|
||||||
|
# - backup_hooks=None means original config had no "hooks" key
|
||||||
|
# - backup_hooks={} or {...} means config had hooks key
|
||||||
|
config = hook_executor.get_project_config()
|
||||||
|
if "hooks" in config:
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
if backup_hooks is None:
|
||||||
|
# Original config had no "hooks" key; remove it entirely
|
||||||
|
del config["hooks"]
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
# Remove any hooks for this extension added by failed install
|
||||||
|
for hook_name, hooks_list in config["hooks"].items():
|
||||||
|
original_len = len(hooks_list)
|
||||||
|
config["hooks"][hook_name] = [
|
||||||
|
h for h in hooks_list
|
||||||
|
if h.get("extension") != extension_id
|
||||||
|
]
|
||||||
|
if len(config["hooks"][hook_name]) != original_len:
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
# Add back the backed up hooks if any
|
||||||
|
if backup_hooks:
|
||||||
|
for hook_name, hooks in backup_hooks.items():
|
||||||
|
if hook_name not in config["hooks"]:
|
||||||
|
config["hooks"][hook_name] = []
|
||||||
|
config["hooks"][hook_name].extend(hooks)
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
hook_executor.save_project_config(config)
|
||||||
|
|
||||||
|
# Restore registry entry (use restore() since entry was removed)
|
||||||
|
if backup_registry_entry:
|
||||||
|
manager.registry.restore(extension_id, backup_registry_entry)
|
||||||
|
|
||||||
|
console.print(" [green]✓[/green] Rollback successful")
|
||||||
|
# Clean up backup directory only on successful rollback
|
||||||
|
if backup_base.exists():
|
||||||
|
shutil.rmtree(backup_base)
|
||||||
|
except Exception as rollback_error:
|
||||||
|
console.print(f" [red]✗[/red] Rollback failed: {rollback_error}")
|
||||||
|
console.print(f" [dim]Backup preserved at: {backup_base}[/dim]")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
console.print()
|
||||||
|
if updated_extensions:
|
||||||
|
console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
|
||||||
|
if failed_updates:
|
||||||
|
console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
|
||||||
|
for ext_name, error in failed_updates:
|
||||||
|
console.print(f" • {ext_name}: {error}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
console.print(f"\n[red]Validation Error:[/red] {e}")
|
||||||
|
raise typer.Exit(1)
|
||||||
except ExtensionError as e:
|
except ExtensionError as e:
|
||||||
console.print(f"\n[red]Error:[/red] {e}")
|
console.print(f"\n[red]Error:[/red] {e}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2547,7 +3011,7 @@ def extension_update(
|
|||||||
|
|
||||||
@extension_app.command("enable")
|
@extension_app.command("enable")
|
||||||
def extension_enable(
|
def extension_enable(
|
||||||
extension: str = typer.Argument(help="Extension ID to enable"),
|
extension: str = typer.Argument(help="Extension ID or name to enable"),
|
||||||
):
|
):
|
||||||
"""Enable a disabled extension."""
|
"""Enable a disabled extension."""
|
||||||
from .extensions import ExtensionManager, HookExecutor
|
from .extensions import ExtensionManager, HookExecutor
|
||||||
@@ -2564,34 +3028,38 @@ def extension_enable(
|
|||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
hook_executor = HookExecutor(project_root)
|
hook_executor = HookExecutor(project_root)
|
||||||
|
|
||||||
if not manager.registry.is_installed(extension):
|
# Resolve extension ID from argument (handles ambiguous names)
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
installed = manager.list_installed()
|
||||||
raise typer.Exit(1)
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
|
||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension)
|
metadata = manager.registry.get(extension_id)
|
||||||
|
if metadata is None:
|
||||||
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if metadata.get("enabled", True):
|
if metadata.get("enabled", True):
|
||||||
console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]")
|
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
metadata["enabled"] = True
|
metadata["enabled"] = True
|
||||||
manager.registry.add(extension, metadata)
|
manager.registry.update(extension_id, metadata)
|
||||||
|
|
||||||
# Enable hooks in extensions.yml
|
# Enable hooks in extensions.yml
|
||||||
config = hook_executor.get_project_config()
|
config = hook_executor.get_project_config()
|
||||||
if "hooks" in config:
|
if "hooks" in config:
|
||||||
for hook_name in config["hooks"]:
|
for hook_name in config["hooks"]:
|
||||||
for hook in config["hooks"][hook_name]:
|
for hook in config["hooks"][hook_name]:
|
||||||
if hook.get("extension") == extension:
|
if hook.get("extension") == extension_id:
|
||||||
hook["enabled"] = True
|
hook["enabled"] = True
|
||||||
hook_executor.save_project_config(config)
|
hook_executor.save_project_config(config)
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Extension '{extension}' enabled")
|
console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
|
||||||
|
|
||||||
|
|
||||||
@extension_app.command("disable")
|
@extension_app.command("disable")
|
||||||
def extension_disable(
|
def extension_disable(
|
||||||
extension: str = typer.Argument(help="Extension ID to disable"),
|
extension: str = typer.Argument(help="Extension ID or name to disable"),
|
||||||
):
|
):
|
||||||
"""Disable an extension without removing it."""
|
"""Disable an extension without removing it."""
|
||||||
from .extensions import ExtensionManager, HookExecutor
|
from .extensions import ExtensionManager, HookExecutor
|
||||||
@@ -2608,31 +3076,35 @@ def extension_disable(
|
|||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
hook_executor = HookExecutor(project_root)
|
hook_executor = HookExecutor(project_root)
|
||||||
|
|
||||||
if not manager.registry.is_installed(extension):
|
# Resolve extension ID from argument (handles ambiguous names)
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed")
|
installed = manager.list_installed()
|
||||||
raise typer.Exit(1)
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
|
||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension)
|
metadata = manager.registry.get(extension_id)
|
||||||
|
if metadata is None:
|
||||||
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if not metadata.get("enabled", True):
|
if not metadata.get("enabled", True):
|
||||||
console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]")
|
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
metadata["enabled"] = False
|
metadata["enabled"] = False
|
||||||
manager.registry.add(extension, metadata)
|
manager.registry.update(extension_id, metadata)
|
||||||
|
|
||||||
# Disable hooks in extensions.yml
|
# Disable hooks in extensions.yml
|
||||||
config = hook_executor.get_project_config()
|
config = hook_executor.get_project_config()
|
||||||
if "hooks" in config:
|
if "hooks" in config:
|
||||||
for hook_name in config["hooks"]:
|
for hook_name in config["hooks"]:
|
||||||
for hook in config["hooks"][hook_name]:
|
for hook in config["hooks"][hook_name]:
|
||||||
if hook.get("extension") == extension:
|
if hook.get("extension") == extension_id:
|
||||||
hook["enabled"] = False
|
hook["enabled"] = False
|
||||||
hook_executor.save_project_config(config)
|
hook_executor.save_project_config(config)
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Extension '{extension}' disabled")
|
console.print(f"[green]✓[/green] Extension '{display_name}' disabled")
|
||||||
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||||
console.print(f"To re-enable: specify extension enable {extension}")
|
console.print(f"To re-enable: specify extension enable {extension_id}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
import shutil
|
import shutil
|
||||||
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, List, Any, Callable, Set
|
from typing import Optional, Dict, List, Any, Callable, Set
|
||||||
@@ -228,6 +229,54 @@ class ExtensionRegistry:
|
|||||||
}
|
}
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
|
def update(self, extension_id: str, metadata: dict):
|
||||||
|
"""Update extension metadata in registry, merging with existing entry.
|
||||||
|
|
||||||
|
Merges the provided metadata with the existing entry, preserving any
|
||||||
|
fields not specified in the new metadata. The installed_at timestamp
|
||||||
|
is always preserved from the original entry.
|
||||||
|
|
||||||
|
Use this method instead of add() when updating existing extension
|
||||||
|
metadata (e.g., enabling/disabling) to preserve the original
|
||||||
|
installation timestamp and other existing fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
extension_id: Extension ID
|
||||||
|
metadata: Extension metadata fields to update (merged with existing)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If extension is not installed
|
||||||
|
"""
|
||||||
|
if extension_id not in self.data["extensions"]:
|
||||||
|
raise KeyError(f"Extension '{extension_id}' is not installed")
|
||||||
|
# Merge new metadata with existing, preserving original installed_at
|
||||||
|
existing = self.data["extensions"][extension_id]
|
||||||
|
# Merge: existing fields preserved, new fields override
|
||||||
|
merged = {**existing, **metadata}
|
||||||
|
# Always preserve original installed_at based on key existence, not truthiness,
|
||||||
|
# to handle cases where the field exists but may be falsy (legacy/corruption)
|
||||||
|
if "installed_at" in existing:
|
||||||
|
merged["installed_at"] = existing["installed_at"]
|
||||||
|
else:
|
||||||
|
# If not present in existing, explicitly remove from merged if caller provided it
|
||||||
|
merged.pop("installed_at", None)
|
||||||
|
self.data["extensions"][extension_id] = merged
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def restore(self, extension_id: str, metadata: dict):
|
||||||
|
"""Restore extension metadata to registry without modifying timestamps.
|
||||||
|
|
||||||
|
Use this method for rollback scenarios where you have a complete backup
|
||||||
|
of the registry entry (including installed_at) and want to restore it
|
||||||
|
exactly as it was.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
extension_id: Extension ID
|
||||||
|
metadata: Complete extension metadata including installed_at
|
||||||
|
"""
|
||||||
|
self.data["extensions"][extension_id] = dict(metadata)
|
||||||
|
self._save()
|
||||||
|
|
||||||
def remove(self, extension_id: str):
|
def remove(self, extension_id: str):
|
||||||
"""Remove extension from registry.
|
"""Remove extension from registry.
|
||||||
|
|
||||||
@@ -241,21 +290,28 @@ class ExtensionRegistry:
|
|||||||
def get(self, extension_id: str) -> Optional[dict]:
|
def get(self, extension_id: str) -> Optional[dict]:
|
||||||
"""Get extension metadata from registry.
|
"""Get extension metadata from registry.
|
||||||
|
|
||||||
|
Returns a deep copy to prevent callers from accidentally mutating
|
||||||
|
nested internal registry state without going through the write path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
extension_id: Extension ID
|
extension_id: Extension ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Extension metadata or None if not found
|
Deep copy of extension metadata, or None if not found
|
||||||
"""
|
"""
|
||||||
return self.data["extensions"].get(extension_id)
|
entry = self.data["extensions"].get(extension_id)
|
||||||
|
return copy.deepcopy(entry) if entry is not None else None
|
||||||
|
|
||||||
def list(self) -> Dict[str, dict]:
|
def list(self) -> Dict[str, dict]:
|
||||||
"""Get all installed extensions.
|
"""Get all installed extensions.
|
||||||
|
|
||||||
|
Returns a deep copy of the extensions mapping to prevent callers
|
||||||
|
from accidentally mutating nested internal registry state.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of extension_id -> metadata
|
Dictionary of extension_id -> metadata (deep copies)
|
||||||
"""
|
"""
|
||||||
return self.data["extensions"]
|
return copy.deepcopy(self.data["extensions"])
|
||||||
|
|
||||||
def is_installed(self, extension_id: str) -> bool:
|
def is_installed(self, extension_id: str) -> bool:
|
||||||
"""Check if extension is installed.
|
"""Check if extension is installed.
|
||||||
@@ -600,7 +656,7 @@ class ExtensionManager:
|
|||||||
result.append({
|
result.append({
|
||||||
"id": ext_id,
|
"id": ext_id,
|
||||||
"name": manifest.name,
|
"name": manifest.name,
|
||||||
"version": metadata["version"],
|
"version": metadata.get("version", "unknown"),
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"enabled": metadata.get("enabled", True),
|
"enabled": metadata.get("enabled", True),
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
@@ -1112,12 +1168,13 @@ class ExtensionCatalog:
|
|||||||
config_path: Path to extension-catalogs.yml
|
config_path: Path to extension-catalogs.yml
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Ordered list of CatalogEntry objects, or None if file doesn't exist
|
Ordered list of CatalogEntry objects, or None if file doesn't exist.
|
||||||
or contains no valid catalog entries.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If any catalog entry has an invalid URL,
|
ValidationError: If any catalog entry has an invalid URL,
|
||||||
the file cannot be parsed, or a priority value is invalid.
|
the file cannot be parsed, a priority value is invalid,
|
||||||
|
or the file exists but contains no valid catalog entries
|
||||||
|
(fail-closed for security).
|
||||||
"""
|
"""
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -1129,12 +1186,17 @@ class ExtensionCatalog:
|
|||||||
)
|
)
|
||||||
catalogs_data = data.get("catalogs", [])
|
catalogs_data = data.get("catalogs", [])
|
||||||
if not catalogs_data:
|
if not catalogs_data:
|
||||||
return None
|
# File exists but has no catalogs key or empty list - fail closed
|
||||||
|
raise ValidationError(
|
||||||
|
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||||
|
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||||
|
)
|
||||||
if not isinstance(catalogs_data, list):
|
if not isinstance(catalogs_data, list):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||||
)
|
)
|
||||||
entries: List[CatalogEntry] = []
|
entries: List[CatalogEntry] = []
|
||||||
|
skipped_entries: List[int] = []
|
||||||
for idx, item in enumerate(catalogs_data):
|
for idx, item in enumerate(catalogs_data):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -1142,6 +1204,7 @@ class ExtensionCatalog:
|
|||||||
)
|
)
|
||||||
url = str(item.get("url", "")).strip()
|
url = str(item.get("url", "")).strip()
|
||||||
if not url:
|
if not url:
|
||||||
|
skipped_entries.append(idx)
|
||||||
continue
|
continue
|
||||||
self._validate_catalog_url(url)
|
self._validate_catalog_url(url)
|
||||||
try:
|
try:
|
||||||
@@ -1164,7 +1227,14 @@ class ExtensionCatalog:
|
|||||||
description=str(item.get("description", "")),
|
description=str(item.get("description", "")),
|
||||||
))
|
))
|
||||||
entries.sort(key=lambda e: e.priority)
|
entries.sort(key=lambda e: e.priority)
|
||||||
return entries if entries else None
|
if not entries:
|
||||||
|
# All entries were invalid (missing URLs) - fail closed for security
|
||||||
|
raise ValidationError(
|
||||||
|
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
|
||||||
|
f"(entries at indices {skipped_entries} were skipped). "
|
||||||
|
f"Each catalog entry must have a 'url' field."
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||||
"""Get the ordered list of active catalogs.
|
"""Get the ordered list of active catalogs.
|
||||||
|
|||||||
@@ -277,6 +277,135 @@ class TestExtensionRegistry:
|
|||||||
assert registry2.is_installed("test-ext")
|
assert registry2.is_installed("test-ext")
|
||||||
assert registry2.get("test-ext")["version"] == "1.0.0"
|
assert registry2.get("test-ext")["version"] == "1.0.0"
|
||||||
|
|
||||||
|
def test_update_preserves_installed_at(self, temp_dir):
|
||||||
|
"""Test that update() preserves the original installed_at timestamp."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
registry.add("test-ext", {"version": "1.0.0", "enabled": True})
|
||||||
|
|
||||||
|
# Get original installed_at
|
||||||
|
original_data = registry.get("test-ext")
|
||||||
|
original_installed_at = original_data["installed_at"]
|
||||||
|
|
||||||
|
# Update with new metadata
|
||||||
|
registry.update("test-ext", {"version": "2.0.0", "enabled": False})
|
||||||
|
|
||||||
|
# Verify installed_at is preserved
|
||||||
|
updated_data = registry.get("test-ext")
|
||||||
|
assert updated_data["installed_at"] == original_installed_at
|
||||||
|
assert updated_data["version"] == "2.0.0"
|
||||||
|
assert updated_data["enabled"] is False
|
||||||
|
|
||||||
|
def test_update_merges_with_existing(self, temp_dir):
|
||||||
|
"""Test that update() merges new metadata with existing fields."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
registry.add("test-ext", {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"enabled": True,
|
||||||
|
"registered_commands": {"claude": ["cmd1", "cmd2"]},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update with partial metadata (only enabled field)
|
||||||
|
registry.update("test-ext", {"enabled": False})
|
||||||
|
|
||||||
|
# Verify existing fields are preserved
|
||||||
|
updated_data = registry.get("test-ext")
|
||||||
|
assert updated_data["enabled"] is False
|
||||||
|
assert updated_data["version"] == "1.0.0" # Preserved
|
||||||
|
assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved
|
||||||
|
|
||||||
|
def test_update_raises_for_missing_extension(self, temp_dir):
|
||||||
|
"""Test that update() raises KeyError for non-installed extension."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="not installed"):
|
||||||
|
registry.update("nonexistent-ext", {"enabled": False})
|
||||||
|
|
||||||
|
def test_restore_overwrites_completely(self, temp_dir):
|
||||||
|
"""Test that restore() overwrites the registry entry completely."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
registry.add("test-ext", {"version": "2.0.0", "enabled": True})
|
||||||
|
|
||||||
|
# Restore with complete backup data
|
||||||
|
backup_data = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"enabled": False,
|
||||||
|
"installed_at": "2024-01-01T00:00:00+00:00",
|
||||||
|
"registered_commands": {"claude": ["old-cmd"]},
|
||||||
|
}
|
||||||
|
registry.restore("test-ext", backup_data)
|
||||||
|
|
||||||
|
# Verify entry is exactly as restored
|
||||||
|
restored_data = registry.get("test-ext")
|
||||||
|
assert restored_data == backup_data
|
||||||
|
|
||||||
|
def test_restore_can_recreate_removed_entry(self, temp_dir):
|
||||||
|
"""Test that restore() can recreate an entry after remove()."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
registry.add("test-ext", {"version": "1.0.0"})
|
||||||
|
|
||||||
|
# Save backup and remove
|
||||||
|
backup = registry.get("test-ext").copy()
|
||||||
|
registry.remove("test-ext")
|
||||||
|
assert not registry.is_installed("test-ext")
|
||||||
|
|
||||||
|
# Restore should recreate the entry
|
||||||
|
registry.restore("test-ext", backup)
|
||||||
|
assert registry.is_installed("test-ext")
|
||||||
|
assert registry.get("test-ext")["version"] == "1.0.0"
|
||||||
|
|
||||||
|
def test_get_returns_deep_copy(self, temp_dir):
|
||||||
|
"""Test that get() returns deep copies for nested structures."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
metadata = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"registered_commands": {"claude": ["cmd1"]},
|
||||||
|
}
|
||||||
|
registry.add("test-ext", metadata)
|
||||||
|
|
||||||
|
fetched = registry.get("test-ext")
|
||||||
|
fetched["registered_commands"]["claude"].append("cmd2")
|
||||||
|
|
||||||
|
# Internal registry must remain unchanged.
|
||||||
|
internal = registry.data["extensions"]["test-ext"]
|
||||||
|
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||||
|
|
||||||
|
def test_list_returns_deep_copy(self, temp_dir):
|
||||||
|
"""Test that list() returns deep copies for nested structures."""
|
||||||
|
extensions_dir = temp_dir / "extensions"
|
||||||
|
extensions_dir.mkdir()
|
||||||
|
|
||||||
|
registry = ExtensionRegistry(extensions_dir)
|
||||||
|
metadata = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"registered_commands": {"claude": ["cmd1"]},
|
||||||
|
}
|
||||||
|
registry.add("test-ext", metadata)
|
||||||
|
|
||||||
|
listed = registry.list()
|
||||||
|
listed["test-ext"]["registered_commands"]["claude"].append("cmd2")
|
||||||
|
|
||||||
|
# Internal registry must remain unchanged.
|
||||||
|
internal = registry.data["extensions"]["test-ext"]
|
||||||
|
assert internal["registered_commands"] == {"claude": ["cmd1"]}
|
||||||
|
|
||||||
|
|
||||||
# ===== ExtensionManager Tests =====
|
# ===== ExtensionManager Tests =====
|
||||||
|
|
||||||
@@ -1402,8 +1531,8 @@ class TestCatalogStack:
|
|||||||
with pytest.raises(ValidationError, match="HTTPS"):
|
with pytest.raises(ValidationError, match="HTTPS"):
|
||||||
catalog.get_active_catalogs()
|
catalog.get_active_catalogs()
|
||||||
|
|
||||||
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
|
def test_empty_project_config_raises_error(self, temp_dir):
|
||||||
"""Empty catalogs list in config falls back to default stack."""
|
"""Empty catalogs list in config raises ValidationError (fail-closed for security)."""
|
||||||
import yaml as yaml_module
|
import yaml as yaml_module
|
||||||
|
|
||||||
project_dir = self._make_project(temp_dir)
|
project_dir = self._make_project(temp_dir)
|
||||||
@@ -1412,11 +1541,32 @@ class TestCatalogStack:
|
|||||||
yaml_module.dump({"catalogs": []}, f)
|
yaml_module.dump({"catalogs": []}, f)
|
||||||
|
|
||||||
catalog = ExtensionCatalog(project_dir)
|
catalog = ExtensionCatalog(project_dir)
|
||||||
entries = catalog.get_active_catalogs()
|
|
||||||
|
|
||||||
# Falls back to default stack
|
# Fail-closed: empty config should raise, not fall back to defaults
|
||||||
assert len(entries) == 2
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
catalog.get_active_catalogs()
|
||||||
|
assert "contains no 'catalogs' entries" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_catalog_entries_without_urls_raises_error(self, temp_dir):
|
||||||
|
"""Catalog entries without URLs raise ValidationError (fail-closed for security)."""
|
||||||
|
import yaml as yaml_module
|
||||||
|
|
||||||
|
project_dir = self._make_project(temp_dir)
|
||||||
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml_module.dump({
|
||||||
|
"catalogs": [
|
||||||
|
{"name": "no-url-catalog", "priority": 1},
|
||||||
|
{"name": "another-no-url", "description": "Also missing URL"},
|
||||||
|
]
|
||||||
|
}, f)
|
||||||
|
|
||||||
|
catalog = ExtensionCatalog(project_dir)
|
||||||
|
|
||||||
|
# Fail-closed: entries without URLs should raise, not fall back to defaults
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
catalog.get_active_catalogs()
|
||||||
|
assert "none have valid URLs" in str(exc_info.value)
|
||||||
|
|
||||||
# --- _load_catalog_config ---
|
# --- _load_catalog_config ---
|
||||||
|
|
||||||
@@ -1943,3 +2093,238 @@ class TestExtensionIgnore:
|
|||||||
assert not (dest / "docs" / "guide.md").exists()
|
assert not (dest / "docs" / "guide.md").exists()
|
||||||
assert not (dest / "docs" / "internal.md").exists()
|
assert not (dest / "docs" / "internal.md").exists()
|
||||||
assert (dest / "docs" / "api.md").exists()
|
assert (dest / "docs" / "api.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionAddCLI:
|
||||||
|
"""CLI integration tests for extension add command."""
|
||||||
|
|
||||||
|
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
|
||||||
|
"""extension add by display name should use resolved ID for download_extension()."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
# Create project structure
|
||||||
|
project_dir = tmp_path / "test-project"
|
||||||
|
project_dir.mkdir()
|
||||||
|
(project_dir / ".specify").mkdir()
|
||||||
|
(project_dir / ".specify" / "extensions").mkdir(parents=True)
|
||||||
|
|
||||||
|
# Mock catalog that returns extension by display name
|
||||||
|
mock_catalog = MagicMock()
|
||||||
|
mock_catalog.get_extension_info.return_value = None # ID lookup fails
|
||||||
|
mock_catalog.search.return_value = [
|
||||||
|
{
|
||||||
|
"id": "acme-jira-integration",
|
||||||
|
"name": "Jira Integration",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Jira integration extension",
|
||||||
|
"_install_allowed": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Track what ID was passed to download_extension
|
||||||
|
download_called_with = []
|
||||||
|
def mock_download(extension_id):
|
||||||
|
download_called_with.append(extension_id)
|
||||||
|
# Return a path that will fail install (we just want to verify the ID)
|
||||||
|
raise ExtensionError("Mock download - checking ID was resolved")
|
||||||
|
|
||||||
|
mock_catalog.download_extension.side_effect = mock_download
|
||||||
|
|
||||||
|
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
|
||||||
|
patch.object(Path, "cwd", return_value=project_dir):
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["extension", "add", "Jira Integration"],
|
||||||
|
catch_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code != 0, (
|
||||||
|
f"Expected non-zero exit code since mock download raises, got {result.exit_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify download_extension was called with the resolved ID, not the display name
|
||||||
|
assert len(download_called_with) == 1
|
||||||
|
assert download_called_with[0] == "acme-jira-integration", (
|
||||||
|
f"Expected download_extension to be called with resolved ID 'acme-jira-integration', "
|
||||||
|
f"but was called with '{download_called_with[0]}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionUpdateCLI:
|
||||||
|
"""CLI integration tests for extension update command."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path:
|
||||||
|
"""Create a minimal extension source directory for install tests."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = base_dir / f"test-ext-{version}"
|
||||||
|
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "test-ext",
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": version,
|
||||||
|
"description": "A test extension",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.test.hello",
|
||||||
|
"file": "commands/hello.md",
|
||||||
|
"description": "Test command",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"after_tasks": {
|
||||||
|
"command": "speckit.test.hello",
|
||||||
|
"optional": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False))
|
||||||
|
commands_dir = ext_dir / "commands"
|
||||||
|
commands_dir.mkdir(exist_ok=True)
|
||||||
|
(commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n")
|
||||||
|
if include_config:
|
||||||
|
(ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n")
|
||||||
|
return ext_dir
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_catalog_zip(zip_path: Path, version: str):
|
||||||
|
"""Create a minimal ZIP that passes extension_update ID validation."""
|
||||||
|
import zipfile
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "test-ext",
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": version,
|
||||||
|
"description": "A test extension",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
zf.writestr("extension.yml", yaml.dump(manifest, sort_keys=False))
|
||||||
|
|
||||||
|
def test_update_success_preserves_installed_at(self, tmp_path):
|
||||||
|
"""Successful update should keep original installed_at and apply new version."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
project_dir = tmp_path / "project"
|
||||||
|
project_dir.mkdir()
|
||||||
|
(project_dir / ".specify").mkdir()
|
||||||
|
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
|
||||||
|
manager.install_from_directory(v1_dir, "0.1.0")
|
||||||
|
original_installed_at = manager.registry.get("test-ext")["installed_at"]
|
||||||
|
original_config_content = (
|
||||||
|
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
zip_path = tmp_path / "test-ext-update.zip"
|
||||||
|
self._create_catalog_zip(zip_path, "2.0.0")
|
||||||
|
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
|
||||||
|
|
||||||
|
def fake_install_from_zip(self_obj, _zip_path, speckit_version):
|
||||||
|
return self_obj.install_from_directory(v2_dir, speckit_version)
|
||||||
|
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||||
|
patch.object(ExtensionCatalog, "get_extension_info", return_value={
|
||||||
|
"id": "test-ext",
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"_install_allowed": True,
|
||||||
|
}), \
|
||||||
|
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
|
||||||
|
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
|
||||||
|
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
updated = ExtensionManager(project_dir).registry.get("test-ext")
|
||||||
|
assert updated["version"] == "2.0.0"
|
||||||
|
assert updated["installed_at"] == original_installed_at
|
||||||
|
restored_config_content = (
|
||||||
|
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
|
||||||
|
).read_text()
|
||||||
|
assert restored_config_content == original_config_content
|
||||||
|
|
||||||
|
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path):
|
||||||
|
"""Failed update should restore original registry, hooks, and command files."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
project_dir = tmp_path / "project"
|
||||||
|
project_dir.mkdir()
|
||||||
|
(project_dir / ".specify").mkdir()
|
||||||
|
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
|
||||||
|
manager.install_from_directory(v1_dir, "0.1.0")
|
||||||
|
|
||||||
|
backup_registry_entry = manager.registry.get("test-ext")
|
||||||
|
hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
|
||||||
|
|
||||||
|
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||||
|
command_files = []
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
for agent_name, cmd_names in registered_commands.items():
|
||||||
|
if agent_name not in registrar.AGENT_CONFIGS:
|
||||||
|
continue
|
||||||
|
agent_cfg = registrar.AGENT_CONFIGS[agent_name]
|
||||||
|
commands_dir = project_dir / agent_cfg["dir"]
|
||||||
|
for cmd_name in cmd_names:
|
||||||
|
cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}"
|
||||||
|
command_files.append(cmd_path)
|
||||||
|
|
||||||
|
assert command_files, "Expected at least one registered command file"
|
||||||
|
for cmd_file in command_files:
|
||||||
|
assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}"
|
||||||
|
|
||||||
|
zip_path = tmp_path / "test-ext-update.zip"
|
||||||
|
self._create_catalog_zip(zip_path, "2.0.0")
|
||||||
|
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||||
|
patch.object(ExtensionCatalog, "get_extension_info", return_value={
|
||||||
|
"id": "test-ext",
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"_install_allowed": True,
|
||||||
|
}), \
|
||||||
|
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
|
||||||
|
patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")):
|
||||||
|
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
|
||||||
|
|
||||||
|
assert result.exit_code == 1, result.output
|
||||||
|
|
||||||
|
restored_entry = ExtensionManager(project_dir).registry.get("test-ext")
|
||||||
|
assert restored_entry == backup_registry_entry
|
||||||
|
|
||||||
|
hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
|
||||||
|
assert hooks_after == hooks_before
|
||||||
|
|
||||||
|
for cmd_file in command_files:
|
||||||
|
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
|
||||||
|
|||||||
Reference in New Issue
Block a user