From bcc40e011587efd6d316b8aea302af77c570e040 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sat, 21 Feb 2026 00:59:20 +0100 Subject: [PATCH 1/3] feat: launch module marketplace lifecycle and trust-first UX Deliver the central module marketplace workflow with source-aware discovery, lifecycle management, and trust/publisher visibility so users can safely manage official vs local modules. This also aligns docs and OpenSpec artifacts with the shipped behavior, including command introspection and clearer install/uninstall guidance. Co-authored-by: Cursor --- CHANGELOG.md | 16 + README.md | 14 +- docs/README.md | 10 +- docs/_layouts/default.html | 2 + docs/guides/README.md | 2 + docs/guides/installing-modules.md | 110 +++ docs/guides/module-marketplace.md | 66 ++ docs/index.md | 10 + docs/reference/README.md | 6 + docs/reference/architecture.md | 51 +- docs/reference/commands.md | 61 +- docs/reference/directory-structure.md | 14 +- modules/backlog-core/module-package.yaml | 17 +- modules/bundle-mapper/module-package.yaml | 15 +- modules/bundle-mapper/src/app.py | 38 + .../CHANGE_VALIDATION.md | 266 ++----- .../TDD_EVIDENCE.md | 37 + .../design.md | 17 + .../proposal.md | 9 +- .../specs/module-lifecycle-management/spec.md | 15 + .../tasks.md | 379 +++++----- pyproject.toml | 2 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/models/module_package.py | 10 + .../modules/analyze/module-package.yaml | 13 +- .../modules/auth/module-package.yaml | 13 +- .../modules/backlog/module-package.yaml | 13 +- .../modules/contract/module-package.yaml | 13 +- .../modules/drift/module-package.yaml | 13 +- .../modules/enforce/module-package.yaml | 13 +- .../modules/generate/module-package.yaml | 13 +- .../modules/import_cmd/module-package.yaml | 13 +- .../modules/init/module-package.yaml | 13 +- src/specfact_cli/modules/init/src/commands.py | 19 +- .../modules/migrate/module-package.yaml | 13 +- .../module_registry/module-package.yaml | 16 + .../modules/module_registry/src/__init__.py | 1 + .../modules/module_registry/src/app.py | 18 + .../modules/module_registry/src/commands.py | 459 ++++++++++++ .../modules/patch_mode/module-package.yaml | 13 +- .../modules/plan/module-package.yaml | 13 +- .../modules/policy_engine/module-package.yaml | 13 +- .../modules/project/module-package.yaml | 13 +- .../modules/repro/module-package.yaml | 13 +- .../modules/sdd/module-package.yaml | 13 +- .../modules/spec/module-package.yaml | 13 +- .../modules/sync/module-package.yaml | 13 +- .../modules/upgrade/module-package.yaml | 13 +- .../modules/validate/module-package.yaml | 13 +- .../registry/marketplace_client.py | 100 +++ src/specfact_cli/registry/module_discovery.py | 87 +++ src/specfact_cli/registry/module_installer.py | 125 +++- src/specfact_cli/registry/module_lifecycle.py | 184 +++++ src/specfact_cli/registry/module_packages.py | 28 +- .../modules/module_registry/test_commands.py | 694 ++++++++++++++++++ .../unit/registry/test_marketplace_client.py | 120 +++ tests/unit/registry/test_module_discovery.py | 72 ++ tests/unit/registry/test_module_installer.py | 87 +++ .../registry/test_command_registry.py | 14 + .../test_module_migration_compatibility.py | 61 +- 62 files changed, 2970 insertions(+), 548 deletions(-) create mode 100644 docs/guides/installing-modules.md create mode 100644 docs/guides/module-marketplace.md create mode 100644 modules/bundle-mapper/src/app.py create mode 100644 openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md create mode 100644 src/specfact_cli/modules/module_registry/module-package.yaml create mode 100644 src/specfact_cli/modules/module_registry/src/__init__.py create mode 100644 src/specfact_cli/modules/module_registry/src/app.py create mode 100644 src/specfact_cli/modules/module_registry/src/commands.py create mode 100644 src/specfact_cli/registry/marketplace_client.py create mode 100644 src/specfact_cli/registry/module_discovery.py create mode 100644 src/specfact_cli/registry/module_lifecycle.py create mode 100644 tests/unit/modules/module_registry/test_commands.py create mode 100644 tests/unit/registry/test_marketplace_client.py create mode 100644 tests/unit/registry/test_module_discovery.py create mode 100644 tests/unit/registry/test_module_installer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f523a5..58d2ed5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project will be documented in this file. **Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first. --- +## [0.35.0] - 2026-02-20 + +### Added + +- Central module marketplace foundations (OpenSpec change `marketplace-01-central-module-registry`) with multi-location discovery, source tracking (`builtin`/`marketplace`/`custom`), and source-priority shadow handling. +- New module registry client and installer workflows for fetching registry index, secure module download with checksum verification, install/uninstall operations, and core compatibility validation. +- New `specfact module` command group with `install`, `uninstall`, `search`, `list`, and `upgrade` subcommands. +- New docs: [Installing Modules](docs/guides/installing-modules.md) and [Module Marketplace](docs/guides/module-marketplace.md), plus architecture and sidebar updates for marketplace workflows. + +### Changed + +- Module package metadata now includes `source` to persist module origin across discovery and lifecycle registration. +- README module lifecycle baseline now includes marketplace command entry points. + +--- + ## [0.34.1] - 2026-02-18 ### Fixed diff --git a/README.md b/README.md index d24dcb5f..e08682e5 100644 --- a/README.md +++ b/README.md @@ -158,13 +158,15 @@ Start with: SpecFact now has a lifecycle-managed module system: -- `specfact init` is bootstrap-first: initializes local CLI state, discovers installed modules, and reports prompt status. +- `specfact init` is bootstrap-first: initializes local CLI state and reports prompt status. - `specfact init ide` handles IDE prompt/template sync and IDE settings updates. -- `specfact init --list-modules` shows effective enabled/disabled state. -- `specfact init --enable-module` / `--disable-module` support: - - interactive selection in interactive terminals when no module id is provided - - explicit ids in non-interactive mode (for automation) - - dependency-aware safety checks with `--force` cascading enable/disable behavior +- `specfact module` is the canonical lifecycle surface: + - `specfact module install ` installs marketplace modules into `~/.specfact/marketplace-modules/`. + - `specfact module list [--source builtin|marketplace|custom]` shows multi-source discovery state. + - `specfact module enable ` / `specfact module disable [--force]` manage enabled state. + - `specfact module uninstall ` and `specfact module upgrade ` manage marketplace lifecycle. +- `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain supported as compatibility aliases during migration. +- Module lifecycle operations keep dependency-aware safety checks with `--force` cascading behavior. - Module manifests support dependency and core-version compatibility enforcement at registration time. This lifecycle model is the baseline for future granular module updates and enhancements. Module installation from third-party or open-source community providers is planned, but not implemented yet. diff --git a/docs/README.md b/docs/README.md index d63c6682..e74e2c7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -78,13 +78,13 @@ Start with: SpecFact CLI uses a lifecycle-managed module system: -- `specfact init` bootstraps local state and manages module enable/disable lifecycle. +- `specfact init` bootstraps local state. - `specfact init ide` handles IDE prompt/template installation and updates. -- `specfact init --list-modules` shows current enabled/disabled state. -- `--enable-module` and `--disable-module` support interactive selection in interactive terminals and explicit ids in non-interactive mode. +- `specfact module` is the canonical lifecycle surface for install/list/show/search/enable/disable/uninstall/upgrade. +- `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain compatibility aliases. - Dependency and compatibility guards prevent invalid module states; `--force` enables dependency-aware cascades. -This is the baseline for future granular module updates and enhancements. Third-party/community module installation is planned, but not available yet. +This is the baseline for marketplace-driven module lifecycle and future community module distribution. ### Why the Module System Is the Foundation @@ -104,6 +104,8 @@ For implementation details, see: - [Architecture](reference/architecture.md) - [Module Contracts](reference/module-contracts.md) +- [Installing Modules](guides/installing-modules.md) +- [Module Marketplace](guides/module-marketplace.md) --- diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index eabf781d..58ec6f32 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -144,6 +144,8 @@

  • Policy Engine Commands
  • Creating Custom Bridges
  • Extending ProjectBundle
  • +
  • Installing Modules
  • +
  • Module Marketplace
  • Using Module Security and Extensions
  • Working With Existing Code
  • Existing Code Journey
  • diff --git a/docs/guides/README.md b/docs/guides/README.md index 16251e4a..d2c887bb 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -27,6 +27,8 @@ Practical guides for using SpecFact CLI effectively. - **[Backlog Refinement](backlog-refinement.md)** 🆕 **NEW FEATURE** - AI-assisted template-driven refinement for standardizing work items with persona/framework filtering, sprint/iteration support, and DoR validation - **[Specmatic Integration](specmatic-integration.md)** - API contract testing with Specmatic (validate specs, generate tests, mock servers) - **[Troubleshooting](troubleshooting.md)** - Common issues and solutions +- **[Installing Modules](installing-modules.md)** - Install, list, show, search, enable/disable, uninstall, and upgrade modules +- **[Module Marketplace](module-marketplace.md)** - Discovery priority, trust vs origin semantics, and security model - **[Competitive Analysis](competitive-analysis.md)** - How SpecFact compares to other tools - **[Operational Modes](../reference/modes.md)** - CI/CD vs CoPilot modes (reference) diff --git a/docs/guides/installing-modules.md b/docs/guides/installing-modules.md new file mode 100644 index 00000000..e16bbca3 --- /dev/null +++ b/docs/guides/installing-modules.md @@ -0,0 +1,110 @@ +--- +layout: default +title: Installing Modules +permalink: /guides/installing-modules/ +description: Install, list, show, enable, disable, uninstall, and upgrade SpecFact modules. +--- + +# Installing Modules + +Use the `specfact module` command group to manage marketplace and locally discovered modules. + +## Install Behavior + +```bash +# Marketplace id format +specfact module install specfact/backlog + +# Bare names are accepted and normalized to specfact/ +specfact module install backlog + +# Install a specific version +specfact module install specfact/backlog --version 0.35.0 +``` + +Notes: + +- If a module is already available locally (`built-in` or `custom`), install is skipped with a clear message. +- Invalid ids show an explicit error (`name` or `namespace/name` only). + +## List Modules + +```bash +specfact module list +specfact module list --show-origin +specfact module list --source marketplace +``` + +Default columns: + +- `Module` +- `Version` +- `State` +- `Trust` (`official`, `community`, `local-dev`) +- `Publisher` + +With `--show-origin`, an additional `Origin` column is shown (`built-in`, `marketplace`, `custom`). + +## Show Detailed Module Info + +```bash +specfact module show module-registry +``` + +This prints detailed metadata: + +- Name, description, version, state +- Trust, publisher, publisher URL, license +- Origin, tier, core compatibility +- Full command tree (including subcommands) with short command descriptions + +## Search Modules + +```bash +specfact module search bundle-mapper +``` + +Search includes both: + +- Marketplace registry entries (`scope=marketplace`) +- Locally discovered modules (`scope=installed`) + +Results are sorted alphabetically by module id. + +## Enable and Disable Modules + +```bash +specfact module enable backlog +specfact module disable backlog +specfact module disable plan --force +``` + +Use `--force` to allow dependency-aware cascades when required. + +## Uninstall Behavior + +```bash +specfact module uninstall backlog +specfact module uninstall specfact/backlog +``` + +Uninstall only removes marketplace-installed modules. + +Clear guidance is provided for: + +- `built-in` modules (disable instead of uninstall) +- `custom` modules (remove from local module roots) +- unknown/untracked modules (`module list --show-origin`) + +## Upgrade Behavior + +```bash +# Upgrade a single marketplace module +specfact module upgrade backlog + +# Upgrade all marketplace modules +specfact module upgrade +specfact module upgrade --all +``` + +Upgrade applies only to modules with origin `marketplace`. diff --git a/docs/guides/module-marketplace.md b/docs/guides/module-marketplace.md new file mode 100644 index 00000000..9f5841e6 --- /dev/null +++ b/docs/guides/module-marketplace.md @@ -0,0 +1,66 @@ +--- +layout: default +title: Module Marketplace +permalink: /guides/module-marketplace/ +description: Registry model, discovery priority, trust semantics, and security checks for SpecFact modules. +--- + +# Module Marketplace + +SpecFact supports centralized marketplace distribution with local multi-source discovery. + +## Registry Overview + +- Registry repository: +- Index document: `registry/index.json` +- Marketplace module id format: `namespace/name` (for example `specfact/backlog`) + +## Discovery and Priority + +Local module discovery scans these roots in priority order: + +1. `built-in` modules (`src/specfact_cli/modules`) +2. `marketplace` modules (`~/.specfact/marketplace-modules`) +3. `custom` modules (`~/.specfact/custom-modules`) +4. extra custom roots (workspace `modules/` and `SPECFACT_MODULES_ROOTS`) + +If module names collide, higher-priority sources win and lower-priority entries are shadowed. + +## Trust vs Origin + +SpecFact shows both trust semantics and origin details: + +- `Trust` column (default): `official`, `community`, `local-dev` +- `Origin` column (`--show-origin`): `built-in`, `marketplace`, `custom` + +Use: + +```bash +specfact module list --show-origin +``` + +## Security Model + +Install workflow enforces integrity and compatibility checks: + +1. Fetch registry index +2. Download module archive +3. Validate SHA-256 checksum +4. Validate module `core_compatibility` against current CLI version +5. Install into `~/.specfact/marketplace-modules/` + +Checksum mismatch blocks installation. + +## Marketplace vs Local Modules + +- `specfact module install` targets marketplace modules. +- If a requested module already exists locally (`built-in`/`custom`), install reports that no marketplace install is needed. +- `specfact module uninstall` removes only marketplace-installed modules and provides actionable guidance for built-in/custom modules. + +## Module Introspection + +`specfact module show ` includes: + +- Module metadata (publisher, license, trust, origin, compatibility) +- Full command tree, including subcommands +- Short command descriptions derived from Typer command registration diff --git a/docs/index.md b/docs/index.md index a3dd9b61..fc163a94 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,16 @@ Why this matters: - **[Extending ProjectBundle](guides/extending-projectbundle.md)** - Declare and use namespaced extension fields on Feature/ProjectBundle - **[Module Security](reference/module-security.md)** - Publisher, integrity (checksum/signature), and versioned dependencies + +## Module Marketplace + +SpecFact now supports a central marketplace workflow for module installation and lifecycle management. + +- **[Installing Modules](guides/installing-modules.md)** - Install, list, uninstall, and upgrade modules +- **[Module Marketplace](guides/module-marketplace.md)** - Registry model, security checks, and discovery priority + +Compatibility note: `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain available as migration aliases while `specfact module` (`install`, `list`, `show`, `search`, `enable`, `disable`, `uninstall`, `upgrade`) is the canonical lifecycle command group. + ## 📚 Documentation ### Guides diff --git a/docs/reference/README.md b/docs/reference/README.md index 35e595a2..ec8f0471 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -22,6 +22,7 @@ Complete technical reference for SpecFact CLI. - **[Feature Keys](feature-keys.md)** - Key normalization and formats - **[Directory Structure](directory-structure.md)** - Project structure and organization - **[Schema Versioning](schema-versioning.md)** - Bundle schema versions and backward compatibility (v1.0, v1.1) +- **[Module Security](module-security.md)** - Marketplace/module integrity and publisher metadata ## Quick Reference @@ -38,6 +39,11 @@ Complete technical reference for SpecFact CLI. - `specfact spec generate-tests [--bundle ]` - Generate contract tests from specifications - `specfact spec mock [--bundle ]` - Launch mock server for development - `specfact init ide --ide ` - Initialize IDE integration explicitly +- `specfact module install ` - Install marketplace module (bare names normalize to `specfact/`) +- `specfact module list [--source ...] [--show-origin]` - List modules with trust/publisher and optional origin details +- `specfact module show ` - Show detailed module metadata and full command tree with short descriptions +- `specfact module search ` - Search marketplace and installed modules +- `specfact module uninstall ` / `specfact module upgrade [|--all]` - Manage module lifecycle with source-aware behavior ### Modes diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 9d94ea3a..b18bb64b 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -77,6 +77,49 @@ SpecFact is transitioning from hard-wired command wiring to a module-first archi - Easier interface-based testing and safer incremental migrations. - Better path for pending OpenSpec-driven module evolution. +## Module Marketplace + +SpecFact supports marketplace-driven module distribution with deterministic multi-location discovery. + +### Discovery Pattern + +Module discovery scans in strict priority order: + +1. Built-in modules (`site-packages/specfact_cli/modules/`) +2. Marketplace modules (`~/.specfact/marketplace-modules/`) +3. Custom modules (`~/.specfact/custom-modules/`) + +When duplicate module names exist, the higher-priority source wins and shadowed modules are ignored. + +### Registry Client Architecture + +The registry client fetches `index.json` from the central module repository and resolves: + +- module metadata (`id`, `namespace`, `latest_version`, compatibility) +- download URL +- checksum for integrity validation + +Install and search commands degrade gracefully in offline mode. + +### Install Sequence + +```mermaid +sequenceDiagram + participant User + participant CLI as specfact module install + participant Registry as Marketplace Registry + participant Local as ~/.specfact/marketplace-modules + + User->>CLI: install specfact/backlog + CLI->>Registry: fetch index.json + Registry-->>CLI: module metadata + checksum + CLI->>Registry: download tarball + Registry-->>CLI: module archive + CLI->>CLI: verify checksum + compatibility + CLI->>Local: extract and register module + CLI-->>User: install success +``` + ## Operational Modes SpecFact CLI supports two operational modes for different use cases: @@ -626,16 +669,16 @@ class ChangeArchive(BaseModel): - **File**: `~/.specfact/registry/modules.json` (created when you run `specfact init`). - **Content**: List of `{ "id", "version", "enabled" }` per module. Only modules with `enabled: true` have their commands registered. - **CLI**: - - `specfact init --list-modules` shows effective state. - - `specfact init --enable-module ` and `--disable-module ` update persisted state. - - In interactive terminals, `specfact init --enable-module` and `specfact init --disable-module` (without ids) open an interactive selector. + - Canonical lifecycle surface: `specfact module` (`install`, `list`, `uninstall`, `upgrade`). + - Compatibility aliases: `specfact init --list-modules`, `--enable-module`, `--disable-module` remain supported during migration. + - In interactive terminals, bare init compatibility flags still open an interactive selector. - In non-interactive mode, explicit module ids are required. - Safe dependency guards block invalid enable/disable actions unless `--force` is used. - With `--force`, enable cascades to required dependencies and disable cascades to enabled dependents. ### Lifecycle notes and roadmap -- `specfact init` is bootstrap/module-lifecycle focused. +- `specfact init` is bootstrap-focused; lifecycle UX is canonical in `specfact module` with init aliases preserved for compatibility. - `specfact init ide` is responsible for IDE prompt/template setup. - This lifecycle architecture is the baseline for future granular module updates and enhancements. - Third-party/community module installation is planned as a next step, but not implemented yet. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c8d08fa3..8a4c008f 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -3865,6 +3865,8 @@ specfact backlog analyze-deps --project-id [OPTIONS] **Common options:** +**Migration note:** `specfact module` is the canonical lifecycle command group. Init lifecycle flags remain supported as compatibility aliases. + - `--adapter ADAPTER` - Backlog adapter id (default: `github`) - `--template TEMPLATE` - Mapping template (default: `github_projects`) - `--custom-config PATH` - Optional custom mapping YAML @@ -3881,6 +3883,8 @@ specfact backlog trace-impact --project-id [OPTIONS] **Common options:** +**Migration note:** `specfact module` is the canonical lifecycle command group. Init lifecycle flags remain supported as compatibility aliases. + - `--adapter ADAPTER` - Backlog adapter id (default: `github`) - `--template TEMPLATE` - Mapping template (default: `github_projects`) - `--custom-config PATH` - Optional custom mapping YAML @@ -3895,6 +3899,8 @@ specfact backlog verify-readiness --project-id [OPTIONS] **Common options:** +**Migration note:** `specfact module` is the canonical lifecycle command group. Init lifecycle flags remain supported as compatibility aliases. + - `--adapter ADAPTER` - Backlog adapter id (default: `github`) - `--template TEMPLATE` - Mapping template (default: `github_projects`) - `--target-items CSV` - Optional comma-separated subset of item IDs @@ -5045,9 +5051,9 @@ Replace `implement tasks` with the new AI IDE bridge workflow: --- -### `init` - Bootstrap and Module Lifecycle Management +### `init` - Bootstrap and Compatibility Module Lifecycle Aliases -Bootstrap SpecFact local state and manage enabled/disabled command modules. +Bootstrap SpecFact local state and expose compatibility aliases for legacy module lifecycle flags. ```bash specfact init [OPTIONS] @@ -5055,10 +5061,12 @@ specfact init [OPTIONS] **Common options:** +**Migration note:** `specfact module` is the canonical lifecycle command group. Init lifecycle flags remain supported as compatibility aliases. + - `--repo PATH` - Repository path (default: current directory) -- `--list-modules` - Show discovered modules with effective enabled/disabled state and exit -- `--enable-module TEXT` - Enable module by id (repeatable) -- `--disable-module TEXT` - Disable module by id (repeatable) +- `--list-modules` - Compatibility alias for module lifecycle listing +- `--enable-module TEXT` - Compatibility alias for enabling module id (repeatable) +- `--disable-module TEXT` - Compatibility alias for disabling module id (repeatable) - `--force` - Override dependency guards; cascades dependency updates **Interactive behavior:** @@ -5104,6 +5112,49 @@ specfact init --disable-module plan --force 3. Enforces module dependency and compatibility constraints. 4. Reports IDE prompt status and points to `specfact init ide` for prompt/template setup. + +### `module` - Module Lifecycle and Marketplace Management + +Canonical module lifecycle commands for marketplace and locally discovered modules. + +```bash +specfact module [OPTIONS] COMMAND [ARGS]... +``` + +**Commands:** + +- `install ` - Install marketplace module (bare names normalize to `specfact/`) +- `list [--source builtin|marketplace|custom] [--show-origin]` - List modules with `Trust`/`Publisher` and optional `Origin` +- `show ` - Show detailed module metadata and full command tree (with subcommands and short descriptions) +- `search ` - Search marketplace registry and installed modules (`Scope` column) +- `enable ` - Enable module in lifecycle state registry +- `disable [--force]` - Disable module in lifecycle state registry +- `uninstall ` - Uninstall marketplace module with source-aware guidance for built-in/custom modules +- `upgrade [] [--all]` - Upgrade one module or all marketplace-installed modules + +**Examples:** + +```bash +# Install and inspect modules +specfact module install specfact/backlog +specfact module install backlog +specfact module list +specfact module list --show-origin +specfact module show module-registry + +# Search and manage +specfact module search backlog +specfact module enable backlog +specfact module disable backlog --force +specfact module uninstall specfact/backlog +specfact module upgrade +``` + +**Compatibility and migration:** + +- `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain migration aliases. +- Prefer `specfact module ...` for all lifecycle operations. + ### `init ide` - IDE Prompt/Template Setup Install and update prompt templates and IDE settings. diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index d175edce..f2a56263 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -28,10 +28,11 @@ All SpecFact artifacts are stored under `.specfact/` in the repository root. Thi - `commands.json` – Command names and help text used for fast root `specfact --help` without loading every command module. - `modules.json` – Per-module state (id, version, enabled) for optional module packages. - - Managed by `specfact init --list-modules`, `specfact init --enable-module ...`, `specfact init --disable-module ...` + - Managed primarily by `specfact module ...` commands (`list`, `install`, `uninstall`, `upgrade`) + - `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain compatibility aliases - Supports dependency-safe lifecycle operations with optional `--force` cascading behavior -`specfact init` is bootstrap/module-lifecycle focused. IDE prompt/template setup is handled by `specfact init ide`. +`specfact init` is bootstrap-focused; module lifecycle is canonical under `specfact module` with init aliases preserved for migration. IDE prompt/template setup is handled by `specfact init ide`. For how the CLI discovers and loads commands from module packages (registry, module-package.yaml, lazy loading), see [Architecture – Modules design](architecture.md#modules-design). @@ -452,10 +453,13 @@ Bootstraps local module lifecycle state (without IDE template copy side effects) # Bootstrap and discover modules specfact init -# List enabled/disabled module state -specfact init --list-modules +# Canonical lifecycle commands +specfact module list +specfact module install specfact/backlog +specfact module uninstall backlog -# Manage modules (interactive selector in interactive terminals) +# Compatibility aliases +specfact init --list-modules specfact init --enable-module specfact init --disable-module ``` diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml index 7dbf04df..e547ebfb 100644 --- a/modules/backlog-core/module-package.yaml +++ b/modules/backlog-core/module-package.yaml @@ -1,25 +1,28 @@ name: backlog-core -version: "0.1.0" +version: 0.1.0 commands: - backlog command_help: - backlog: "Backlog dependency analysis, delta workflows, and release readiness" + backlog: Backlog dependency analysis, delta workflows, and release readiness pip_dependencies: [] module_dependencies: [] -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' tier: community schema_extensions: project_bundle: backlog_core.backlog_graph: - type: "BacklogGraph | None" - description: "Dependency graph for backlog analysis" + type: BacklogGraph | None + description: Dependency graph for backlog analysis project_metadata: backlog_core.backlog_config: - type: "dict[str, Any] | None" - description: "Backlog provider and template configuration" + type: dict[str, Any] | None + description: Backlog provider and template configuration publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai integrity: checksum_algorithm: sha256 dependencies: [] +description: Provide advanced backlog analysis and readiness capabilities. +license: Apache-2.0 diff --git a/modules/bundle-mapper/module-package.yaml b/modules/bundle-mapper/module-package.yaml index e93b39f0..e0c8f2c0 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -1,22 +1,25 @@ name: bundle-mapper -version: "0.1.0" +version: 0.1.0 commands: [] pip_dependencies: [] module_dependencies: [] -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' tier: community schema_extensions: project_bundle: {} project_metadata: bundle_mapper.mapping_rules: - type: "list | None" - description: "Persistent mapping rules from user confirmations" + type: list | None + description: Persistent mapping rules from user confirmations bundle_mapper.history: - type: "dict | None" - description: "Auto-populated historical mappings (item_key -> bundle_id counts)" + type: dict | None + description: Auto-populated historical mappings (item_key -> bundle_id counts) publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai integrity: checksum_algorithm: sha256 dependencies: [] +description: Map backlog items to best-fit modules using scoring heuristics. +license: Apache-2.0 diff --git a/modules/bundle-mapper/src/app.py b/modules/bundle-mapper/src/app.py new file mode 100644 index 00000000..7a8ea674 --- /dev/null +++ b/modules/bundle-mapper/src/app.py @@ -0,0 +1,38 @@ +"""Module entrypoint for bundle-mapper protocol compliance.""" + +from __future__ import annotations + +from typing import Any + +import typer + +from specfact_cli.modules.module_io_shim import export_from_bundle, import_to_bundle, sync_with_bundle, validate_bundle + + +class _BundleMapperIO: + """Expose standard module lifecycle I/O operations.""" + + def import_to_bundle(self, bundle: Any, payload: dict[str, Any]) -> Any: + return import_to_bundle(bundle, payload) + + def export_from_bundle(self, bundle: Any) -> dict[str, Any]: + return export_from_bundle(bundle) + + def sync_with_bundle(self, bundle: Any, external_state: dict[str, Any]) -> Any: + return sync_with_bundle(bundle, external_state) + + def validate_bundle(self, bundle: Any) -> dict[str, Any]: + return validate_bundle(bundle) + + +runtime_interface = _BundleMapperIO() +app = typer.Typer(help="Bundle mapper module") + +__all__ = [ + "app", + "export_from_bundle", + "import_to_bundle", + "runtime_interface", + "sync_with_bundle", + "validate_bundle", +] diff --git a/openspec/changes/marketplace-01-central-module-registry/CHANGE_VALIDATION.md b/openspec/changes/marketplace-01-central-module-registry/CHANGE_VALIDATION.md index 4c45090f..5f24d43d 100644 --- a/openspec/changes/marketplace-01-central-module-registry/CHANGE_VALIDATION.md +++ b/openspec/changes/marketplace-01-central-module-registry/CHANGE_VALIDATION.md @@ -1,229 +1,101 @@ # Change Validation Report: marketplace-01-central-module-registry -**Validation Date**: 2026-02-09 20:30 UTC +**Validation Date**: 2026-02-20 23:25:03 **Change Proposal**: [proposal.md](./proposal.md) -**Validation Method**: Interface analysis + OpenSpec strict validation +**Validation Method**: Dry-run simulation in temporary workspace + dependency scan with `rg` ## Executive Summary -- **Breaking Changes**: 0 detected / 0 resolved ✅ -- **Dependent Files**: 1 modified (`module_packages.py` - additive extension) -- **Impact Level**: **LOW** - All changes are additive (new modules + extended discovery) -- **Validation Result**: **PASS** ✅ -- **User Decision**: N/A (no breaking changes, proceed with implementation) +- Breaking Changes: 1 potential breaking path detected (hard removal of init lifecycle flags) / 1 resolved by scope adjustment +- Dependent Files: 20+ directly referenced files affected by lifecycle flag ownership (`init` + docs + tests + canonical specs) +- Impact Level: Medium (reduced to Low for runtime compatibility with deprecation-alias strategy) +- Validation Result: Pass +- User Decision: Adjust change to avoid breaking behavior (harmonize via deprecation + delegation) ## Breaking Changes Detected -**None** - All changes are **additive only**: -- New `module` module with install/uninstall/search/list/upgrade commands -- New module_discovery.py with multi-location scanning -- New marketplace_client.py for registry access -- New module_installer.py for installation workflow -- Extended module_packages.py to use discover_all_modules() (backward compatible) +### Interface: init lifecycle options ownership -## Interface Changes (Non-Breaking) +- **Type**: CLI interface removal risk +- **Old Interface**: + - `specfact init --list-modules` + - `specfact init --enable-module ` + - `specfact init --disable-module ` +- **Proposed hard-removal path (breaking)**: remove these options entirely from `init` +- **Breaking**: Yes (would break existing user automation, tests, and canonical OpenSpec specs) +- **Resolution**: Do not remove in this change; retain compatibility aliases and move canonical UX guidance to `specfact module` -### New Module: `module` (src/specfact_cli/modules/module/) - -**NEW CLI commands**: -```python -@app.command() -def install(module_id: str, version: str | None, allow_unsigned: bool) -> None: ... - -@app.command() -def uninstall(module_name: str) -> None: ... - -@app.command() -def search(query: str) -> None: ... - -@app.command() -def list(source: str) -> None: ... - -@app.command() -def upgrade(module_name: str) -> None: ... -``` -- No existing code depends on this (new module) - -### New Module: module_discovery.py +## Dependencies Affected -**NEW function**: -```python -def discover_all_modules() -> list[tuple[Path, ModulePackageMetadata]]: ... -``` -- Scans built-in, marketplace, custom paths -- Returns modules with source tracking -- No existing dependencies +### Critical Updates Required -### New Module: marketplace_client.py +- `src/specfact_cli/modules/init/src/commands.py`: currently owns lifecycle flag behavior and state updates +- `src/specfact_cli/cli.py`: contains interactive sentinel normalization for bare lifecycle flags +- `openspec/specs/init-module-state/spec.md`: canonical spec explicitly defines init lifecycle flags +- `openspec/specs/module-lifecycle-management/spec.md`: canonical behavior includes init-based lifecycle operations +- `openspec/specs/init-module-discovery-alignment/spec.md`: canonical behavior references init lifecycle flags -**NEW functions**: -```python -def fetch_registry_index() -> dict | None: ... -def download_module(module_id: str, version: str) -> Path: ... -``` -- Fetches registry from GitHub -- Downloads and verifies module tarballs -- No existing dependencies +### Recommended Updates -### New Module: module_installer.py +- `README.md`, `docs/README.md`, `docs/reference/commands.md`, `docs/reference/architecture.md`, `docs/reference/directory-structure.md`: harmonize wording so `specfact module` is canonical while `init` flags are documented as compatibility aliases +- `tests/unit/specfact_cli/registry/test_init_module_state.py` +- `tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py` -**NEW functions**: -```python -def install_module(module_id: str, version: str | None) -> None: ... -def uninstall_module(module_name: str) -> None: ... -``` -- Install/uninstall workflow -- Checksum verification -- No existing dependencies +### Optional Updates -### Extended Module: module_packages.py +- Archived OpenSpec historical docs that mention `init` flags can remain unchanged (historical records) -**EXTENDED**: Uses discover_all_modules() instead of single-path discovery -- Backward compatible (built-in modules still discovered first) -- No signature changes to existing functions -- Additive extension only +## Impact Assessment -## Dependencies Affected +- **Code Impact**: Medium if hard removal; low if deprecate + delegate strategy +- **Test Impact**: Medium; existing init lifecycle tests must stay green and may require deprecation assertion updates +- **Documentation Impact**: Medium; command ownership language needs harmonization +- **Release Impact**: Minor (non-breaking) under deprecation-alias strategy -### Files Modified -- **module_packages.py**: Extended to use multi-location discovery (backward compatible) +## User Decision -### New External Dependency -- **nold-ai/specfact-cli-modules repository**: New GitHub repository for registry - - Not a blocker for CLI changes (registry can be created in parallel) - - CLI gracefully handles missing registry (offline mode) +**Decision**: Adjust change for backward compatibility -## Impact Assessment +**Rationale**: +- Keep existing workflows and scripts stable +- Avoid conflict with existing canonical OpenSpec specs +- Preserve migration path to canonical `specfact module` lifecycle UX without forcing immediate breaking changes -### Code Impact -- **Scope**: New module + registry infrastructure -- **Type**: Additive (new modules, no existing code modified except extension) -- **Backward Compatibility**: ✅ Full (built-in modules remain functional) -- **Migration Required**: ❌ None - -### Test Impact -- **Existing Tests**: ✅ Should pass without modification -- **New Tests Required**: ✅ Covered in tasks.md (TDD-first) -- **Coverage**: Expect >80% for new functionality - -### Documentation Impact -- **New Guides**: - - `docs/guides/installing-modules.md` ✅ - - `docs/guides/module-marketplace.md` ✅ -- **Updated Reference**: `docs/reference/architecture.md` ✅ -- **Navigation**: `docs/_layouts/default.html` ✅ - -### Release Impact -- **Version Bump**: **Minor** (new feature, backward compatible) -- **Semver**: Appropriate -- **Changelog**: Update required ✅ +**Next Steps**: +1. Implement deprecation-compatible alias behavior in `init` lifecycle options (no hard removal) +2. Keep `specfact module` command group as canonical lifecycle command surface +3. Add/adjust tests to lock compatibility + lazy-loader entrypoint reliability +4. Update docs to steer users toward `specfact module` ## Format Validation -### proposal.md Format: ✅ PASS -- ✅ Title, Why, What Changes, Capabilities, Impact sections -- ✅ Capabilities: 3 new, 2 modified (correctly identified) -- ✅ External dependency documented (nold-ai/specfact-cli-modules repo) -- ✅ Backward compatibility stated - -### tasks.md Format: ✅ PASS -- ✅ TDD/SDD order enforced -- ✅ Git workflow: Branch first (Task 1), PR last (Task 10) -- ✅ Task structure: `## N.` with `- [ ] N.M` format -- ✅ Quality gates: Task 7 -- ✅ Documentation: Task 8 -- ✅ Version/changelog: Task 9 -- ✅ External repo creation: Task 2 - -### specs Format: ✅ PASS -- ✅ 3 new specs: module-marketplace-registry, module-installation, multi-location-discovery -- ✅ 2 delta specs: module-packages, module-lifecycle-management -- ✅ WHEN/THEN format (38+ scenarios total) -- ✅ Offline-first scenarios included - -### design.md Format: ✅ PASS -- ✅ Context, Goals/Non-Goals, Decisions, Risks/Trade-offs sections -- ✅ 6 key decisions with rationale -- ✅ Offline behavior documented -- ✅ Migration plan included - -### Config.yaml Compliance: ✅ PASS -- ✅ TDD-first enforced -- ✅ Offline-first philosophy maintained -- ✅ Contract requirements (@icontract/@beartype) -- ✅ Documentation requirements -- ✅ Git workflow +- **proposal.md Format**: Pass + - Title format: Correct (`# Change: ...`) + - Required sections: Present (`Why`, `What Changes`, `Capabilities`, `Impact`) + - "What Changes" markers: Uses NEW/MODIFY bullets + - Source Tracking section: Present +- **tasks.md Format**: Pass + - Hierarchical numbered sections and checklist formatting: Correct + - Worktree branch creation first / PR creation last: Present + - Quality gate tasks: Present + - Added harmonization tasks in TDD order: Present +- **specs Format**: Pass + - Given/When/Then scenarios present + - Delta updates added for lifecycle harmonization compatibility requirement +- **design.md Format**: Pass + - Added explicit lifecycle harmonization decision and constraints +- **Config.yaml Compliance**: Pass for updated artifacts ## OpenSpec Validation -- **Status**: ✅ PASS -- **Command**: `openspec validate marketplace-01-central-module-registry --strict` -- **Output**: "Change 'marketplace-01-central-module-registry' is valid" -- **Issues Found/Fixed**: 0 - -## Dependencies and Prerequisites - -### Required Changes -- ✅ **arch-06** (Enhanced Manifest Security): Provides checksum verification (in progress, not blocking) -- â„šī¸ **nold-ai/specfact-cli-modules repository**: Must be created (Task 2 in implementation plan) - -### Recommendation -This change can proceed. The external repository creation is part of the implementation tasks. CLI will gracefully handle missing registry (offline mode). - -## Risk Assessment - -### Technical Risks -1. **Registry unavailable** - Mitigated by offline-first design, built-in modules remain functional -2. **Network failures** - Mitigated by graceful degradation, clear error messages -3. **Module conflicts** - Mitigated by priority order (built-in first), namespace enforcement -4. **Incomplete install** - Mitigated by atomic operations, rollback on failure - -### Process Risks -1. **External repo coordination** - Mitigated by including repo creation in tasks -2. **Documentation lag** - Mitigated by documentation task before PR +- **Status**: Pass +- **Validation Command**: `openspec validate marketplace-01-central-module-registry --strict` +- **Issues Found**: 0 +- **Issues Fixed**: 0 +- **Re-validated**: Yes (after proposal/tasks/design/spec updates) ## Validation Artifacts -- **Change Directory**: `openspec/changes/marketplace-01-central-module-registry/` -- **Artifacts Validated**: - - ✅ proposal.md - - ✅ design.md - - ✅ specs/module-marketplace-registry/spec.md - - ✅ specs/module-installation/spec.md - - ✅ specs/multi-location-discovery/spec.md - - ✅ specs/module-packages/spec.md - - ✅ specs/module-lifecycle-management/spec.md - - ✅ tasks.md -- **Validation Method**: Interface analysis + OpenSpec strict validation -- **Temporary Workspace**: Not required (no breaking changes) - -## Conclusion - -**Change marketplace-01-central-module-registry is SAFE TO IMPLEMENT** ✅ - -### Key Findings -1. ✅ Zero breaking changes - all new modules -2. ✅ Full backward compatibility with existing modules -3. ✅ Comprehensive task plan with external repo creation -4. ✅ Offline-first design maintained -5. ✅ Well-designed with documented trade-offs -6. ✅ All format requirements met -7. ✅ OpenSpec validation passed - -### Recommendation -**PROCEED WITH IMPLEMENTATION** following the task plan in tasks.md. - -Start with: Task 1 (git branch) → Task 2 (create nold-ai/specfact-cli-modules repo) → Task 3+ (implementation) - -### Next Steps -1. Create feature branch (Task 1) -2. Create nold-ai/specfact-cli-modules repository (Task 2) -3. Begin TDD cycle: write tests for multi-location discovery (Task 3.1) -4. Follow tasks.md sequentially through completion -5. Run quality gates before PR (Task 7) -6. Create PR to dev (Task 10) - ---- - -**Validated by**: OpenSpec Validation Workflow -**Sign-off**: Ready for implementation +- Temporary workspace: `/tmp/specfact-validation-marketplace-01-central-module-registry-20260220232347` +- Interface scaffold: `/tmp/specfact-validation-marketplace-01-central-module-registry-20260220232347/interface_scaffold.md` +- Dependency map: `/tmp/specfact-validation-marketplace-01-central-module-registry-20260220232347/dependency_graph.json` diff --git a/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md b/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md new file mode 100644 index 00000000..e25c04c0 --- /dev/null +++ b/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md @@ -0,0 +1,37 @@ +# TDD Evidence: marketplace-01-central-module-registry + +## Pre-implementation failing runs + +- 2026-02-20 23:06:47 +0100 + - Command: `hatch test --cover -v tests/unit/registry/test_module_discovery.py` + - Result: failed during collection with `ModuleNotFoundError: specfact_cli.registry.module_discovery`. + +- 2026-02-20 23:08:34 +0100 + - Command: `hatch test -- tests/unit/registry/test_marketplace_client.py -v` + - Result: failed during collection with `ModuleNotFoundError: specfact_cli.registry.marketplace_client`. + +- 2026-02-20 23:10:22 +0100 + - Command: `hatch test -- tests/unit/registry/test_module_installer.py -v` + - Result: failed during collection with `ImportError: cannot import name 'install_module'`. + +- 2026-02-20 23:11:42 +0100 + - Command: `hatch test -- tests/unit/modules/module/test_commands.py -v` + - Result: failed during collection with `ModuleNotFoundError: specfact_cli.modules.module`. + +## Post-implementation passing runs + +- 2026-02-20 23:08:58 +0100 + - Command: `hatch test -- tests/unit/registry/test_marketplace_client.py -v` + - Result: all tests passed. + +- 2026-02-20 23:11:09 +0100 + - Command: `hatch test -- tests/unit/registry/test_module_installer.py -v` + - Result: all tests passed. + +- 2026-02-20 23:12:05 +0100 + - Command: `hatch test -- tests/unit/modules/module/test_commands.py -v` + - Result: all tests passed. + +- 2026-02-20 23:12:38 +0100 + - Command: `hatch test -- tests/unit/registry/test_module_discovery.py tests/unit/registry/test_marketplace_client.py tests/unit/registry/test_module_installer.py tests/unit/modules/module/test_commands.py -v` + - Result: 20 tests passed. diff --git a/openspec/changes/marketplace-01-central-module-registry/design.md b/openspec/changes/marketplace-01-central-module-registry/design.md index a2acfbaa..caea183c 100644 --- a/openspec/changes/marketplace-01-central-module-registry/design.md +++ b/openspec/changes/marketplace-01-central-module-registry/design.md @@ -261,3 +261,20 @@ If critical issues arise: **Q4: How to handle module dependencies (module A requires module B)?** - **Recommendation**: Deferred to marketplace-02 (dependency resolution) - **MVP**: module_dependencies field exists but not enforced during install + + +### Decision 7: Lifecycle UX Harmonization (init vs module command) + +**Context:** `specfact init` already owns module enable/disable/list lifecycle flags from prior architecture changes. This marketplace change introduces a new canonical module management command group (`specfact module ...`), creating potential UX duplication. + +**Choice:** Keep `init` lifecycle flags as backward-compatible aliases while standardizing user guidance and documentation on `specfact module` as canonical lifecycle surface. + +**Rationale:** +- Avoids breaking existing automation and user workflows built on `specfact init --enable-module/--disable-module/--list-modules`. +- Preserves behavior required by existing canonical specs and tests while still reducing UX ambiguity. +- Enables phased deprecation instead of disruptive removal. + +**Implementation constraints:** +- No hard removal of init lifecycle flags in this change. +- Alias behavior must remain functionally equivalent for state management operations. +- Help text and docs should steer new users to `specfact module` lifecycle commands. diff --git a/openspec/changes/marketplace-01-central-module-registry/proposal.md b/openspec/changes/marketplace-01-central-module-registry/proposal.md index c56f0c25..8f66f7f9 100644 --- a/openspec/changes/marketplace-01-central-module-registry/proposal.md +++ b/openspec/changes/marketplace-01-central-module-registry/proposal.md @@ -7,13 +7,14 @@ The modular architecture (arch-01 through arch-07) provides strong encapsulation ## What Changes - **NEW**: Create `nold-ai/specfact-cli-modules` GitHub repository with registry index and module publishing infrastructure -- **NEW**: Create `module` module with CLI commands for install/uninstall/search/list/upgrade operations +- **NEW**: Create `module-registry` module package exposing `specfact module` CLI commands for install/uninstall/search/list/upgrade operations - **NEW**: Implement multi-location module discovery (built-in, marketplace, custom paths) - **NEW**: Create marketplace client for fetching registry index and downloading module tarballs - **NEW**: Create module installer with checksum verification and marketplace path management - **MODIFY**: Extend module discovery to scan multiple locations (~/.specfact/marketplace-modules, ~/.specfact/custom-modules) - **NEW**: Add registry index.json schema with module metadata (id, namespace, version, download URLs, checksums) - **NEW**: Add documentation for installing modules and marketplace usage +- **MODIFY**: Harmonize module lifecycle UX by keeping `specfact init --enable-module/--disable-module/--list-modules` as deprecated compatibility aliases while centralizing lifecycle management under `specfact module` ## Capabilities @@ -31,7 +32,9 @@ The modular architecture (arch-01 through arch-07) provides strong encapsulation ## Impact - **Affected code**: - - `src/specfact_cli/modules/module/` (new module with install/uninstall/search/list/upgrade commands) + - `src/specfact_cli/modules/module_registry/` (new module package backing `specfact module` commands) + - `src/specfact_cli/modules/init/src/commands.py` (deprecation-compatible delegation for lifecycle flags) + - `src/specfact_cli/cli.py` (compatibility normalization behavior retained for bare interactive lifecycle flags) - `src/specfact_cli/registry/module_discovery.py` (new: multi-location discovery) - `src/specfact_cli/registry/marketplace_client.py` (new: registry client) - `src/specfact_cli/registry/module_installer.py` (new: installation logic) @@ -46,7 +49,7 @@ The modular architecture (arch-01 through arch-07) provides strong encapsulation - New repository: `nold-ai/specfact-cli-modules` (registry infrastructure) - Depends on arch-06 (Enhanced Manifest Security) for checksum verification - **Integration points**: Module discovery, installation workflow, registry client, module verification -- **Backward compatibility**: Fully backward compatible (built-in modules remain functional, marketplace is additive) +- **Backward compatibility**: Backward compatible via deprecation alias strategy (existing `init` lifecycle flags remain supported while `specfact module` is canonical) - **Rollback plan**: Disable marketplace client, revert to built-in-only discovery --- diff --git a/openspec/changes/marketplace-01-central-module-registry/specs/module-lifecycle-management/spec.md b/openspec/changes/marketplace-01-central-module-registry/specs/module-lifecycle-management/spec.md index cda03aef..bb9fd8f7 100644 --- a/openspec/changes/marketplace-01-central-module-registry/specs/module-lifecycle-management/spec.md +++ b/openspec/changes/marketplace-01-central-module-registry/specs/module-lifecycle-management/spec.md @@ -20,3 +20,18 @@ The system SHALL extend registration to handle modules from built-in, marketplac - **WHEN** marketplace module is registered - **THEN** system SHALL validate id uses "namespace/name" format - **AND** SHALL log warning if flat name used + + +### Requirement: Lifecycle command harmonization remains backward compatible + +The system SHALL keep existing init-based lifecycle flags functional while introducing `specfact module` as the canonical lifecycle command surface. + +#### Scenario: init lifecycle flags remain functional +- **WHEN** user runs `specfact init --list-modules` or `--enable-module/--disable-module` +- **THEN** system SHALL preserve current lifecycle behavior and state updates +- **AND** SHALL provide deprecation guidance toward `specfact module` commands + +#### Scenario: module command is canonical lifecycle surface +- **WHEN** user runs `specfact module list` or lifecycle operations +- **THEN** system SHALL provide equivalent lifecycle management capabilities +- **AND** documentation SHALL reference `specfact module` as primary UX diff --git a/openspec/changes/marketplace-01-central-module-registry/tasks.md b/openspec/changes/marketplace-01-central-module-registry/tasks.md index 4ddf8a75..7984a593 100644 --- a/openspec/changes/marketplace-01-central-module-registry/tasks.md +++ b/openspec/changes/marketplace-01-central-module-registry/tasks.md @@ -13,223 +13,238 @@ Do not implement production code until tests exist and have been run (expecting ## 1. Create git worktree branch from dev -- [ ] 1.1 Ensure on dev and up to date; create branch `feature/marketplace-01-central-module-registry`; verify - - [ ] 1.1.1 `git checkout dev && git pull origin dev` - - [ ] 1.1.2 `scripts/worktree.sh create feature/marketplace-01-central-module-registry` - - [ ] 1.1.3 `git branch --show-current` +- [x] 1.1 Ensure on dev and up to date; create branch `feature/marketplace-01-central-module-registry`; verify + - [x] 1.1.1 `git checkout dev && git pull origin dev` + - [x] 1.1.2 `scripts/worktree.sh create feature/marketplace-01-central-module-registry` + - [x] 1.1.3 `git branch --show-current` ## 2. Create nold-ai/specfact-cli-modules repository -- [ ] 2.1 Create GitHub repository - - [ ] 2.1.1 Create repository via GitHub web UI or gh CLI - - [ ] 2.1.2 Set description: "Central module registry for SpecFact CLI marketplace" - - [ ] 2.1.3 Add MIT license - - [ ] 2.1.4 Create initial README.md +- [x] 2.1 Create GitHub repository + - [x] 2.1.1 Create repository via GitHub web UI or gh CLI + - [x] 2.1.2 Set description: "Central module registry for SpecFact CLI marketplace" + - [x] 2.1.3 Add MIT license + - [x] 2.1.4 Create initial README.md -- [ ] 2.2 Set up registry structure - - [ ] 2.2.1 Create registry/index.json with schema_version and empty modules array - - [ ] 2.2.2 Create registry/modules/ directory - - [ ] 2.2.3 Create registry/signatures/ directory - - [ ] 2.2.4 Create docs/ directory with module-publishing-guide.md template - - [ ] 2.2.5 Commit and push: "Initialize module registry structure" +- [x] 2.2 Set up registry structure + - [x] 2.2.1 Create registry/index.json with schema_version and empty modules array + - [x] 2.2.2 Create registry/modules/ directory + - [x] 2.2.3 Create registry/signatures/ directory + - [x] 2.2.4 Create docs/ directory with module-publishing-guide.md template + - [x] 2.2.5 Commit and push: "Initialize module registry structure" ## 3. Implement multi-location discovery (TDD) -- [ ] 3.1 Write tests for multi-location discovery (expect failure) - - [ ] 3.1.1 Create tests/unit/registry/test_module_discovery.py - - [ ] 3.1.2 Test discover_all_modules() scans built-in path - - [ ] 3.1.3 Test discover_all_modules() scans marketplace path if exists - - [ ] 3.1.4 Test discover_all_modules() scans custom path if exists - - [ ] 3.1.5 Test built-in modules take priority over marketplace - - [ ] 3.1.6 Test graceful handling of missing marketplace/custom paths - - [ ] 3.1.7 Run tests (expect failures) - -- [ ] 3.2 Create module_discovery.py - - [ ] 3.2.1 Create src/specfact_cli/registry/module_discovery.py - - [ ] 3.2.2 Implement discover_all_modules() with multi-path scanning - - [ ] 3.2.3 Add contracts: @require paths are valid, @ensure returns list - - [ ] 3.2.4 Add @beartype decorators - - [ ] 3.2.5 Implement priority order (built-in → marketplace → custom) - - [ ] 3.2.6 Add source tracking to metadata - - [ ] 3.2.7 Verify tests pass - -- [ ] 3.3 Update module_packages.py to use multi-location discovery - - [ ] 3.3.1 Modify discover_package_metadata() to accept source parameter - - [ ] 3.3.2 Update registration to use discover_all_modules() - - [ ] 3.3.3 Store source in module metadata - - [ ] 3.3.4 Verify existing functionality preserved +- [x] 3.1 Write tests for multi-location discovery (expect failure) + - [x] 3.1.1 Create tests/unit/registry/test_module_discovery.py + - [x] 3.1.2 Test discover_all_modules() scans built-in path + - [x] 3.1.3 Test discover_all_modules() scans marketplace path if exists + - [x] 3.1.4 Test discover_all_modules() scans custom path if exists + - [x] 3.1.5 Test built-in modules take priority over marketplace + - [x] 3.1.6 Test graceful handling of missing marketplace/custom paths + - [x] 3.1.7 Run tests (expect failures) + +- [x] 3.2 Create module_discovery.py + - [x] 3.2.1 Create src/specfact_cli/registry/module_discovery.py + - [x] 3.2.2 Implement discover_all_modules() with multi-path scanning + - [x] 3.2.3 Add contracts: @require paths are valid, @ensure returns list + - [x] 3.2.4 Add @beartype decorators + - [x] 3.2.5 Implement priority order (built-in → marketplace → custom) + - [x] 3.2.6 Add source tracking to metadata + - [x] 3.2.7 Verify tests pass + +- [x] 3.3 Update module_packages.py to use multi-location discovery + - [x] 3.3.1 Modify discover_package_metadata() to accept source parameter + - [x] 3.3.2 Update registration to use discover_all_modules() + - [x] 3.3.3 Store source in module metadata + - [x] 3.3.4 Verify existing functionality preserved ## 4. Implement marketplace client (TDD) -- [ ] 4.1 Write tests for marketplace client (expect failure) - - [ ] 4.1.1 Create tests/unit/registry/test_marketplace_client.py - - [ ] 4.1.2 Test fetch_registry_index() fetches and parses index.json - - [ ] 4.1.3 Test graceful handling of network unavailable - - [ ] 4.1.4 Test invalid JSON raises ValueError - - [ ] 4.1.5 Test download_module() downloads tarball - - [ ] 4.1.6 Test checksum verification - - [ ] 4.1.7 Test checksum mismatch raises SecurityError - - [ ] 4.1.8 Mock HTTP requests with responses library - - [ ] 4.1.9 Run tests (expect failures) - -- [ ] 4.2 Create marketplace_client.py - - [ ] 4.2.1 Create src/specfact_cli/registry/marketplace_client.py - - [ ] 4.2.2 Implement fetch_registry_index() with requests library - - [ ] 4.2.3 Add contracts: @ensure returns dict or None - - [ ] 4.2.4 Implement download_module() with checksum verification - - [ ] 4.2.5 Add contracts: @require module_id format, @ensure returns Path - - [ ] 4.2.6 Add @beartype decorators - - [ ] 4.2.7 Implement offline fallback (log warning, return None) - - [ ] 4.2.8 Verify tests pass +- [x] 4.1 Write tests for marketplace client (expect failure) + - [x] 4.1.1 Create tests/unit/registry/test_marketplace_client.py + - [x] 4.1.2 Test fetch_registry_index() fetches and parses index.json + - [x] 4.1.3 Test graceful handling of network unavailable + - [x] 4.1.4 Test invalid JSON raises ValueError + - [x] 4.1.5 Test download_module() downloads tarball + - [x] 4.1.6 Test checksum verification + - [x] 4.1.7 Test checksum mismatch raises SecurityError + - [x] 4.1.8 Mock HTTP requests with responses library + - [x] 4.1.9 Run tests (expect failures) + +- [x] 4.2 Create marketplace_client.py + - [x] 4.2.1 Create src/specfact_cli/registry/marketplace_client.py + - [x] 4.2.2 Implement fetch_registry_index() with requests library + - [x] 4.2.3 Add contracts: @ensure returns dict or None + - [x] 4.2.4 Implement download_module() with checksum verification + - [x] 4.2.5 Add contracts: @require module_id format, @ensure returns Path + - [x] 4.2.6 Add @beartype decorators + - [x] 4.2.7 Implement offline fallback (log warning, return None) + - [x] 4.2.8 Verify tests pass ## 5. Implement module installer (TDD) -- [ ] 5.1 Write tests for module installer (expect failure) - - [ ] 5.1.1 Create tests/unit/registry/test_module_installer.py - - [ ] 5.1.2 Test install_module() downloads, verifies, extracts, registers - - [ ] 5.1.3 Test install to ~/.specfact/marketplace-modules/ - - [ ] 5.1.4 Test module already installed scenario - - [ ] 5.1.5 Test uninstall_module() removes marketplace module - - [ ] 5.1.6 Test uninstall built-in module raises error - - [ ] 5.1.7 Test core compatibility validation - - [ ] 5.1.8 Run tests (expect failures) - -- [ ] 5.2 Create module_installer.py - - [ ] 5.2.1 Create src/specfact_cli/registry/module_installer.py - - [ ] 5.2.2 Implement install_module() workflow (download → verify → extract → register) - - [ ] 5.2.3 Add contracts: @require valid module_id, @ensure module registered - - [ ] 5.2.4 Implement uninstall_module() with source check - - [ ] 5.2.5 Add contracts: @require module exists, @ensure removed if marketplace - - [ ] 5.2.6 Add @beartype decorators - - [ ] 5.2.7 Implement atomic install (rollback on failure) - - [ ] 5.2.8 Verify tests pass +- [x] 5.1 Write tests for module installer (expect failure) + - [x] 5.1.1 Create tests/unit/registry/test_module_installer.py + - [x] 5.1.2 Test install_module() downloads, verifies, extracts, registers + - [x] 5.1.3 Test install to ~/.specfact/marketplace-modules/ + - [x] 5.1.4 Test module already installed scenario + - [x] 5.1.5 Test uninstall_module() removes marketplace module + - [x] 5.1.6 Test uninstall built-in module raises error + - [x] 5.1.7 Test core compatibility validation + - [x] 5.1.8 Run tests (expect failures) + +- [x] 5.2 Create module_installer.py + - [x] 5.2.1 Create src/specfact_cli/registry/module_installer.py + - [x] 5.2.2 Implement install_module() workflow (download → verify → extract → register) + - [x] 5.2.3 Add contracts: @require valid module_id, @ensure module registered + - [x] 5.2.4 Implement uninstall_module() with source check + - [x] 5.2.5 Add contracts: @require module exists, @ensure removed if marketplace + - [x] 5.2.6 Add @beartype decorators + - [x] 5.2.7 Implement atomic install (rollback on failure) + - [x] 5.2.8 Verify tests pass ## 6. Create module management CLI commands (TDD) -- [ ] 6.1 Write tests for module commands (expect failure) - - [ ] 6.1.1 Create tests/unit/modules/module/test_commands.py - - [ ] 6.1.2 Test install command integration - - [ ] 6.1.3 Test uninstall command with source validation - - [ ] 6.1.4 Test search command filters registry - - [ ] 6.1.5 Test list command shows all sources - - [ ] 6.1.6 Test list --source filter - - [ ] 6.1.7 Test upgrade command - - [ ] 6.1.8 Mock CliRunner for Typer commands - - [ ] 6.1.9 Run tests (expect failures) - -- [ ] 6.2 Create module module structure - - [ ] 6.2.1 Create src/specfact_cli/modules/module/ directory - - [ ] 6.2.2 Create module-package.yaml with name, version, commands - - [ ] 6.2.3 Create src/ directory - - [ ] 6.2.4 Create __init__.py - -- [ ] 6.3 Implement module commands - - [ ] 6.3.1 Create src/commands.py with Typer app - - [ ] 6.3.2 Implement install command with module_id argument, version option - - [ ] 6.3.3 Implement uninstall command with source check - - [ ] 6.3.4 Implement search command with query argument - - [ ] 6.3.5 Implement list command with --source option - - [ ] 6.3.6 Implement upgrade command - - [ ] 6.3.7 Add @beartype to all commands - - [ ] 6.3.8 Use Rich Console for output formatting - - [ ] 6.3.9 Verify tests pass +- [x] 6.1 Write tests for module commands (expect failure) + - [x] 6.1.1 Create tests/unit/modules/module/test_commands.py + - [x] 6.1.2 Test install command integration + - [x] 6.1.3 Test uninstall command with source validation + - [x] 6.1.4 Test search command filters registry + - [x] 6.1.5 Test list command shows all sources + - [x] 6.1.6 Test list --source filter + - [x] 6.1.7 Test upgrade command + - [x] 6.1.8 Mock CliRunner for Typer commands + - [x] 6.1.9 Run tests (expect failures) + +- [x] 6.2 Create module module structure + - [x] 6.2.1 Create src/specfact_cli/modules/module/ directory + - [x] 6.2.2 Create module-package.yaml with name, version, commands + - [x] 6.2.3 Create src/ directory + - [x] 6.2.4 Create __init__.py + +- [x] 6.3 Implement module commands + - [x] 6.3.1 Create src/commands.py with Typer app + - [x] 6.3.2 Implement install command with module_id argument, version option + - [x] 6.3.3 Implement uninstall command with source check + - [x] 6.3.4 Implement search command with query argument + - [x] 6.3.5 Implement list command with --source option + - [x] 6.3.6 Implement upgrade command + - [x] 6.3.7 Add @beartype to all commands + - [x] 6.3.8 Use Rich Console for output formatting + - [x] 6.3.9 Verify tests pass + +## 6.4 Harmonize init/module lifecycle UX (TDD) + +- [x] 6.4.1 Write tests first for compatibility + canonical UX + - [x] 6.4.1.1 Add test: `specfact module --help` loads without relative-import errors in lazy loader path + - [x] 6.4.1.2 Add test: `specfact init --list-modules` remains functional but emits deprecation guidance toward `specfact module list` + - [x] 6.4.1.3 Add test: `specfact init --enable-module/--disable-module` remain functional aliases with deprecation guidance + - [x] 6.4.1.4 Run tests and capture failing evidence in TDD_EVIDENCE.md + +- [x] 6.4.2 Implement non-breaking harmonization + - [x] 6.4.2.1 Use `module-registry` as module identity; keep command name `module` + - [x] 6.4.2.2 Ensure module entrypoint imports are robust under lazy file-based loading + - [x] 6.4.2.3 Keep init lifecycle flags as deprecated aliases; avoid behavior regressions + - [x] 6.4.2.4 Add user-facing deprecation messaging and docs updates + - [x] 6.4.2.5 Verify tests pass and update TDD_EVIDENCE.md with passing evidence ## 7. Quality gates -- [ ] 7.1 Format code - - [ ] 7.1.1 `hatch run format` +- [x] 7.1 Format code + - [x] 7.1.1 `hatch run format` -- [ ] 7.2 Type checking - - [ ] 7.2.1 `hatch run type-check` - - [ ] 7.2.2 Fix any type errors +- [x] 7.2 Type checking + - [x] 7.2.1 `hatch run type-check` + - [x] 7.2.2 Fix any type errors -- [ ] 7.3 Contract-first testing - - [ ] 7.3.1 `hatch run contract-test` - - [ ] 7.3.2 Verify all contracts pass +- [x] 7.3 Contract-first testing + - [x] 7.3.1 `hatch run contract-test` + - [x] 7.3.2 Verify all contracts pass -- [ ] 7.4 Full test suite - - [ ] 7.4.1 `hatch test --cover -v` - - [ ] 7.4.2 Verify >80% coverage for new code - - [ ] 7.4.3 Fix any failing tests +- [x] 7.4 Full test suite + - [x] 7.4.1 `hatch test --cover -v` + - [x] 7.4.2 Verify >80% coverage for new code + - [x] 7.4.3 Fix any failing tests -- [ ] 7.5 OpenSpec validation - - [ ] 7.5.1 `openspec validate marketplace-01-central-module-registry --strict` - - [ ] 7.5.2 Fix any validation errors +- [x] 7.5 OpenSpec validation + - [x] 7.5.1 `openspec validate marketplace-01-central-module-registry --strict` + - [x] 7.5.2 Fix any validation errors ## 8. Documentation research and review -- [ ] 8.1 Identify affected documentation - - [ ] 8.1.1 Review docs/guides/ for module management docs - - [ ] 8.1.2 Review docs/reference/ for architecture docs - - [ ] 8.1.3 Review README.md for marketplace feature mention - -- [ ] 8.2 Create new guide: docs/guides/installing-modules.md - - [ ] 8.2.1 Add Jekyll front-matter (layout, title, permalink, description) - - [ ] 8.2.2 Write sections: Installing from Marketplace, Listing Modules, Uninstalling, Upgrading - - [ ] 8.2.3 Include command examples with output - - [ ] 8.2.4 Document offline behavior - -- [ ] 8.3 Create new guide: docs/guides/module-marketplace.md - - [ ] 8.3.1 Add Jekyll front-matter - - [ ] 8.3.2 Write sections: Marketplace Overview, Official Modules, Security Model, Custom Modules - - [ ] 8.3.3 Link to registry repository - - [ ] 8.3.4 Explain namespace system (specfact/*) - -- [ ] 8.4 Update docs/reference/architecture.md - - [ ] 8.4.1 Add "Module Marketplace" section - - [ ] 8.4.2 Document multi-location discovery pattern - - [ ] 8.4.3 Document registry client architecture - - [ ] 8.4.4 Include sequence diagram for install workflow - -- [ ] 8.5 Update sidebar navigation - - [ ] 8.5.1 Update docs/_layouts/default.html - - [ ] 8.5.2 Add "Installing Modules" under Guides - - [ ] 8.5.3 Add "Module Marketplace" under Guides - -- [ ] 8.6 Verify docs build - - [ ] 8.6.1 Test markdown formatting - - [ ] 8.6.2 Check all links work +- [x] 8.1 Identify affected documentation + - [x] 8.1.1 Review docs/guides/ for module management docs + - [x] 8.1.2 Review docs/reference/ for architecture docs + - [x] 8.1.3 Review README.md for marketplace feature mention + +- [x] 8.2 Create new guide: docs/guides/installing-modules.md + - [x] 8.2.1 Add Jekyll front-matter (layout, title, permalink, description) + - [x] 8.2.2 Write sections: Installing from Marketplace, Listing Modules, Uninstalling, Upgrading + - [x] 8.2.3 Include command examples with output + - [x] 8.2.4 Document offline behavior + +- [x] 8.3 Create new guide: docs/guides/module-marketplace.md + - [x] 8.3.1 Add Jekyll front-matter + - [x] 8.3.2 Write sections: Marketplace Overview, Official Modules, Security Model, Custom Modules + - [x] 8.3.3 Link to registry repository + - [x] 8.3.4 Explain namespace system (specfact/*) + +- [x] 8.4 Update docs/reference/architecture.md + - [x] 8.4.1 Add "Module Marketplace" section + - [x] 8.4.2 Document multi-location discovery pattern + - [x] 8.4.3 Document registry client architecture + - [x] 8.4.4 Include sequence diagram for install workflow + +- [x] 8.5 Update sidebar navigation + - [x] 8.5.1 Update docs/_layouts/default.html + - [x] 8.5.2 Add "Installing Modules" under Guides + - [x] 8.5.3 Add "Module Marketplace" under Guides + +- [x] 8.6 Verify docs build + - [x] 8.6.1 Test markdown formatting + - [x] 8.6.2 Check all links work ## 9. Version and changelog -- [ ] 9.1 Bump version - - [ ] 9.1.1 Determine version bump: minor (new feature) - - [ ] 9.1.2 Update pyproject.toml version - - [ ] 9.1.3 Update setup.py version - - [ ] 9.1.4 Update src/__init__.py version - - [ ] 9.1.5 Update src/specfact_cli/__init__.py version - - [ ] 9.1.6 Verify all versions match +- [x] 9.1 Bump version + - [x] 9.1.1 Determine version bump: minor (new feature) + - [x] 9.1.2 Update pyproject.toml version + - [x] 9.1.3 Update setup.py version + - [x] 9.1.4 Update src/__init__.py version + - [x] 9.1.5 Update src/specfact_cli/__init__.py version + - [x] 9.1.6 Verify all versions match -- [ ] 9.2 Update CHANGELOG.md - - [ ] 9.2.1 Add new section: [X.Y.Z] - YYYY-MM-DD - - [ ] 9.2.2 Add "Added" subsection with marketplace features - - [ ] 9.2.3 Reference GitHub issue if created +- [x] 9.2 Update CHANGELOG.md + - [x] 9.2.1 Add new section: [X.Y.Z] - YYYY-MM-DD + - [x] 9.2.2 Add "Added" subsection with marketplace features + - [x] 9.2.3 Reference GitHub issue if created ## 10. Create PR to dev -- [ ] 10.1 Prepare commit - - [ ] 10.1.1 `git add .` - - [ ] 10.1.2 Create commit with conventional message format - - [ ] 10.1.3 Include Co-Authored-By: Claude Sonnet 4.5 - - [ ] 10.1.4 `git push -u origin feature/marketplace-01-central-module-registry` +- [x] 10.1 Prepare commit + - [x] 10.1.1 `git add .` + - [x] 10.1.2 Create commit with conventional message format + - [x] 10.1.3 Include Co-Authored-By: Claude Sonnet 4.5 + - [x] 10.1.4 `git push -u origin feature/marketplace-01-central-module-registry` -- [ ] 10.2 Create PR body - - [ ] 10.2.1 Copy PR template to temp file - - [ ] 10.2.2 Fill in issue reference (if exists) - - [ ] 10.2.3 Add OpenSpec change ID - - [ ] 10.2.4 Describe marketplace infrastructure +- [x] 10.2 Create PR body + - [x] 10.2.1 Copy PR template to temp file + - [x] 10.2.2 Fill in issue reference (if exists) + - [x] 10.2.3 Add OpenSpec change ID + - [x] 10.2.4 Describe marketplace infrastructure -- [ ] 10.3 Create PR via gh CLI - - [ ] 10.3.1 `gh pr create --repo nold-ai/specfact-cli --base dev --head feature/marketplace-01-central-module-registry --title "feat: Central Module Registry MVP for Official Modules" --body-file ` - - [ ] 10.3.2 Capture PR URL +- [x] 10.3 Create PR via gh CLI + - [x] 10.3.1 `gh pr create --repo nold-ai/specfact-cli --base dev --head feature/marketplace-01-central-module-registry --title "feat: Central Module Registry MVP for Official Modules" --body-file ` + - [x] 10.3.2 Capture PR URL -- [ ] 10.4 Link to project - - [ ] 10.4.1 `gh project item-add 1 --owner nold-ai --url ` +- [x] 10.4 Link to project + - [x] 10.4.1 `gh project item-add 1 --owner nold-ai --url ` -- [ ] 10.5 Verify PR setup - - [ ] 10.5.1 Check PR shows correct base and head - - [ ] 10.5.2 Verify CI checks running - - [ ] 10.5.3 Verify project board shows PR +- [x] 10.5 Verify PR setup + - [x] 10.5.1 Check PR shows correct base and head + - [x] 10.5.2 Verify CI checks running + - [x] 10.5.3 Verify project board shows PR -- [ ] 10.6 Cleanup - - [ ] 10.6.1 Remove temp files +- [x] 10.6 Cleanup + - [x] 10.6.1 Remove temp files diff --git a/pyproject.toml b/pyproject.toml index 129625e1..12452a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.34.1" +version = "0.35.0" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 86056f2e..94c550cc 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.34.1", + version="0.35.0", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 7ea2256a..d82b7ce8 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.34.1" +__version__ = "0.35.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 75342bcf..b44ef2f6 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.34.1" +__version__ = "0.35.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py index 4bb0b9f8..44a54bd5 100644 --- a/src/specfact_cli/models/module_package.py +++ b/src/specfact_cli/models/module_package.py @@ -150,9 +150,19 @@ class ModulePackageMetadata(BaseModel): default_factory=list, description="Declarative schema extensions for Feature/ProjectBundle (arch-07).", ) + description: str | None = Field(default=None, description="Module description for user-facing module details") + license: str | None = Field(default=None, description="SPDX license identifier or license name") + source: str = Field(default="builtin", description="Module source: builtin, marketplace, or custom") @beartype @ensure(lambda result: isinstance(result, list), "Validated bridges must be returned as a list") def validate_service_bridges(self) -> list[ServiceBridgeMetadata]: """Return validated bridge declarations for lifecycle registration.""" return list(self.service_bridges) + + @model_validator(mode="after") + def validate_source(self) -> ModulePackageMetadata: + """Validate source is one of supported module origins.""" + if self.source not in {"builtin", "marketplace", "custom"}: + raise ValueError("source must be one of: builtin, marketplace, custom") + return self diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index 83377c55..b9b23efb 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: analyze -version: "0.27.0" +version: 0.35.0 commands: - analyze command_help: - analyze: "Analyze codebase for contract coverage and quality" + analyze: Analyze codebase for contract coverage and quality pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Analyze codebase quality, contracts, and architecture signals. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index 129e04c0..161447b1 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: auth -version: "0.27.0" +version: 0.35.0 commands: - auth command_help: - auth: "Authenticate with DevOps providers (GitHub, Azure DevOps)" + auth: Authenticate with DevOps providers (GitHub, Azure DevOps) pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Authenticate SpecFact with supported DevOps providers. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index fde28ff3..4b35d540 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -1,14 +1,13 @@ -# SpecFact CLI module package manifest. name: backlog -version: "0.27.0" +version: 0.35.0 commands: - backlog command_help: - backlog: "Backlog refinement and template management" + backlog: Backlog refinement and template management pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' service_bridges: - id: ado converter_class: specfact_cli.modules.backlog.src.adapters.ado.AdoConverter @@ -22,3 +21,9 @@ service_bridges: - id: github converter_class: specfact_cli.modules.backlog.src.adapters.github.GitHubConverter description: GitHub issue payload converter +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Manage backlog ceremonies, refinement, and dependency insights. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index 4b6b3767..e23f2e5a 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: contract -version: "0.27.0" +version: 0.35.0 commands: - contract command_help: - contract: "Manage OpenAPI contracts for project bundles" + contract: Manage OpenAPI contracts for project bundles pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Validate and manage API contracts for project bundles. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index aa069e32..6ec43c49 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: drift -version: "0.27.0" +version: 0.35.0 commands: - drift command_help: - drift: "Detect drift between code and specifications" + drift: Detect drift between code and specifications pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Detect and report drift between code, plans, and specs. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index e6c47214..341f58bf 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -1,12 +1,17 @@ -# SpecFact CLI module package manifest. name: enforce -version: "0.27.0" +version: 0.35.0 commands: - enforce command_help: - enforce: "Configure quality gates" + enforce: Configure quality gates pip_dependencies: [] module_dependencies: - plan tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Apply governance policies and quality gates to bundles. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index 9b798791..2ccbab5e 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -1,12 +1,17 @@ -# SpecFact CLI module package manifest. name: generate -version: "0.27.0" +version: 0.35.0 commands: - generate command_help: - generate: "Generate artifacts from SDD and plans" + generate: Generate artifacts from SDD and plans pip_dependencies: [] module_dependencies: - plan tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Generate implementation artifacts from plans and SDD. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index c2090ffb..7a383556 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. Command name is "import"; package id import_cmd. name: import_cmd -version: "0.27.0" +version: 0.35.0 commands: - import command_help: - import: "Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown)" + import: Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Import projects and requirements from code and external tools. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 0f5f8411..b7174644 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: init -version: "0.27.0" +version: 0.35.0 commands: - init command_help: - init: "Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)" + init: Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup) pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Initialize SpecFact workspace and bootstrap local configuration. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index d2269877..1e7f00cc 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -126,7 +126,9 @@ def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: console.print("[dim]Backlog field mapping templates already exist (use --force to overwrite)[/dim]") -app = typer.Typer(help="Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)") +app = typer.Typer( + help="Bootstrap SpecFact. Module lifecycle flags under init are deprecated soon; use `specfact module ...` (use `init ide` for IDE setup)" +) console = Console() _MODULE_IO_CONTRACT = ModuleIOContract import_to_bundle = module_io_shim.import_to_bundle @@ -481,26 +483,29 @@ def init( [], "--enable-module", help=( - "Enable module by id (repeatable); if provided without value in interactive mode, " - "opens selector. In non-interactive mode, a module id is required." + "DEPRECATED soon: enable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required. Prefer `specfact module ...`." ), ), disable_module: list[str] = typer.Option( [], "--disable-module", help=( - "Disable module by id (repeatable); if provided without value in interactive mode, " - "opens selector. In non-interactive mode, a module id is required." + "DEPRECATED soon: disable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required. Prefer `specfact module ...`." ), ), list_modules: bool = typer.Option( False, "--list-modules", - help="List discovered installed modules with enabled/disabled status and exit.", + help="DEPRECATED soon: list module state (prefer `specfact module list`).", ), ) -> None: """ - Bootstrap SpecFact local state and manage module lifecycle. + Bootstrap SpecFact local state. + + Note: `--list-modules`, `--enable-module`, and `--disable-module` under `init` are deprecated soon. + Prefer `specfact module ...` for lifecycle operations. This command initializes/updates user-level module registry state, discovers installed modules, and manages enabled/disabled module lifecycle with dependency diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 7f0136be..bebac551 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: migrate -version: "0.27.0" +version: 0.35.0 commands: - migrate command_help: - migrate: "Migrate project bundles between formats" + migrate: Migrate project bundles between formats pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Migrate project bundles across supported structure versions. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml new file mode 100644 index 00000000..96f2c0a6 --- /dev/null +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -0,0 +1,16 @@ +name: module-registry +version: 0.35.0 +commands: + - module +command_help: + module: Manage marketplace modules (install, uninstall, search, list, show, upgrade) +pip_dependencies: [] +module_dependencies: [] +tier: community +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: 'Manage modules: search, list, show, install, and upgrade.' +license: Apache-2.0 diff --git a/src/specfact_cli/modules/module_registry/src/__init__.py b/src/specfact_cli/modules/module_registry/src/__init__.py new file mode 100644 index 00000000..35857ddd --- /dev/null +++ b/src/specfact_cli/modules/module_registry/src/__init__.py @@ -0,0 +1 @@ +"""Module marketplace command package.""" diff --git a/src/specfact_cli/modules/module_registry/src/app.py b/src/specfact_cli/modules/module_registry/src/app.py new file mode 100644 index 00000000..af283644 --- /dev/null +++ b/src/specfact_cli/modules/module_registry/src/app.py @@ -0,0 +1,18 @@ +"""Module package app entrypoint for marketplace commands.""" + +from specfact_cli.modules.module_registry.src.commands import ( + app, + export_from_bundle, + import_to_bundle, + sync_with_bundle, + validate_bundle, +) + + +__all__ = [ + "app", + "export_from_bundle", + "import_to_bundle", + "sync_with_bundle", + "validate_bundle", +] diff --git a/src/specfact_cli/modules/module_registry/src/commands.py b/src/specfact_cli/modules/module_registry/src/commands.py new file mode 100644 index 00000000..7c2de3ee --- /dev/null +++ b/src/specfact_cli/modules/module_registry/src/commands.py @@ -0,0 +1,459 @@ +"""Marketplace module management CLI commands.""" + +from __future__ import annotations + +import inspect + +import typer +from beartype import beartype +from rich.console import Console +from rich.table import Table + +from specfact_cli.modules import module_io_shim +from specfact_cli.registry.marketplace_client import fetch_registry_index +from specfact_cli.registry.module_discovery import discover_all_modules +from specfact_cli.registry.module_installer import install_module, uninstall_module +from specfact_cli.registry.module_lifecycle import ( + apply_module_state_update, + get_modules_with_state, + render_modules_table, + select_module_ids_interactive, +) +from specfact_cli.registry.registry import CommandRegistry +from specfact_cli.runtime import is_non_interactive + + +app = typer.Typer(help="Manage marketplace modules") +console = Console() + + +@app.command() +@beartype +def install( + module_id: str = typer.Argument(..., help="Module id (name or namespace/name format)"), + version: str | None = typer.Option(None, "--version", help="Install a specific version"), +) -> None: + """Install a module from marketplace registry.""" + normalized = module_id if "/" in module_id else f"specfact/{module_id}" + if normalized.count("/") != 1: + console.print("[red]Invalid module id. Use 'name' or 'namespace/name'.[/red]") + raise typer.Exit(1) + + requested_name = normalized.split("/", 1)[1] + discovered_by_name = {entry.metadata.name: entry for entry in discover_all_modules()} + existing = discovered_by_name.get(requested_name) + if existing is not None and existing.source != "marketplace": + console.print( + f"[yellow]Module '{requested_name}' is already available from source '{existing.source}'. " + "No marketplace install needed.[/yellow]" + ) + return + + try: + installed_path = install_module(normalized, version=version) + except Exception as exc: + console.print(f"[red]Failed installing {normalized}: {exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"[green]Installed[/green] {normalized} -> {installed_path}") + + +@app.command() +@beartype +def uninstall(module_name: str = typer.Argument(..., help="Installed module name (name or namespace/name)")) -> None: + """Uninstall a marketplace module.""" + normalized = module_name + if "/" in normalized: + if normalized.count("/") != 1: + console.print("[red]Invalid module id. Use 'name' or 'namespace/name'.[/red]") + raise typer.Exit(1) + normalized = normalized.split("/", 1)[1] + + discovered_by_name = {entry.metadata.name: entry for entry in discover_all_modules()} + existing = discovered_by_name.get(normalized) + source = existing.source if existing is not None else "unknown" + + if source == "builtin": + console.print( + f"[red]Cannot uninstall built-in module '{normalized}'. Use `specfact module disable {normalized}` instead.[/red]" + ) + raise typer.Exit(1) + if source == "custom": + console.print( + f"[red]Cannot uninstall custom module '{normalized}' via marketplace uninstall. " + "Remove it from your local module roots (workspace `modules/` or `~/.specfact/custom-modules`).[/red]" + ) + raise typer.Exit(1) + if source == "unknown": + console.print( + f"[red]Module '{normalized}' is not installed from marketplace (or not discovered). " + "Run `specfact module list --show-origin` to inspect available modules.[/red]" + ) + raise typer.Exit(1) + + try: + uninstall_module(normalized) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"[green]Uninstalled[/green] {normalized}") + + +@app.command() +@beartype +def enable( + module_id: str | None = typer.Argument(None, help="Module id to enable; omit in interactive mode to select"), + force: bool = typer.Option(False, "--force", help="Override dependency checks and cascade dependencies"), +) -> None: + """Enable modules in lifecycle state registry.""" + enable_ids: list[str] + if module_id: + enable_ids = [module_id] + else: + if is_non_interactive(): + console.print("[red]Error:[/red] Non-interactive mode requires explicit module id value.") + raise typer.Exit(1) + modules = get_modules_with_state() + enable_ids = select_module_ids_interactive("enable", modules, console) + if not enable_ids: + return + + try: + apply_module_state_update(enable_ids=enable_ids, disable_ids=[], force=force) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"[green]Enabled[/green] {', '.join(sorted(enable_ids, key=str.lower))}") + + +@app.command() +@beartype +def disable( + module_id: str | None = typer.Argument(None, help="Module id to disable; omit in interactive mode to select"), + force: bool = typer.Option(False, "--force", help="Override dependency checks and cascade dependents"), +) -> None: + """Disable modules in lifecycle state registry.""" + disable_ids: list[str] + if module_id: + disable_ids = [module_id] + else: + if is_non_interactive(): + console.print("[red]Error:[/red] Non-interactive mode requires explicit module id value.") + raise typer.Exit(1) + modules = get_modules_with_state() + disable_ids = select_module_ids_interactive("disable", modules, console) + if not disable_ids: + return + + try: + apply_module_state_update(enable_ids=[], disable_ids=disable_ids, force=force) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"[green]Disabled[/green] {', '.join(sorted(disable_ids, key=str.lower))}") + + +@app.command() +@beartype +def search(query: str = typer.Argument(..., help="Search query")) -> None: + """Search marketplace and installed modules by id/description/tags.""" + query_l = query.lower().strip() + seen_ids: set[str] = set() + rows: list[dict[str, str]] = [] + + index = fetch_registry_index() or {} + for entry in index.get("modules", []): + if not isinstance(entry, dict): + continue + module_id = str(entry.get("id", "")) + description = str(entry.get("description", "")) + tags = entry.get("tags", []) + tags_text = " ".join(str(t) for t in tags) if isinstance(tags, list) else "" + haystack = f"{module_id} {description} {tags_text}".lower() + if query_l in haystack and module_id not in seen_ids: + seen_ids.add(module_id) + rows.append( + { + "id": module_id, + "version": str(entry.get("latest_version", "")), + "description": description, + "scope": "marketplace", + } + ) + + for discovered in discover_all_modules(): + meta = discovered.metadata + module_id = str(meta.name) + description = str(meta.description or "") + publisher = meta.publisher.name if meta.publisher else "" + haystack = f"{module_id} {description} {publisher}".lower() + if query_l not in haystack: + continue + + if module_id in seen_ids: + continue + + seen_ids.add(module_id) + rows.append( + { + "id": module_id, + "version": str(meta.version), + "description": description, + "scope": "installed", + } + ) + + if not rows: + console.print(f"No modules found for query '{query}'") + return + + rows.sort(key=lambda row: row["id"].lower()) + + table = Table(title="Module Search Results") + table.add_column("ID", style="cyan") + table.add_column("Version", style="magenta") + table.add_column("Scope", style="yellow") + table.add_column("Description") + for row in rows: + table.add_row(row["id"], row["version"], row["scope"], row["description"]) + console.print(table) + + +def _trust_label(module: dict) -> str: + """Return user-facing trust label for a module row.""" + source = str(module.get("source", "unknown")) + if bool(module.get("official", False)): + return "official" + if source == "marketplace": + return "community" + return "local-dev" + + +def _origin_label(source: str) -> str: + """Return user-friendly origin label.""" + return "built-in" if source == "builtin" else source + + +def _typer_command_info_name(command_info: object) -> str: + """Return a stable command name from Typer command info.""" + explicit_name = getattr(command_info, "name", None) + if isinstance(explicit_name, str) and explicit_name: + return explicit_name + callback = getattr(command_info, "callback", None) + callback_name = getattr(callback, "__name__", "") + return callback_name.replace("_", "-") if callback_name else "" + + +def _short_help(help_text: object) -> str: + """Normalize help text into a short single-line description.""" + if isinstance(help_text, str) and help_text.strip(): + return " ".join(help_text.strip().split()) + return "No description available" + + +def _command_info_help(command_info: object) -> str: + """Extract command help from Typer info or callback docstring.""" + explicit_help = getattr(command_info, "help", None) + if isinstance(explicit_help, str) and explicit_help.strip(): + return _short_help(explicit_help) + + callback = getattr(command_info, "callback", None) + callback_doc = inspect.getdoc(callback) if callback is not None else None + if callback_doc: + first_line = callback_doc.splitlines()[0].strip() + if first_line: + return _short_help(first_line) + + return "No description available" + + +def _group_info_help(group_info: object) -> str: + """Extract group help from Typer group info or nested app info.""" + explicit_help = getattr(group_info, "help", None) + if isinstance(explicit_help, str) and explicit_help.strip(): + return _short_help(explicit_help) + + nested_app = getattr(group_info, "typer_instance", None) + app_info = getattr(nested_app, "info", None) + app_help = getattr(app_info, "help", None) if app_info is not None else None + if isinstance(app_help, str) and app_help.strip(): + return _short_help(app_help) + + return "No description available" + + +def _collect_typer_command_entries(app: object, prefix: str) -> dict[str, str]: + """Collect full command paths and short help recursively from a Typer app.""" + entries: dict[str, str] = {} + + command_infos = list(getattr(app, "registered_commands", [])) + for command_info in command_infos: + command_name = _typer_command_info_name(command_info) + if not command_name: + continue + command_path = f"{prefix} {command_name}" + entries[command_path] = _command_info_help(command_info) + + group_infos = list(getattr(app, "registered_groups", [])) + for group_info in group_infos: + group_name = getattr(group_info, "name", "") or "" + if not group_name: + continue + group_prefix = f"{prefix} {group_name}" + entries[group_prefix] = _group_info_help(group_info) + nested_app = getattr(group_info, "typer_instance", None) + if nested_app is not None: + entries.update(_collect_typer_command_entries(nested_app, group_prefix)) + + return entries + + +def _derive_module_command_entries(metadata: object) -> list[tuple[str, str]]: + """Derive command/subcommand paths with short help for module show output.""" + roots: list[str] = [] + meta_commands = list(getattr(metadata, "commands", []) or []) + if meta_commands: + roots.extend(str(cmd) for cmd in meta_commands) + else: + command_help = getattr(metadata, "command_help", None) or {} + roots.extend(str(cmd) for cmd in command_help) + + if not roots: + return [] + + manifest_help = getattr(metadata, "command_help", None) or {} + entries: dict[str, str] = {} + for root in roots: + registry_meta = CommandRegistry.get_metadata(root) + root_help = registry_meta.help if registry_meta and registry_meta.help else manifest_help.get(root) + entries[root] = _short_help(root_help) + try: + root_app = CommandRegistry.get_typer(root) + except Exception: + continue + entries.update(_collect_typer_command_entries(root_app, root)) + + return sorted(entries.items(), key=lambda item: item[0].lower()) + + +@app.command(name="list") +@beartype +def list_modules( + source: str | None = typer.Option(None, "--source", help="Filter by origin: builtin, marketplace, custom"), + show_origin: bool = typer.Option(False, "--show-origin", help="Show raw origin column in addition to trust"), +) -> None: + """List installed modules with trust labels and optional origin details.""" + modules = get_modules_with_state() + if source: + modules = [m for m in modules if str(m.get("source", "")) == source] + render_modules_table(console, modules, show_origin=show_origin) + + +@app.command() +@beartype +def show(module_name: str = typer.Argument(..., help="Installed module name")) -> None: + """Show detailed metadata for an installed module.""" + modules = get_modules_with_state() + module_row = next((m for m in modules if str(m.get("id", "")) == module_name), None) + if module_row is None: + console.print(f"[red]Module '{module_name}' is not installed.[/red]") + raise typer.Exit(1) + + discovered = {entry.metadata.name: entry.metadata for entry in discover_all_modules()} + metadata = discovered.get(module_name) + + source = str(module_row.get("source", "unknown")) + trust = _trust_label(module_row) + state = "enabled" if bool(module_row.get("enabled", True)) else "disabled" + publisher = str(module_row.get("publisher", "unknown")) + description = metadata.description if metadata and metadata.description else "n/a" + license_value = metadata.license if metadata and metadata.license else "n/a" + tier = metadata.tier if metadata and metadata.tier else "n/a" + command_entries = _derive_module_command_entries(metadata) if metadata else [] + commands = "\n".join(f"{path} - {help_text}" for path, help_text in command_entries) if command_entries else "n/a" + core_compatibility = metadata.core_compatibility if metadata and metadata.core_compatibility else "n/a" + + publisher_url = "n/a" + if metadata and metadata.publisher: + publisher_url = metadata.publisher.attributes.get("url", "n/a") + + table = Table(title=f"Module Details: {module_name}") + table.add_column("Field", style="cyan", no_wrap=True) + table.add_column("Value", style="white") + table.add_row("Name", module_name) + table.add_row("Description", description) + table.add_row("Version", str(module_row.get("version", ""))) + table.add_row("State", state) + table.add_row("Trust", trust) + table.add_row("Publisher", publisher) + table.add_row("Publisher URL", publisher_url) + table.add_row("License", license_value) + table.add_row("Origin", _origin_label(source)) + table.add_row("Tier", tier) + table.add_row("Core Compatibility", core_compatibility) + table.add_row("Commands", commands) + console.print(table) + + +@app.command() +@beartype +def upgrade( + module_name: str | None = typer.Argument( + None, help="Installed module name (optional; omit to upgrade all marketplace modules)" + ), + all: bool = typer.Option(False, "--all", help="Upgrade all installed marketplace modules"), +) -> None: + """Upgrade marketplace module(s) to latest available versions.""" + modules = get_modules_with_state() + by_id = {str(m.get("id", "")): m for m in modules} + + target_ids: list[str] = [] + if all or module_name is None: + target_ids = [str(m.get("id", "")) for m in modules if str(m.get("source", "")) == "marketplace"] + if not target_ids: + console.print("[yellow]No marketplace-installed modules found to upgrade.[/yellow]") + return + else: + normalized = module_name + if normalized in by_id: + source = str(by_id[normalized].get("source", "unknown")) + if source != "marketplace": + console.print( + f"[red]Cannot upgrade '{normalized}' from source '{source}'. Only marketplace modules are upgradeable.[/red]" + ) + raise typer.Exit(1) + target_ids = [normalized] + else: + prefixed = normalized if "/" in normalized else f"specfact/{normalized}" + # If module isn't discovered locally, still attempt marketplace install/upgrade by ID. + target_ids = [prefixed] + + upgraded: list[str] = [] + failed: list[str] = [] + for target in target_ids: + try: + module_id = target if "/" in target else f"specfact/{target}" + install_module(module_id) + upgraded.append(module_id) + except Exception as exc: + console.print(f"[red]Failed upgrading {target}: {exc}[/red]") + failed.append(target) + + if upgraded: + console.print(f"[green]Upgraded[/green] {', '.join(upgraded)}") + if failed: + raise typer.Exit(1) + + +# Expose standard ModuleIOContract operations for protocol compliance discovery. +import_to_bundle = module_io_shim.import_to_bundle +export_from_bundle = module_io_shim.export_from_bundle +sync_with_bundle = module_io_shim.sync_with_bundle +validate_bundle = module_io_shim.validate_bundle + +__all__ = [ + "app", + "export_from_bundle", + "import_to_bundle", + "sync_with_bundle", + "validate_bundle", +] diff --git a/src/specfact_cli/modules/patch_mode/module-package.yaml b/src/specfact_cli/modules/patch_mode/module-package.yaml index 4871cec1..d044a3ec 100644 --- a/src/specfact_cli/modules/patch_mode/module-package.yaml +++ b/src/specfact_cli/modules/patch_mode/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: patch-mode -version: "0.1.0" +version: 0.35.0 commands: - patch command_help: - patch: "Preview and apply patches (backlog body, OpenSpec, config); --apply local, --write upstream with confirmation." + patch: Preview and apply patches (backlog body, OpenSpec, config); --apply local, --write upstream with confirmation. pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Prepare, review, and apply structured repository patches safely. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index c82ddf48..c75def19 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -1,12 +1,17 @@ -# SpecFact CLI module package manifest. name: plan -version: "0.27.0" +version: 0.35.0 commands: - plan command_help: - plan: "Manage development plans" + plan: Manage development plans pip_dependencies: [] module_dependencies: - sync tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Create and manage implementation plans for project execution. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/policy_engine/module-package.yaml b/src/specfact_cli/modules/policy_engine/module-package.yaml index cfd999c5..8f933789 100644 --- a/src/specfact_cli/modules/policy_engine/module-package.yaml +++ b/src/specfact_cli/modules/policy_engine/module-package.yaml @@ -1,21 +1,24 @@ name: policy-engine -version: "0.1.0" +version: 0.35.0 commands: - policy command_help: - policy: "Policy validation and suggestion workflows (DoR/DoD/Flow/PI)" + policy: Policy validation and suggestion workflows (DoR/DoD/Flow/PI) pip_dependencies: [] module_dependencies: [] -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' tier: community schema_extensions: - target: ProjectBundle field: policy_engine_policy_status - type_hint: "dict[str, Any] | None" - description: "Latest policy validation status snapshot for the current project bundle." + type_hint: dict[str, Any] | None + description: Latest policy validation status snapshot for the current project bundle. publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai integrity: checksum_algorithm: sha256 dependencies: [] +description: Run policy evaluations with recommendation and compliance outputs. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/project/module-package.yaml b/src/specfact_cli/modules/project/module-package.yaml index 86f42288..7d21c66c 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: project -version: "0.27.0" +version: 0.35.0 commands: - project command_help: - project: "Manage project bundles with persona workflows" + project: Manage project bundles with persona workflows pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Manage project bundles, contexts, and lifecycle workflows. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index 3094d0e6..21d18be5 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: repro -version: "0.27.0" +version: 0.35.0 commands: - repro command_help: - repro: "Run validation suite" + repro: Run validation suite pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Run reproducible validation and diagnostics workflows end-to-end. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 52c0f54c..f4b27b73 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: sdd -version: "0.27.0" +version: 0.35.0 commands: - sdd command_help: - sdd: "Manage SDD (Spec-Driven Development) manifests" + sdd: Manage SDD (Spec-Driven Development) manifests pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Create and validate Spec-Driven Development manifests and mappings. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index 33e79331..2e7b0a30 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: spec -version: "0.27.0" +version: 0.35.0 commands: - spec command_help: - spec: "Specmatic integration for API contract testing" + spec: Specmatic integration for API contract testing pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Integrate and run API specification and contract checks. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 96970309..7d730aa7 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -1,13 +1,18 @@ -# SpecFact CLI module package manifest. name: sync -version: "0.27.0" +version: 0.35.0 commands: - sync command_help: - sync: "Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.)" + sync: Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.) pip_dependencies: [] module_dependencies: - plan - sdd tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Synchronize repository state with connected external systems. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index cdb19b66..0b695869 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. CLI command is "upgrade"; implementation in commands.update. name: upgrade -version: "0.27.0" +version: 0.35.0 commands: - upgrade command_help: - upgrade: "Check for and install SpecFact CLI updates" + upgrade: Check for and install SpecFact CLI updates pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Check and apply SpecFact CLI version upgrades. +license: Apache-2.0 diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index b737bec9..8bcd85e4 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -1,11 +1,16 @@ -# SpecFact CLI module package manifest. name: validate -version: "0.27.0" +version: 0.35.0 commands: - validate command_help: - validate: "Validation commands including sidecar validation" + validate: Validation commands including sidecar validation pip_dependencies: [] module_dependencies: [] tier: community -core_compatibility: ">=0.28.0,<1.0.0" +core_compatibility: '>=0.28.0,<1.0.0' +publisher: + name: nold-ai + url: https://github.com/nold-ai/specfact-cli-modules + email: oss@nold.ai +description: Run schema, contract, and workflow validation suites. +license: Apache-2.0 diff --git a/src/specfact_cli/registry/marketplace_client.py b/src/specfact_cli/registry/marketplace_client.py new file mode 100644 index 00000000..97c9a63b --- /dev/null +++ b/src/specfact_cli/registry/marketplace_client.py @@ -0,0 +1,100 @@ +"""Marketplace registry client for module discovery and downloads.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from urllib.parse import urlparse + +import requests +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.common import get_bridge_logger + + +REGISTRY_INDEX_URL = "https://raw.githubusercontent.com/nold-ai/specfact-cli-modules/main/registry/index.json" + + +class SecurityError(RuntimeError): + """Raised when downloaded module integrity verification fails.""" + + +@beartype +@ensure(lambda result: result is None or isinstance(result, dict), "Result must be dict or None") +def fetch_registry_index(index_url: str = REGISTRY_INDEX_URL, timeout: float = 10.0) -> dict | None: + """Fetch and parse marketplace registry index.""" + logger = get_bridge_logger(__name__) + try: + response = requests.get(index_url, timeout=timeout) + response.raise_for_status() + except Exception as exc: + logger.warning("Registry unavailable, using offline mode: %s", exc) + return None + + try: + payload = response.json() + except (ValueError, json.JSONDecodeError) as exc: + logger.error("Failed to parse registry index JSON: %s", exc) + raise ValueError("Invalid registry index format") from exc + + if not isinstance(payload, dict): + raise ValueError("Invalid registry index format") + + return payload + + +@beartype +@require(lambda module_id: "/" in module_id and len(module_id.split("/")) == 2, "module_id must be namespace/name") +@ensure(lambda result: result.exists(), "Downloaded module archive must exist") +def download_module( + module_id: str, + *, + version: str | None = None, + download_dir: Path | None = None, + index: dict | None = None, + timeout: float = 20.0, +) -> Path: + """Download module tarball and verify SHA-256 checksum from registry metadata.""" + logger = get_bridge_logger(__name__) + registry_index = index if index is not None else fetch_registry_index() + if not registry_index: + raise ValueError("Cannot install from marketplace (offline)") + + modules = registry_index.get("modules", []) + if not isinstance(modules, list): + raise ValueError("Invalid registry index format") + + entry = None + for candidate in modules: + if isinstance(candidate, dict) and candidate.get("id") == module_id: + if version and candidate.get("latest_version") != version: + continue + entry = candidate + break + + if entry is None: + raise ValueError(f"Module '{module_id}' not found in registry") + + download_url = str(entry.get("download_url", "")).strip() + expected_checksum = str(entry.get("checksum_sha256", "")).strip().lower() + if not download_url or not expected_checksum: + raise ValueError("Invalid registry index format") + + response = requests.get(download_url, timeout=timeout) + response.raise_for_status() + content = response.content + + actual_checksum = hashlib.sha256(content).hexdigest() + if actual_checksum != expected_checksum: + raise SecurityError(f"Checksum mismatch for module {module_id}") + + target_dir = download_dir or (Path.home() / ".specfact" / "downloads") + target_dir.mkdir(parents=True, exist_ok=True) + parsed = urlparse(download_url) + file_name = Path(parsed.path).name or f"{module_id.replace('/', '-')}.tar.gz" + target_path = target_dir / file_name + target_path.write_bytes(content) + logger.info("Downloaded module '%s' to '%s'", module_id, target_path) + return target_path diff --git a/src/specfact_cli/registry/module_discovery.py b/src/specfact_cli/registry/module_discovery.py new file mode 100644 index 00000000..1be8fac7 --- /dev/null +++ b/src/specfact_cli/registry/module_discovery.py @@ -0,0 +1,87 @@ +"""Module discovery across built-in, marketplace, and custom roots.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from beartype import beartype +from icontract import ensure + +from specfact_cli.common import get_bridge_logger +from specfact_cli.models.module_package import ModulePackageMetadata + + +MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" +CUSTOM_MODULES_ROOT = Path.home() / ".specfact" / "custom-modules" + + +@dataclass(frozen=True) +class DiscoveredModule: + """Discovered module package metadata with source tracking.""" + + package_dir: Path + metadata: ModulePackageMetadata + source: str + + +@beartype +@ensure(lambda result: isinstance(result, list), "Discovery result must be a list") +def discover_all_modules( + builtin_root: Path | None = None, + marketplace_root: Path | None = None, + custom_root: Path | None = None, +) -> list[DiscoveredModule]: + """Discover modules from all configured locations with deterministic priority.""" + from specfact_cli.registry.module_packages import discover_package_metadata, get_modules_root, get_modules_roots + + logger = get_bridge_logger(__name__) + discovered: list[DiscoveredModule] = [] + seen_names: set[str] = set() + + effective_builtin_root = builtin_root or get_modules_root() + effective_marketplace_root = marketplace_root or MARKETPLACE_MODULES_ROOT + effective_custom_root = custom_root or CUSTOM_MODULES_ROOT + + roots: list[tuple[str, Path]] = [ + ("builtin", effective_builtin_root), + ("marketplace", effective_marketplace_root), + ("custom", effective_custom_root), + ] + + # Keep legacy discovery roots (workspace-level + SPECFACT_MODULES_ROOTS) as custom sources. + seen_root_paths = {path.resolve() for _source, path in roots} + for extra_root in get_modules_roots(): + resolved = extra_root.resolve() + if resolved in seen_root_paths: + continue + seen_root_paths.add(resolved) + roots.append(("custom", extra_root)) + + for source, root in roots: + if not root.exists() or not root.is_dir(): + continue + + entries = discover_package_metadata(root, source=source) + for package_dir, metadata in entries: + module_name = metadata.name + if module_name in seen_names: + if source in {"marketplace", "custom"}: + logger.warning( + "Module '%s' from %s at '%s' is shadowed by higher-priority source.", + module_name, + source, + package_dir, + ) + continue + + seen_names.add(module_name) + discovered.append( + DiscoveredModule( + package_dir=package_dir, + metadata=metadata, + source=source, + ) + ) + + return discovered diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index 53ede3ba..4d23e5e6 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -1,16 +1,26 @@ -""" -Module artifact verification stages for installation and registration (arch-06). -""" +"""Module artifact verification and marketplace installation workflows.""" from __future__ import annotations +import shutil +import tarfile +import tempfile from pathlib import Path +import yaml from beartype import beartype +from icontract import ensure, require +from packaging.specifiers import SpecifierSet +from packaging.version import Version +from specfact_cli import __version__ as cli_version from specfact_cli.common import get_bridge_logger from specfact_cli.models.module_package import ModulePackageMetadata from specfact_cli.registry.crypto_validator import verify_checksum +from specfact_cli.registry.marketplace_client import download_module + + +MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" @beartype @@ -19,16 +29,7 @@ def verify_module_artifact( meta: ModulePackageMetadata, allow_unsigned: bool = False, ) -> bool: - """ - Run integrity verification for a module artifact. Used at registration and install time. - - - If meta.integrity is set: verify checksum (and signature if present); return False on failure. - - If meta.integrity is not set and allow_unsigned: return True (allow with warning). - - If meta.integrity is not set and not allow_unsigned: return False (reject unsigned by default). - - Returns: - True if the module passes trust checks and may be registered/installed. - """ + """Run integrity verification for a module artifact.""" logger = get_bridge_logger(__name__) manifest_path = package_dir / "module-package.yaml" if not manifest_path.exists(): @@ -38,7 +39,6 @@ def verify_module_artifact( return allow_unsigned if meta.integrity is None: - # Backward compatible: allow modules without integrity unless strict mode is added later. if allow_unsigned: logger.debug("Module %s: No integrity metadata; allowing (allow-unsigned)", meta.name) return True @@ -46,15 +46,106 @@ def verify_module_artifact( try: data = manifest_path.read_bytes() verify_checksum(data, meta.integrity.checksum) - except ValueError as e: - logger.warning("Module %s: Integrity check failed: %s", meta.name, e) + except ValueError as exc: + logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) return False if meta.integrity.signature: - # Signature verification would require key material (not in manifest). Allow with warning. logger.warning( "Module %s: Signature present but key material not configured; checksum-only verification", meta.name, ) return True + + +@beartype +@require(lambda module_id: "/" in module_id and len(module_id.split("/")) == 2, "module_id must be namespace/name") +@ensure(lambda result: result.exists(), "Installed module path must exist") +def install_module( + module_id: str, + *, + version: str | None = None, + install_root: Path | None = None, +) -> Path: + """Install a marketplace module from tarball into marketplace modules root.""" + logger = get_bridge_logger(__name__) + target_root = install_root or MARKETPLACE_MODULES_ROOT + target_root.mkdir(parents=True, exist_ok=True) + + _namespace, module_name = module_id.split("/", 1) + final_path = target_root / module_name + manifest_path = final_path / "module-package.yaml" + + if manifest_path.exists(): + logger.info("Module already installed (%s)", module_name) + return final_path + + archive_path = download_module(module_id, version=version) + + with tempfile.TemporaryDirectory(prefix="specfact-module-install-") as tmp_dir: + tmp_dir_path = Path(tmp_dir) + extract_root = tmp_dir_path / "extract" + extract_root.mkdir(parents=True, exist_ok=True) + + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(path=extract_root) + + candidate_dirs = [p for p in extract_root.rglob("module-package.yaml") if p.is_file()] + if not candidate_dirs: + raise ValueError("Downloaded module archive does not contain module-package.yaml") + + extracted_manifest = candidate_dirs[0] + extracted_module_dir = extracted_manifest.parent + + metadata = yaml.safe_load(extracted_manifest.read_text(encoding="utf-8")) + if not isinstance(metadata, dict): + raise ValueError("Invalid module manifest format") + + compatibility = str(metadata.get("core_compatibility", "")).strip() + if compatibility and Version(cli_version) not in SpecifierSet(compatibility): + raise ValueError("Module is incompatible with current SpecFact CLI version") + + staged_path = target_root / f".{module_name}.tmp-install" + if staged_path.exists(): + shutil.rmtree(staged_path) + shutil.copytree(extracted_module_dir, staged_path) + + try: + staged_path.replace(final_path) + except Exception: + if staged_path.exists(): + shutil.rmtree(staged_path) + raise + + logger.info("Installed marketplace module '%s' to '%s'", module_id, final_path) + return final_path + + +@beartype +@require(lambda module_name: module_name.strip() != "", "module_name must be non-empty") +def uninstall_module( + module_name: str, + *, + install_root: Path | None = None, + source_map: dict[str, str] | None = None, +) -> None: + """Uninstall a marketplace module from the local marketplace root.""" + logger = get_bridge_logger(__name__) + target_root = install_root or MARKETPLACE_MODULES_ROOT + + if source_map is None: + from specfact_cli.registry.module_discovery import discover_all_modules + + source_map = {entry.metadata.name: entry.source for entry in discover_all_modules()} + + source = source_map.get(module_name) + if source == "builtin": + raise ValueError("Cannot uninstall built-in module") + if source != "marketplace": + raise ValueError(f"Cannot uninstall module from source '{source or 'unknown'}'") + + module_path = target_root / module_name + if module_path.exists(): + shutil.rmtree(module_path) + logger.info("Uninstalled marketplace module '%s'", module_name) diff --git a/src/specfact_cli/registry/module_lifecycle.py b/src/specfact_cli/registry/module_lifecycle.py new file mode 100644 index 00000000..33c9f9a8 --- /dev/null +++ b/src/specfact_cli/registry/module_lifecycle.py @@ -0,0 +1,184 @@ +"""Shared module lifecycle operations for init and module commands.""" + +from __future__ import annotations + +from typing import Any + +from beartype import beartype +from rich.console import Console +from rich.table import Table + +from specfact_cli import __version__ +from specfact_cli.registry.help_cache import run_discovery_and_write_cache +from specfact_cli.registry.module_discovery import discover_all_modules +from specfact_cli.registry.module_packages import ( + discover_all_package_metadata, + expand_disable_with_dependents, + expand_enable_with_dependencies, + get_discovered_modules_for_state, + merge_module_state, + validate_disable_safe, + validate_enable_safe, +) +from specfact_cli.registry.module_state import read_modules_state, write_modules_state + + +def _sort_modules_by_id(modules_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return modules sorted alphabetically by module id (case-insensitive).""" + return sorted(modules_list, key=lambda module: str(module.get("id", "")).lower()) + + +@beartype +def get_modules_with_state( + enable_ids: list[str] | None = None, + disable_ids: list[str] | None = None, +) -> list[dict[str, Any]]: + """Return discovered modules with version, enabled state, source, and trust metadata.""" + modules_list = get_discovered_modules_for_state(enable_ids=enable_ids or [], disable_ids=disable_ids or []) + discovered = discover_all_modules() + source_map = {entry.metadata.name: entry.source for entry in discovered} + official_map = { + entry.metadata.name: bool( + entry.metadata.publisher and entry.metadata.publisher.name.strip().lower() == "nold-ai" + ) + for entry in discovered + } + publisher_map = { + entry.metadata.name: entry.metadata.publisher.name if entry.metadata.publisher else "unknown" + for entry in discovered + } + for item in modules_list: + module_id = str(item.get("id", "")) + item["source"] = source_map.get(module_id, "unknown") + item["official"] = official_map.get(module_id, False) + item["publisher"] = publisher_map.get(module_id, "unknown") + return modules_list + + +@beartype +def apply_module_state_update(*, enable_ids: list[str], disable_ids: list[str], force: bool) -> list[dict[str, Any]]: + """Apply lifecycle updates with dependency safety and return resulting module state.""" + packages = discover_all_package_metadata() + discovered_list = [(meta.name, meta.version) for _package_dir, meta in packages] + state = read_modules_state() + if force and enable_ids: + enable_ids = expand_enable_with_dependencies(enable_ids, packages) + enabled_map = merge_module_state(discovered_list, state, enable_ids, []) + if enable_ids and not force: + blocked_enable = validate_enable_safe(enable_ids, packages, enabled_map) + if blocked_enable: + lines: list[str] = [] + for module_id, missing in blocked_enable.items(): + lines.append(f"Cannot enable '{module_id}': missing required dependencies: {', '.join(missing)}") + raise ValueError("\n".join(lines)) + if disable_ids: + if force: + disable_ids = expand_disable_with_dependents(disable_ids, packages, enabled_map) + blocked_disable = validate_disable_safe(disable_ids, packages, enabled_map) + if blocked_disable and not force: + lines = [] + for module_id, dependents in blocked_disable.items(): + lines.append(f"Cannot disable '{module_id}': required by enabled modules: {', '.join(dependents)}") + raise ValueError("\n".join(lines)) + modules_list = get_discovered_modules_for_state(enable_ids=enable_ids, disable_ids=disable_ids) + write_modules_state(modules_list) + run_discovery_and_write_cache(__version__) + return get_modules_with_state() + + +def _questionary_style() -> Any: + """Return a shared questionary color theme for interactive selectors.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError: + return None + return questionary.Style( + [ + ("qmark", "fg:#00af87 bold"), + ("question", "bold"), + ("answer", "fg:#00af87 bold"), + ("pointer", "fg:#5f87ff bold"), + ("highlighted", "fg:#5f87ff bold"), + ("selected", "fg:#00af87 bold"), + ("instruction", "fg:#808080 italic"), + ("separator", "fg:#808080"), + ("text", ""), + ("disabled", "fg:#6c6c6c"), + ] + ) + + +@beartype +def render_modules_table(console: Console, modules_list: list[dict[str, Any]], show_origin: bool = False) -> None: + """Render module table with id, version, state, trust, publisher, and optional origin.""" + table = Table(title="Installed Modules") + table.add_column("Module", style="cyan") + table.add_column("Version", style="magenta") + table.add_column("State", style="green") + table.add_column("Trust", style="yellow") + table.add_column("Publisher", style="bright_blue") + if show_origin: + table.add_column("Origin", style="blue") + for module in _sort_modules_by_id(modules_list): + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + enabled = bool(module.get("enabled", True)) + state = "enabled" if enabled else "disabled" + source = str(module.get("source", "unknown")) + if bool(module.get("official", False)): + trust_label = "official" + elif source == "marketplace": + trust_label = "community" + else: + trust_label = "local-dev" + + publisher_label = str(module.get("publisher", "unknown")) + origin_label = "built-in" if source == "builtin" else source + if show_origin: + table.add_row(module_id, version, state, trust_label, publisher_label, origin_label) + else: + table.add_row(module_id, version, state, trust_label, publisher_label) + console.print(table) + + +@beartype +def select_module_ids_interactive(action: str, modules_list: list[dict[str, Any]], console: Console) -> list[str]: + """Select module ids interactively for enable/disable operations.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as exc: + console.print( + "[red]Interactive module selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise RuntimeError("questionary is required for interactive module selection") from exc + target_enabled = action == "disable" + candidates = [m for m in _sort_modules_by_id(modules_list) if bool(m.get("enabled", True)) is target_enabled] + if not candidates: + console.print(f"[yellow]No modules available to {action}.[/yellow]") + return [] + action_title = "Enable" if action == "enable" else "Disable" + current_state = "disabled" if action == "enable" else "enabled" + console.print() + console.print(f"[cyan]{action_title} Modules[/cyan] (currently {current_state})") + console.print("[dim]Controls: arrows navigate, space toggle, enter confirm[/dim]") + display_to_id: dict[str, str] = {} + choices: list[str] = [] + for module in candidates: + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + source = str(module.get("source", "unknown")) + source_label = "official" if bool(module.get("official", False)) else source + state = "enabled" if bool(module.get("enabled", True)) else "disabled" + marker = "✓" if state == "enabled" else "✗" + display = f"{marker} {module_id:<18} [{state}] v{version} ({source_label})" + display_to_id[display] = module_id + choices.append(display) + selected: list[str] | None = questionary.checkbox( + f"{action_title} module(s):", + choices=choices, + instruction="(multi-select)", + style=_questionary_style(), + ).ask() + if not selected: + return [] + return [display_to_id[item] for item in selected if item in display_to_id] diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 73433c17..f151c182 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -123,24 +123,11 @@ def _add_root(path: Path) -> None: @beartype def discover_all_package_metadata() -> list[tuple[Path, ModulePackageMetadata]]: - """Discover module package metadata across all configured roots.""" - discovered: list[tuple[Path, ModulePackageMetadata]] = [] - seen_names: set[str] = set() - logger = get_bridge_logger(__name__) - - for modules_root in get_modules_roots(): - for package_dir, meta in discover_package_metadata(modules_root): - if meta.name in seen_names: - logger.warning( - "Duplicate module package name '%s' found at '%s'; keeping first occurrence.", - meta.name, - package_dir, - ) - continue - seen_names.add(meta.name) - discovered.append((package_dir, meta)) + """Discover module package metadata across built-in/marketplace/custom roots.""" + from specfact_cli.registry.module_discovery import discover_all_modules - return discovered + discovered = discover_all_modules() + return [(entry.package_dir, entry.metadata) for entry in discovered] def _package_sort_key(item: tuple[Path, ModulePackageMetadata]) -> tuple[int, str]: @@ -154,7 +141,7 @@ def _package_sort_key(item: tuple[Path, ModulePackageMetadata]) -> tuple[int, st @beartype -def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePackageMetadata]]: +def discover_package_metadata(modules_root: Path, source: str = "builtin") -> list[tuple[Path, ModulePackageMetadata]]: """ Scan modules root for package dirs that have module-package.yaml; parse and return (dir, metadata). """ @@ -255,6 +242,9 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack pip_dependencies_versioned=pip_deps_versioned, service_bridges=validated_service_bridges, schema_extensions=validated_schema_extensions, + description=str(raw["description"]) if raw.get("description") else None, + license=str(raw["license"]) if raw.get("license") else None, + source=source, ) result.append((child, meta)) except Exception: @@ -915,7 +905,7 @@ def register_module_package_commands( cmd_name, ) CommandRegistry._typer_cache.pop(cmd_name, None) - logger.info("Module %s extended command group '%s'.", meta.name, cmd_name) + logger.debug("Module %s extended command group '%s'.", meta.name, cmd_name) continue help_str = (meta.command_help or {}).get(cmd_name) or f"Module package: {meta.name}" loader = _make_package_loader(package_dir, meta.name, cmd_name) diff --git a/tests/unit/modules/module_registry/test_commands.py b/tests/unit/modules/module_registry/test_commands.py new file mode 100644 index 00000000..b2c58a24 --- /dev/null +++ b/tests/unit/modules/module_registry/test_commands.py @@ -0,0 +1,694 @@ +"""Tests for module marketplace CLI commands.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from specfact_cli.modules.module_registry.src.commands import app + + +runner = CliRunner() + + +def test_install_command_integration(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_module", + lambda module_id, version=None: tmp_path / module_id.split("/")[-1], + ) + + result = runner.invoke(app, ["install", "specfact/backlog"]) + + assert result.exit_code == 0 + assert "Installed" in result.stdout + assert "specfact/backlog" in result.stdout + + +def test_install_command_accepts_bare_module_name(monkeypatch, tmp_path: Path) -> None: + captured = {"module_id": None} + + def _install(module_id: str, version=None): + captured["module_id"] = module_id + return tmp_path / module_id.split("/")[-1] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) + + result = runner.invoke(app, ["install", "bundle-mapper"]) + + assert result.exit_code == 0 + assert captured["module_id"] == "specfact/bundle-mapper" + assert "Installed" in result.stdout + + +def test_install_command_rejects_invalid_module_id(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_module", lambda *_args, **_kwargs: None + ) + + result = runner.invoke(app, ["install", "a/b/c"]) + + assert result.exit_code == 1 + assert "Invalid module id" in result.stdout + + +def test_install_command_skips_when_module_already_available_locally(monkeypatch, tmp_path: Path) -> None: + class _Meta: + name = "bundle-mapper" + + class _Entry: + metadata = _Meta() + source = "custom" + + called = {"install": False} + + def _install(module_id: str, version=None): + called["install"] = True + return tmp_path / module_id.split("/")[-1] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) + + result = runner.invoke(app, ["install", "bundle-mapper"]) + + assert result.exit_code == 0 + assert called["install"] is False + assert "already available" in result.stdout + + +def test_uninstall_command_with_source_validation(monkeypatch) -> None: + called = {"ok": False} + + class _Meta: + name = "backlog" + + class _Entry: + metadata = _Meta() + source = "marketplace" + + def fake_uninstall(module_name: str, **_kwargs) -> None: + called["ok"] = module_name == "backlog" + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.uninstall_module", fake_uninstall) + + result = runner.invoke(app, ["uninstall", "backlog"]) + + assert result.exit_code == 0 + assert called["ok"] is True + + +def test_uninstall_command_custom_module_has_clear_guidance(monkeypatch) -> None: + class _Meta: + name = "bundle-mapper" + + class _Entry: + metadata = _Meta() + source = "custom" + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + result = runner.invoke(app, ["uninstall", "bundle-mapper"]) + + assert result.exit_code == 1 + assert "Cannot uninstall custom module 'bundle-mapper'" in result.stdout + assert "local module roots" in result.stdout + + +def test_uninstall_command_namespace_input_normalizes_name(monkeypatch) -> None: + class _Meta: + name = "bundle-mapper" + + class _Entry: + metadata = _Meta() + source = "custom" + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + result = runner.invoke(app, ["uninstall", "specfact/bundle-mapper"]) + + assert result.exit_code == 1 + assert "Cannot uninstall custom module 'bundle-mapper'" in result.stdout + + +def test_uninstall_command_unknown_module_has_clear_guidance(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) + + result = runner.invoke(app, ["uninstall", "specfact/missing-module"]) + + assert result.exit_code == 1 + assert "is not installed from marketplace" in result.stdout + assert "module list --show-origin" in result.stdout + + +def test_search_command_filters_registry(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.fetch_registry_index", + lambda: { + "schema_version": "1.0.0", + "modules": [ + { + "id": "specfact/backlog", + "description": "Backlog workflows", + "latest_version": "0.1.0", + "tags": ["backlog", "scrum"], + }, + { + "id": "specfact/policy", + "description": "Policy engine", + "latest_version": "0.1.0", + "tags": ["governance"], + }, + ], + }, + ) + + result = runner.invoke(app, ["search", "backlog"]) + + assert result.exit_code == 0 + assert "Module Search Results" in result.stdout + assert "specfact/backlog" in result.stdout + assert "marketplace" in result.stdout + assert "specfact/policy" not in result.stdout + + +def test_search_command_finds_installed_module_when_not_in_registry(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.fetch_registry_index", lambda: {"modules": []} + ) + + class _Meta: + name = "bundle-mapper" + version = "0.1.0" + description = "Maps backlog items to modules" + publisher = None + + class _Entry: + metadata = _Meta() + source = "custom" + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + result = runner.invoke(app, ["search", "bundle-mapper"]) + + assert result.exit_code == 0 + assert "Module Search Results" in result.stdout + assert "bundle-mapper" in result.stdout + assert "installed" in result.stdout + + +def test_search_command_reports_no_results_with_query_context(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.fetch_registry_index", lambda: {"modules": []} + ) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) + + result = runner.invoke(app, ["search", "does-not-exist"]) + + assert result.exit_code == 0 + assert "No modules found for query 'does-not-exist'" in result.stdout + + +def test_search_command_sorts_results_alphabetically(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.fetch_registry_index", + lambda: { + "schema_version": "1.0.0", + "modules": [ + {"id": "specfact/zeta", "description": "Zeta module", "latest_version": "0.1.0", "tags": ["bundle"]}, + {"id": "specfact/alpha", "description": "Alpha module", "latest_version": "0.1.0", "tags": ["bundle"]}, + ], + }, + ) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) + + result = runner.invoke(app, ["search", "module"]) + + assert result.exit_code == 0 + assert result.stdout.index("specfact/alpha") < result.stdout.index("specfact/zeta") + + +def test_list_command_sorts_modules_alphabetically(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "zeta", + "version": "0.1.0", + "enabled": True, + "source": "builtin", + "official": True, + "publisher": "nold-ai", + }, + { + "id": "alpha", + "version": "0.1.0", + "enabled": True, + "source": "builtin", + "official": True, + "publisher": "nold-ai", + }, + ], + ) + + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert result.stdout.index("alpha") < result.stdout.index("zeta") + + +def test_enable_command_message_sorts_module_ids(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + {"id": "alpha", "version": "0.1.0", "enabled": False, "source": "marketplace"}, + {"id": "zeta", "version": "0.1.0", "enabled": False, "source": "marketplace"}, + ], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.select_module_ids_interactive", + lambda *_args, **_kwargs: ["zeta", "alpha"], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.apply_module_state_update", + lambda **_kwargs: [], + ) + + result = runner.invoke(app, ["enable"]) + + assert result.exit_code == 0 + assert "Enabled" in result.stdout + assert "alpha, zeta" in result.stdout + + +def test_list_command_shows_version_state_and_trust(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "init", + "version": "0.1.0", + "enabled": True, + "source": "builtin", + "official": True, + "publisher": "nold-ai", + }, + { + "id": "backlog", + "version": "0.2.0", + "enabled": False, + "source": "marketplace", + "official": False, + "publisher": "community-dev", + }, + ], + ) + + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "Trust" in result.stdout + assert "Publisher" in result.stdout + assert "init" in result.stdout + assert "0.1.0" in result.stdout + assert "enabled" in result.stdout + assert "official" in result.stdout + assert "backlog" in result.stdout + assert "disabled" in result.stdout + assert "community" in result.stdout + assert "nold-ai" in result.stdout + assert "community-dev" in result.stdout + + +def test_list_command_shows_official_label_when_marked(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "bundle-mapper", + "version": "0.1.0", + "enabled": True, + "source": "custom", + "official": True, + "publisher": "nold-ai", + } + ], + ) + + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "official" in result.stdout + assert "custom" not in result.stdout + + +def test_list_command_show_origin_includes_origin_column(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "bundle-mapper", + "version": "0.1.0", + "enabled": True, + "source": "custom", + "official": True, + "publisher": "nold-ai", + }, + { + "id": "community-module", + "version": "0.1.0", + "enabled": True, + "source": "marketplace", + "official": False, + "publisher": "community-dev", + }, + ], + ) + + result = runner.invoke(app, ["list", "--show-origin"]) + + assert result.exit_code == 0 + assert "Origin" in result.stdout + assert "official" in result.stdout + assert "community" in result.stdout + assert "nold-ai" in result.stdout + assert "community-dev" in result.stdout + assert "custom" in result.stdout + assert "marketplace" in result.stdout + + +def test_list_command_source_filter(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + {"id": "init", "version": "0.1.0", "enabled": True, "source": "builtin", "publisher": "nold-ai"}, + { + "id": "backlog", + "version": "0.2.0", + "enabled": False, + "source": "marketplace", + "publisher": "community-dev", + }, + ], + ) + + result = runner.invoke(app, ["list", "--source", "marketplace"]) + + assert result.exit_code == 0 + assert "backlog" in result.stdout + assert "init" not in result.stdout + + +def test_show_command_displays_module_details(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "bundle-mapper", + "version": "0.1.0", + "enabled": True, + "source": "custom", + "official": True, + "publisher": "nold-ai", + } + ], + ) + + class _Meta: + name = "bundle-mapper" + description = "Maps backlog items to modules using confidence heuristics" + license = "Apache-2.0" + tier = "community" + commands = ["backlog"] + core_compatibility = ">=0.28.0,<1.0.0" + + class publisher: # noqa: N801 + attributes = {"url": "https://github.com/nold-ai/specfact-cli-modules"} + + class _Entry: + metadata = _Meta() + + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.discover_all_modules", + lambda: [_Entry()], + ) + + result = runner.invoke(app, ["show", "bundle-mapper"]) + + assert result.exit_code == 0 + assert "Module Details: bundle-mapper" in result.stdout + assert "Description" in result.stdout + assert "Apache-2.0" in result.stdout + assert "Publisher" in result.stdout + assert "nold-ai" in result.stdout + assert "Trust" in result.stdout + assert "official" in result.stdout + + +def test_show_command_uses_command_help_keys_when_commands_missing(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "module-registry", + "version": "0.35.0", + "enabled": True, + "source": "builtin", + "official": True, + "publisher": "nold-ai", + } + ], + ) + + class _Meta: + name = "module-registry" + description = "Manage modules" + license = "Apache-2.0" + tier = "community" + commands = [] + command_help = {"list": "List modules", "show": "Show module details"} + core_compatibility = ">=0.28.0,<1.0.0" + + class publisher: # noqa: N801 + attributes = {"url": "https://github.com/nold-ai/specfact-cli-modules"} + + class _Entry: + metadata = _Meta() + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + result = runner.invoke(app, ["show", "module-registry"]) + + assert result.exit_code == 0 + assert "Commands" in result.stdout + assert "list" in result.stdout + assert "show" in result.stdout + + +def test_show_command_derives_full_command_paths_with_subcommands(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "module-registry", + "version": "0.35.0", + "enabled": True, + "source": "builtin", + "official": True, + "publisher": "nold-ai", + } + ], + ) + + class _Meta: + name = "module-registry" + description = "Manage modules" + license = "Apache-2.0" + tier = "community" + commands = ["module"] + command_help = {"module": "Manage modules"} + core_compatibility = ">=0.28.0,<1.0.0" + + class publisher: # noqa: N801 + attributes = {"url": "https://github.com/nold-ai/specfact-cli-modules"} + + class _Entry: + metadata = _Meta() + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + class _CmdInfo: + def __init__(self, name: str, help_text: str) -> None: + self.name = name + self.help = help_text + self.callback = None + + class _GroupInfo: + def __init__(self, name: str, typer_instance: object) -> None: + self.name = name + self.typer_instance = typer_instance + + class _FakeTyper: + def __init__(self, commands: list[tuple[str, str]], groups: list[object]) -> None: + self.registered_commands = [_CmdInfo(name, help_text) for name, help_text in commands] + self.registered_groups = groups + + delta_app = _FakeTyper([("status", "Show delta status")], []) + root_app = _FakeTyper([("list", "List modules"), ("show", "Show module details")], [_GroupInfo("delta", delta_app)]) + + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.CommandRegistry.get_typer", lambda _name: root_app + ) + + result = runner.invoke(app, ["show", "module-registry"]) + + assert result.exit_code == 0 + assert "module list - List modules" in result.stdout + assert "module show - Show module details" in result.stdout + assert "module delta" in result.stdout + assert "module delta status - Show delta status" in result.stdout + + +def test_show_command_fails_for_unknown_module(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.get_modules_with_state", list) + + result = runner.invoke(app, ["show", "missing-module"]) + + assert result.exit_code == 1 + assert "is not installed" in result.stdout + + +def test_upgrade_command(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_module", + lambda module_id, version=None: tmp_path / module_id.split("/")[-1], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [{"id": "backlog", "version": "0.2.0", "enabled": True, "source": "marketplace"}], + ) + + result = runner.invoke(app, ["upgrade", "backlog"]) + + assert result.exit_code == 0 + assert "Upgraded" in result.stdout + + +def test_upgrade_without_module_name_upgrades_all_marketplace(monkeypatch, tmp_path: Path) -> None: + installed: list[str] = [] + + def _install(module_id: str, version=None): + installed.append(module_id) + return tmp_path / module_id.split("/")[-1] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + {"id": "backlog", "version": "0.2.0", "enabled": True, "source": "marketplace"}, + {"id": "init", "version": "0.1.0", "enabled": True, "source": "builtin", "publisher": "nold-ai"}, + ], + ) + + result = runner.invoke(app, ["upgrade"]) + + assert result.exit_code == 0 + assert installed == ["specfact/backlog"] + assert "Upgraded" in result.stdout + + +def test_upgrade_rejects_non_marketplace_source(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [{"id": "bundle-mapper", "version": "0.1.0", "enabled": True, "source": "custom"}], + ) + + result = runner.invoke(app, ["upgrade", "bundle-mapper"]) + + assert result.exit_code == 1 + assert "marketplace modules" in result.stdout and "upgradeable" in result.stdout + + +def test_enable_command_updates_state_with_dependency_checks(monkeypatch) -> None: + captured = {"enable_ids": None, "disable_ids": None, "force": None} + + def _apply(*, enable_ids, disable_ids, force): + captured["enable_ids"] = enable_ids + captured["disable_ids"] = disable_ids + captured["force"] = force + return [] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.apply_module_state_update", _apply) + + result = runner.invoke(app, ["enable", "backlog"]) + + assert result.exit_code == 0 + assert captured["enable_ids"] == ["backlog"] + assert captured["disable_ids"] == [] + assert captured["force"] is False + assert "Enabled" in result.stdout + + +def test_disable_command_respects_force_cascade(monkeypatch) -> None: + captured = {"enable_ids": None, "disable_ids": None, "force": None} + + def _apply(*, enable_ids, disable_ids, force): + captured["enable_ids"] = enable_ids + captured["disable_ids"] = disable_ids + captured["force"] = force + return [] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.apply_module_state_update", _apply) + + result = runner.invoke(app, ["disable", "sync", "--force"]) + + assert result.exit_code == 0 + assert captured["enable_ids"] == [] + assert captured["disable_ids"] == ["sync"] + assert captured["force"] is True + assert "Disabled" in result.stdout + + +def test_enable_command_interactive_mode_selection(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", + lambda: [ + { + "id": "backlog", + "version": "0.2.0", + "enabled": False, + "source": "marketplace", + "publisher": "community-dev", + } + ], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.select_module_ids_interactive", + lambda *_args, **_kwargs: ["backlog"], + ) + + captured = {"enable_ids": None} + + def _apply(*, enable_ids, disable_ids, force): + captured["enable_ids"] = enable_ids + return [] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.apply_module_state_update", _apply) + + result = runner.invoke(app, ["enable"]) + + assert result.exit_code == 0 + assert captured["enable_ids"] == ["backlog"] + + +def test_disable_command_non_interactive_requires_module_id(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: True) + + result = runner.invoke(app, ["disable"]) + + assert result.exit_code == 1 + assert "Non-interactive mode requires explicit module id value" in result.stdout diff --git a/tests/unit/registry/test_marketplace_client.py b/tests/unit/registry/test_marketplace_client.py new file mode 100644 index 00000000..d454143e --- /dev/null +++ b/tests/unit/registry/test_marketplace_client.py @@ -0,0 +1,120 @@ +"""Tests for marketplace registry client.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from specfact_cli.registry.marketplace_client import SecurityError, download_module, fetch_registry_index + + +class _DummyResponse: + def __init__(self, status_code: int = 200, text: str = "", content: bytes = b"") -> None: + self.status_code = status_code + self.text = text + self.content = content + + def json(self) -> dict: + return json.loads(self.text) + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"http {self.status_code}") + + +def test_fetch_registry_index_parses_valid_json(monkeypatch) -> None: + payload = {"schema_version": "1.0.0", "modules": []} + + def fake_get(*_args, **_kwargs): + return _DummyResponse(status_code=200, text=json.dumps(payload)) + + monkeypatch.setattr("specfact_cli.registry.marketplace_client.requests.get", fake_get) + + index = fetch_registry_index() + assert index is not None + assert index["schema_version"] == "1.0.0" + + +def test_fetch_registry_index_network_unavailable_returns_none(monkeypatch) -> None: + def fake_get(*_args, **_kwargs): + raise OSError("network down") + + monkeypatch.setattr("specfact_cli.registry.marketplace_client.requests.get", fake_get) + + assert fetch_registry_index() is None + + +def test_fetch_registry_index_invalid_json_raises_value_error(monkeypatch) -> None: + def fake_get(*_args, **_kwargs): + return _DummyResponse(status_code=200, text="{invalid") + + monkeypatch.setattr("specfact_cli.registry.marketplace_client.requests.get", fake_get) + + with pytest.raises(ValueError, match="Invalid registry index format"): + fetch_registry_index() + + +def test_download_module_downloads_tarball_and_verifies_checksum(monkeypatch, tmp_path: Path) -> None: + module_bytes = b"module-tarball-bytes" + checksum = hashlib.sha256(module_bytes).hexdigest() + index = { + "schema_version": "1.0.0", + "modules": [ + { + "id": "specfact/backlog", + "namespace": "specfact", + "name": "backlog", + "description": "Backlog module", + "latest_version": "0.1.0", + "core_compatibility": ">=0.1.0,<1.0.0", + "download_url": "https://example.test/specfact-backlog-0.1.0.tar.gz", + "checksum_sha256": checksum, + } + ], + } + + monkeypatch.setattr( + "specfact_cli.registry.marketplace_client.fetch_registry_index", lambda *_args, **_kwargs: index + ) + + def fake_get(*_args, **_kwargs): + return _DummyResponse(status_code=200, content=module_bytes) + + monkeypatch.setattr("specfact_cli.registry.marketplace_client.requests.get", fake_get) + + tarball_path = download_module("specfact/backlog", download_dir=tmp_path) + assert tarball_path.exists() + assert tarball_path.read_bytes() == module_bytes + + +def test_download_module_checksum_mismatch_raises_security_error(monkeypatch, tmp_path: Path) -> None: + index = { + "schema_version": "1.0.0", + "modules": [ + { + "id": "specfact/backlog", + "namespace": "specfact", + "name": "backlog", + "description": "Backlog module", + "latest_version": "0.1.0", + "core_compatibility": ">=0.1.0,<1.0.0", + "download_url": "https://example.test/specfact-backlog-0.1.0.tar.gz", + "checksum_sha256": "0" * 64, + } + ], + } + + monkeypatch.setattr( + "specfact_cli.registry.marketplace_client.fetch_registry_index", lambda *_args, **_kwargs: index + ) + + def fake_get(*_args, **_kwargs): + return _DummyResponse(status_code=200, content=b"tampered") + + monkeypatch.setattr("specfact_cli.registry.marketplace_client.requests.get", fake_get) + + with pytest.raises(SecurityError, match="Checksum mismatch"): + download_module("specfact/backlog", download_dir=tmp_path) diff --git a/tests/unit/registry/test_module_discovery.py b/tests/unit/registry/test_module_discovery.py new file mode 100644 index 00000000..0a06700d --- /dev/null +++ b/tests/unit/registry/test_module_discovery.py @@ -0,0 +1,72 @@ +"""Tests for multi-location module discovery.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.registry.module_discovery import discover_all_modules + + +def _write_manifest(root: Path, module_name: str) -> None: + module_dir = root / module_name + module_dir.mkdir(parents=True, exist_ok=True) + (module_dir / "module-package.yaml").write_text( + f"name: {module_name}\nversion: '0.1.0'\ncommands: [{module_name}]\n", + encoding="utf-8", + ) + (module_dir / "src").mkdir(parents=True, exist_ok=True) + + +def test_discover_all_modules_scans_builtin_marketplace_and_custom(tmp_path: Path) -> None: + """Discovery should scan all available roots.""" + builtin_root = tmp_path / "builtin" + marketplace_root = tmp_path / "marketplace" + custom_root = tmp_path / "custom" + _write_manifest(builtin_root, "init") + _write_manifest(marketplace_root, "backlog") + _write_manifest(custom_root, "drift") + + discovered = discover_all_modules( + builtin_root=builtin_root, + marketplace_root=marketplace_root, + custom_root=custom_root, + ) + + names = {entry.metadata.name for entry in discovered} + assert names == {"init", "backlog", "drift"} + sources = {entry.metadata.name: entry.source for entry in discovered} + assert sources["init"] == "builtin" + assert sources["backlog"] == "marketplace" + assert sources["drift"] == "custom" + + +def test_discover_all_modules_builtin_takes_priority(tmp_path: Path) -> None: + """Built-in module should shadow marketplace/custom duplicates.""" + builtin_root = tmp_path / "builtin" + marketplace_root = tmp_path / "marketplace" + _write_manifest(builtin_root, "backlog") + _write_manifest(marketplace_root, "backlog") + + discovered = discover_all_modules( + builtin_root=builtin_root, + marketplace_root=marketplace_root, + ) + + backlog_entries = [entry for entry in discovered if entry.metadata.name == "backlog"] + assert len(backlog_entries) == 1 + assert backlog_entries[0].source == "builtin" + + +def test_discover_all_modules_handles_missing_optional_paths(tmp_path: Path) -> None: + """Missing marketplace/custom roots should not raise.""" + builtin_root = tmp_path / "builtin" + _write_manifest(builtin_root, "init") + + discovered = discover_all_modules( + builtin_root=builtin_root, + marketplace_root=tmp_path / "missing-marketplace", + custom_root=tmp_path / "missing-custom", + ) + + assert [entry.metadata.name for entry in discovered] == ["init"] + assert discovered[0].source == "builtin" diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py new file mode 100644 index 00000000..93f8101f --- /dev/null +++ b/tests/unit/registry/test_module_installer.py @@ -0,0 +1,87 @@ +"""Tests for marketplace module installer workflows.""" + +from __future__ import annotations + +import tarfile +from pathlib import Path + +import pytest + +from specfact_cli.registry.module_installer import install_module, uninstall_module + + +def _create_module_tarball(tmp_path: Path, module_name: str, core_compatibility: str = ">=0.1.0,<1.0.0") -> Path: + package_root = tmp_path / f"{module_name}-pkg" + module_dir = package_root / module_name + module_dir.mkdir(parents=True, exist_ok=True) + (module_dir / "module-package.yaml").write_text( + f"name: {module_name}\n" + "version: '0.1.0'\n" + f"commands: [{module_name}]\n" + f'core_compatibility: "{core_compatibility}"\n', + encoding="utf-8", + ) + (module_dir / "src").mkdir(parents=True, exist_ok=True) + + tarball = tmp_path / f"{module_name}.tar.gz" + with tarfile.open(tarball, "w:gz") as archive: + archive.add(module_dir, arcname=module_name) + return tarball + + +def test_install_module_downloads_extracts_and_registers(monkeypatch, tmp_path: Path) -> None: + tarball = _create_module_tarball(tmp_path, "backlog") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + install_root = tmp_path / "marketplace-modules" + installed = install_module("specfact/backlog", install_root=install_root) + + assert installed.exists() + assert (installed / "module-package.yaml").exists() + assert installed.parent == install_root + + +def test_install_module_to_default_marketplace_path(monkeypatch, tmp_path: Path) -> None: + tarball = _create_module_tarball(tmp_path, "drift") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + install_root = tmp_path / "marketplace-modules" + installed = install_module("specfact/drift", install_root=install_root) + assert installed == install_root / "drift" + + +def test_install_module_already_installed_returns_existing(monkeypatch, tmp_path: Path) -> None: + tarball = _create_module_tarball(tmp_path, "sync") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + install_root = tmp_path / "marketplace-modules" + first_install = install_module("specfact/sync", install_root=install_root) + second_install = install_module("specfact/sync", install_root=install_root) + + assert first_install == second_install + + +def test_uninstall_module_removes_marketplace_module(tmp_path: Path) -> None: + install_root = tmp_path / "marketplace-modules" + module_dir = install_root / "backlog" + module_dir.mkdir(parents=True, exist_ok=True) + (module_dir / "module-package.yaml").write_text( + "name: backlog\nversion: '0.1.0'\ncommands: [backlog]\n", encoding="utf-8" + ) + + uninstall_module("backlog", install_root=install_root, source_map={"backlog": "marketplace"}) + assert not module_dir.exists() + + +def test_uninstall_builtin_module_raises_error(tmp_path: Path) -> None: + install_root = tmp_path / "marketplace-modules" + with pytest.raises(ValueError, match="Cannot uninstall built-in module"): + uninstall_module("init", install_root=install_root, source_map={"init": "builtin"}) + + +def test_install_module_validates_core_compatibility(monkeypatch, tmp_path: Path) -> None: + tarball = _create_module_tarball(tmp_path, "policy", core_compatibility=">=9.0.0") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + with pytest.raises(ValueError, match="incompatible with current SpecFact CLI version"): + install_module("specfact/policy", install_root=tmp_path / "marketplace-modules") diff --git a/tests/unit/specfact_cli/registry/test_command_registry.py b/tests/unit/specfact_cli/registry/test_command_registry.py index 2cdb140d..904dd379 100644 --- a/tests/unit/specfact_cli/registry/test_command_registry.py +++ b/tests/unit/specfact_cli/registry/test_command_registry.py @@ -170,3 +170,17 @@ def test_cli_backlog_help_exits_zero(): timeout=60, ) assert result.returncode == 0, (result.stdout, result.stderr) + + +def test_cli_module_help_exits_zero(): + """specfact module --help exits 0.""" + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-m", "specfact_cli", "module", "--help"], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, (result.stdout, result.stderr) diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py index e7a3a0c0..c0297196 100644 --- a/tests/unit/specfact_cli/test_module_migration_compatibility.py +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -54,8 +54,11 @@ def test_module_app_entrypoints_import_module_local_commands() -> None: continue expected_import = f"from specfact_cli.modules.{module_name}.src.commands import app" + expected_multiline_prefix = f"from specfact_cli.modules.{module_name}.src.commands import (" text = app_path.read_text(encoding="utf-8") - if expected_import not in text: + has_single_line = expected_import in text + has_multiline = expected_multiline_prefix in text and "app," in text + if not (has_single_line or has_multiline): wrong_import.append(str(app_path.relative_to(PROJECT_ROOT))) assert not missing, "Missing module app entrypoint files:\n" + "\n".join(f"- {path}" for path in missing) @@ -153,3 +156,59 @@ def test_module_discovery_registers_commands_from_manifests(tmp_path: Path, monk missing = sorted(expected_commands - registered) assert not missing, "Missing commands after registry bootstrap:\n" + "\n".join(f"- {cmd}" for cmd in missing) + + +def test_builtin_module_manifest_versions_match_cli_version() -> None: + """Built-in module manifests under src/specfact_cli/modules SHALL stay version-synced with CLI.""" + from specfact_cli import __version__ + + mismatches: list[str] = [] + for module_name in _module_package_names(): + manifest = MODULES_ROOT / module_name / "module-package.yaml" + if not manifest.exists(): + continue + version_line = None + for line in manifest.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("version:"): + version_line = line.split(":", 1)[1].strip().strip("\"'") + break + if version_line is None: + mismatches.append(f"{module_name}: missing version field") + continue + if version_line != __version__: + mismatches.append(f"{module_name}: {version_line} != {__version__}") + + assert not mismatches, "Built-in module version drift detected:\n" + "\n".join(f"- {item}" for item in mismatches) + + +def test_module_manifest_descriptions_are_meaningful() -> None: + """All module manifests SHOULD include concise non-placeholder descriptions.""" + project_root = Path(__file__).resolve().parents[3] + manifest_paths = sorted(project_root.glob("**/module-package.yaml")) + + issues: list[str] = [] + for manifest in manifest_paths: + lines = manifest.read_text(encoding="utf-8").splitlines() + description = "" + for line in lines: + stripped = line.strip() + if stripped.startswith("description:"): + description = stripped.split(":", 1)[1].strip() + if (description.startswith('"') and description.endswith('"')) or ( + description.startswith("'") and description.endswith("'") + ): + description = description[1:-1] + break + + if not description: + issues.append(f"{manifest.relative_to(project_root)}: missing description") + continue + + if description.lower().startswith("specfact module '"): + issues.append(f"{manifest.relative_to(project_root)}: placeholder description") + continue + + if len(description) < 20 or len(description) > 120: + issues.append(f"{manifest.relative_to(project_root)}: description length out of range") + + assert not issues, "Module manifest description quality issues:\n" + "\n".join(f"- {item}" for item in issues) From b8f5c8a09011ec31d02d1cc108f0f22f204bc727 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sat, 21 Feb 2026 01:10:56 +0100 Subject: [PATCH 2/3] fix: respect explicit discovery roots in module tests Disable implicit legacy/workspace roots when explicit roots are passed to module discovery so isolated test roots are honored and deterministic. Co-authored-by: Cursor --- src/specfact_cli/registry/module_discovery.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/specfact_cli/registry/module_discovery.py b/src/specfact_cli/registry/module_discovery.py index 1be8fac7..38863bee 100644 --- a/src/specfact_cli/registry/module_discovery.py +++ b/src/specfact_cli/registry/module_discovery.py @@ -31,6 +31,7 @@ def discover_all_modules( builtin_root: Path | None = None, marketplace_root: Path | None = None, custom_root: Path | None = None, + include_legacy_roots: bool | None = None, ) -> list[DiscoveredModule]: """Discover modules from all configured locations with deterministic priority.""" from specfact_cli.registry.module_packages import discover_package_metadata, get_modules_root, get_modules_roots @@ -50,13 +51,18 @@ def discover_all_modules( ] # Keep legacy discovery roots (workspace-level + SPECFACT_MODULES_ROOTS) as custom sources. - seen_root_paths = {path.resolve() for _source, path in roots} - for extra_root in get_modules_roots(): - resolved = extra_root.resolve() - if resolved in seen_root_paths: - continue - seen_root_paths.add(resolved) - roots.append(("custom", extra_root)) + # When explicit roots are provided (usually tests), legacy roots are disabled by default. + if include_legacy_roots is None: + include_legacy_roots = builtin_root is None and marketplace_root is None and custom_root is None + + if include_legacy_roots: + seen_root_paths = {path.resolve() for _source, path in roots} + for extra_root in get_modules_roots(): + resolved = extra_root.resolve() + if resolved in seen_root_paths: + continue + seen_root_paths.add(resolved) + roots.append(("custom", extra_root)) for source, root in roots: if not root.exists() or not root.is_dir(): From d8e8b86e41ddc5fd59b69587a29c573bb1d6c03b Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sat, 21 Feb 2026 01:27:07 +0100 Subject: [PATCH 3/3] fix: enforce safe module extraction and upgrade reinstall --- .../TDD_EVIDENCE.md | 10 ++++ .../specs/module-installation/spec.md | 14 ++++++ .../modules/module_registry/src/commands.py | 2 +- src/specfact_cli/registry/module_installer.py | 21 ++++++++- .../modules/module_registry/test_commands.py | 16 +++++-- tests/unit/registry/test_module_installer.py | 46 ++++++++++++++++++- 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md b/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md index e25c04c0..61ac9b92 100644 --- a/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md +++ b/openspec/changes/marketplace-01-central-module-registry/TDD_EVIDENCE.md @@ -18,6 +18,12 @@ - Command: `hatch test -- tests/unit/modules/module/test_commands.py -v` - Result: failed during collection with `ModuleNotFoundError: specfact_cli.modules.module`. +- 2026-02-21 01:23:06 +0100 + - Command: `hatch test -- tests/unit/registry/test_module_installer.py -v` + - Result: 2 failures before implementation updates: + - `test_install_module_replaces_existing_module_on_reinstall` remained on version `0.1.0` because installer returned early for existing installs. + - `test_install_module_rejects_archive_path_traversal` did not raise; unsafe archive member `../outside.txt` was extracted. + ## Post-implementation passing runs - 2026-02-20 23:08:58 +0100 @@ -35,3 +41,7 @@ - 2026-02-20 23:12:38 +0100 - Command: `hatch test -- tests/unit/registry/test_module_discovery.py tests/unit/registry/test_marketplace_client.py tests/unit/registry/test_module_installer.py tests/unit/modules/module/test_commands.py -v` - Result: 20 tests passed. + +- 2026-02-21 01:24:00 +0100 + - Command: `hatch test -- tests/unit/registry/test_module_installer.py tests/unit/modules/module_registry/test_commands.py -v` + - Result: 37 tests passed, including new coverage for reinstall-on-upgrade and archive path traversal rejection. diff --git a/openspec/changes/marketplace-01-central-module-registry/specs/module-installation/spec.md b/openspec/changes/marketplace-01-central-module-registry/specs/module-installation/spec.md index 6632a4ad..2ed1d362 100644 --- a/openspec/changes/marketplace-01-central-module-registry/specs/module-installation/spec.md +++ b/openspec/changes/marketplace-01-central-module-registry/specs/module-installation/spec.md @@ -78,3 +78,17 @@ The system SHALL provide `specfact module upgrade ` command that up - **AND** SHALL check if newer version available - **AND** SHALL download and install newer version - **AND** SHALL remove old version after successful install + +#### Scenario: Upgrade reinstalls when module already exists +- **WHEN** user runs `specfact module upgrade backlog` and backlog is already installed +- **THEN** system SHALL replace existing installed files with the upgraded package +- **AND** SHALL NOT no-op due to existing install marker files + +### Requirement: Installation extraction is path-safe + +The system SHALL reject archive members that escape the intended extraction root. + +#### Scenario: Installer blocks path traversal entries +- **WHEN** a downloaded marketplace tarball contains absolute paths or `..` traversal +- **THEN** install SHALL fail before extraction +- **AND** SHALL raise a validation error indicating unsafe archive content diff --git a/src/specfact_cli/modules/module_registry/src/commands.py b/src/specfact_cli/modules/module_registry/src/commands.py index 7c2de3ee..71c08a81 100644 --- a/src/specfact_cli/modules/module_registry/src/commands.py +++ b/src/specfact_cli/modules/module_registry/src/commands.py @@ -432,7 +432,7 @@ def upgrade( for target in target_ids: try: module_id = target if "/" in target else f"specfact/{target}" - install_module(module_id) + install_module(module_id, reinstall=True) upgraded.append(module_id) except Exception as exc: console.print(f"[red]Failed upgrading {target}: {exc}[/red]") diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index 4d23e5e6..d4083332 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -23,6 +23,18 @@ MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" +@beartype +def _validate_archive_members(members: list[tarfile.TarInfo], extract_root: Path) -> None: + """Reject tar members that would escape the intended extraction directory.""" + extract_root_resolved = extract_root.resolve() + for member in members: + member_path = (extract_root / member.name).resolve() + if member_path == extract_root_resolved: + continue + if extract_root_resolved not in member_path.parents: + raise ValueError(f"Downloaded module archive contains unsafe archive path: {member.name}") + + @beartype def verify_module_artifact( package_dir: Path, @@ -66,6 +78,7 @@ def install_module( module_id: str, *, version: str | None = None, + reinstall: bool = False, install_root: Path | None = None, ) -> Path: """Install a marketplace module from tarball into marketplace modules root.""" @@ -77,7 +90,7 @@ def install_module( final_path = target_root / module_name manifest_path = final_path / "module-package.yaml" - if manifest_path.exists(): + if manifest_path.exists() and not reinstall: logger.info("Module already installed (%s)", module_name) return final_path @@ -89,7 +102,9 @@ def install_module( extract_root.mkdir(parents=True, exist_ok=True) with tarfile.open(archive_path, "r:gz") as archive: - archive.extractall(path=extract_root) + members = archive.getmembers() + _validate_archive_members(members, extract_root) + archive.extractall(path=extract_root, members=members) candidate_dirs = [p for p in extract_root.rglob("module-package.yaml") if p.is_file()] if not candidate_dirs: @@ -112,6 +127,8 @@ def install_module( shutil.copytree(extracted_module_dir, staged_path) try: + if final_path.exists(): + shutil.rmtree(final_path) staged_path.replace(final_path) except Exception: if staged_path.exists(): diff --git a/tests/unit/modules/module_registry/test_commands.py b/tests/unit/modules/module_registry/test_commands.py index b2c58a24..44d812b5 100644 --- a/tests/unit/modules/module_registry/test_commands.py +++ b/tests/unit/modules/module_registry/test_commands.py @@ -27,7 +27,7 @@ def test_install_command_integration(monkeypatch, tmp_path: Path) -> None: def test_install_command_accepts_bare_module_name(monkeypatch, tmp_path: Path) -> None: - captured = {"module_id": None} + captured: dict[str, str | None] = {"module_id": None} def _install(module_id: str, version=None): captured["module_id"] = module_id @@ -562,9 +562,15 @@ def test_show_command_fails_for_unknown_module(monkeypatch) -> None: def test_upgrade_command(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, bool | None] = {"reinstall": None} + + def _install(module_id: str, version=None, reinstall: bool = False): + captured["reinstall"] = reinstall + return tmp_path / module_id.split("/")[-1] + monkeypatch.setattr( "specfact_cli.modules.module_registry.src.commands.install_module", - lambda module_id, version=None: tmp_path / module_id.split("/")[-1], + _install, ) monkeypatch.setattr( "specfact_cli.modules.module_registry.src.commands.get_modules_with_state", @@ -574,14 +580,17 @@ def test_upgrade_command(monkeypatch, tmp_path: Path) -> None: result = runner.invoke(app, ["upgrade", "backlog"]) assert result.exit_code == 0 + assert captured["reinstall"] is True assert "Upgraded" in result.stdout def test_upgrade_without_module_name_upgrades_all_marketplace(monkeypatch, tmp_path: Path) -> None: installed: list[str] = [] + reinstall_flags: list[bool] = [] - def _install(module_id: str, version=None): + def _install(module_id: str, version=None, reinstall: bool = False): installed.append(module_id) + reinstall_flags.append(reinstall) return tmp_path / module_id.split("/")[-1] monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) @@ -597,6 +606,7 @@ def _install(module_id: str, version=None): assert result.exit_code == 0 assert installed == ["specfact/backlog"] + assert reinstall_flags == [True] assert "Upgraded" in result.stdout diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index 93f8101f..6ff4b5c9 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import tarfile from pathlib import Path @@ -10,13 +11,18 @@ from specfact_cli.registry.module_installer import install_module, uninstall_module -def _create_module_tarball(tmp_path: Path, module_name: str, core_compatibility: str = ">=0.1.0,<1.0.0") -> Path: +def _create_module_tarball( + tmp_path: Path, + module_name: str, + core_compatibility: str = ">=0.1.0,<1.0.0", + module_version: str = "0.1.0", +) -> Path: package_root = tmp_path / f"{module_name}-pkg" module_dir = package_root / module_name module_dir.mkdir(parents=True, exist_ok=True) (module_dir / "module-package.yaml").write_text( f"name: {module_name}\n" - "version: '0.1.0'\n" + f"version: '{module_version}'\n" f"commands: [{module_name}]\n" f'core_compatibility: "{core_compatibility}"\n', encoding="utf-8", @@ -61,6 +67,42 @@ def test_install_module_already_installed_returns_existing(monkeypatch, tmp_path assert first_install == second_install +def test_install_module_replaces_existing_module_on_reinstall(monkeypatch, tmp_path: Path) -> None: + first_tarball = _create_module_tarball(tmp_path, "sync", module_version="0.1.0") + second_tarball = _create_module_tarball(tmp_path, "sync-v2", module_version="0.2.0") + + def _download(_module_id: str, version: str | None = None) -> Path: + return second_tarball if version == "0.2.0" else first_tarball + + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", _download) + + install_root = tmp_path / "marketplace-modules" + install_module("specfact/sync", install_root=install_root, version="0.1.0") + install_module("specfact/sync", install_root=install_root, version="0.2.0", reinstall=True) + + manifest = (install_root / "sync" / "module-package.yaml").read_text(encoding="utf-8") + assert "version: '0.2.0'" in manifest + + +def test_install_module_rejects_archive_path_traversal(monkeypatch, tmp_path: Path) -> None: + tarball = tmp_path / "unsafe.tar.gz" + with tarfile.open(tarball, "w:gz") as archive: + manifest_bytes = b"name: policy\nversion: '0.1.0'\ncommands: [policy]\ncore_compatibility: \">=0.1.0,<1.0.0\"\n" + manifest_info = tarfile.TarInfo(name="policy/module-package.yaml") + manifest_info.size = len(manifest_bytes) + archive.addfile(manifest_info, io.BytesIO(manifest_bytes)) + + traversal_bytes = b"owned" + traversal_info = tarfile.TarInfo(name="../outside.txt") + traversal_info.size = len(traversal_bytes) + archive.addfile(traversal_info, io.BytesIO(traversal_bytes)) + + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + with pytest.raises(ValueError, match="unsafe archive"): + install_module("specfact/policy", install_root=tmp_path / "marketplace-modules") + + def test_uninstall_module_removes_marketplace_module(tmp_path: Path) -> None: install_root = tmp_path / "marketplace-modules" module_dir = install_root / "backlog"