From 43043056d77e7f3dbd507a1bbea3741dfb3c8ed2 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Feb 2026 00:01:15 +0100 Subject: [PATCH 1/3] fix: harden module lifecycle bootstrap and signing workflows --- .github/workflows/pr-orchestrator.yml | 60 +- .github/workflows/sign-modules.yml | 99 ++- AGENTS.md | 19 + CHANGELOG.md | 34 ++ docs/README.md | 2 +- docs/_layouts/default.html | 2 + docs/getting-started/README.md | 1 + .../module-bootstrap-checklist.md | 90 +++ docs/guides/README.md | 1 + docs/guides/installing-modules.md | 36 +- docs/guides/module-marketplace.md | 42 +- .../guides/module-signing-and-key-rotation.md | 131 ++++ docs/index.md | 13 +- docs/reference/README.md | 4 +- docs/reference/commands.md | 78 +-- docs/reference/directory-structure.md | 19 +- docs/reference/module-security.md | 44 +- modules/backlog-core/module-package.yaml | 5 +- modules/bundle-mapper/module-package.yaml | 7 +- openspec/CHANGE_ORDER.md | 4 +- .../CHANGE_VALIDATION.md | 6 + .../TDD_EVIDENCE.md | 125 ++++ .../design.md | 95 +++ .../proposal.md | 53 ++ .../specs/prompt-resource-sync/spec.md | 19 + .../specs/user-module-root/spec.md | 213 +++++++ .../tasks.md | 79 +++ openspec/config.yaml | 7 + pyproject.toml | 5 +- resources/keys/README.md | 14 + resources/keys/module-signing-public.pem | 3 + scripts/pre-commit-smart-checks.sh | 0 scripts/run_actionlint.sh | 0 scripts/sign-module.sh | 120 +++- scripts/sign-modules.py | 296 +++++++++ scripts/verify-modules-signature.py | 276 +++++++++ scripts/yaml-tools.sh | 0 setup.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/cli.py | 28 - src/specfact_cli/models/module_package.py | 6 +- .../modules/analyze/module-package.yaml | 7 +- .../modules/auth/module-package.yaml | 7 +- .../modules/backlog/module-package.yaml | 31 +- .../modules/contract/module-package.yaml | 7 +- .../modules/drift/module-package.yaml | 7 +- .../modules/enforce/module-package.yaml | 9 +- .../modules/generate/module-package.yaml | 9 +- .../modules/import_cmd/module-package.yaml | 7 +- .../modules/init/module-package.yaml | 7 +- src/specfact_cli/modules/init/src/commands.py | 567 +----------------- .../modules/migrate/module-package.yaml | 7 +- .../module_registry/module-package.yaml | 7 +- .../modules/module_registry/src/commands.py | 211 ++++++- .../modules/patch_mode/module-package.yaml | 10 +- .../modules/plan/module-package.yaml | 9 +- .../modules/policy_engine/module-package.yaml | 15 +- .../modules/project/module-package.yaml | 7 +- .../modules/repro/module-package.yaml | 7 +- .../modules/sdd/module-package.yaml | 7 +- .../modules/spec/module-package.yaml | 7 +- .../modules/sync/module-package.yaml | 14 +- .../modules/upgrade/module-package.yaml | 7 +- .../modules/validate/module-package.yaml | 7 +- src/specfact_cli/registry/module_discovery.py | 34 +- src/specfact_cli/registry/module_installer.py | 355 ++++++++++- src/specfact_cli/registry/module_packages.py | 28 +- src/specfact_cli/registry/module_security.py | 121 ++++ src/specfact_cli/utils/metadata.py | 8 + src/specfact_cli/utils/startup_checks.py | 69 +++ .../modules/module_registry/test_commands.py | 369 +++++++++++- tests/unit/registry/test_module_discovery.py | 45 ++ tests/unit/registry/test_module_installer.py | 145 +++++ tests/unit/registry/test_module_security.py | 63 ++ .../registry/test_init_module_lifecycle_ux.py | 253 +------- .../registry/test_module_packages.py | 21 +- .../registry/test_signing_artifacts.py | 172 +++++- tests/unit/utils/test_ide_setup.py | 13 + tests/unit/utils/test_startup_checks.py | 116 +++- 79 files changed, 3777 insertions(+), 1048 deletions(-) create mode 100644 docs/getting-started/module-bootstrap-checklist.md create mode 100644 docs/guides/module-signing-and-key-rotation.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/design.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/specs/prompt-resource-sync/spec.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md create mode 100644 openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md create mode 100644 resources/keys/README.md create mode 100644 resources/keys/module-signing-public.pem mode change 100644 => 100755 scripts/pre-commit-smart-checks.sh mode change 100644 => 100755 scripts/run_actionlint.sh mode change 100644 => 100755 scripts/sign-module.sh create mode 100755 scripts/sign-modules.py create mode 100755 scripts/verify-modules-signature.py mode change 100644 => 100755 scripts/yaml-tools.sh create mode 100644 src/specfact_cli/registry/module_security.py create mode 100644 tests/unit/registry/test_module_security.py diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index 83b5486e..bc90d542 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -38,6 +38,8 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + with: + fetch-depth: 0 - uses: dorny/paths-filter@v3 id: filter with: @@ -64,9 +66,42 @@ jobs: echo "skip_tests_dev_to_main=false" >> "$GITHUB_OUTPUT" fi + verify-module-signatures: + name: Verify Module Signatures + needs: [changes] + if: needs.changes.outputs.code_changed == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install verifier dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml cryptography cffi + + - name: Verify bundled module checksums and signatures + run: | + BASE_REF="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + fi + if [ -n "$BASE_REF" ]; then + python scripts/verify-modules-signature.py --require-signature --enforce-version-bump --version-check-base "$BASE_REF" + else + python scripts/verify-modules-signature.py --require-signature --enforce-version-bump + fi + tests: name: Tests (Python 3.12) - needs: [changes] + needs: [changes, verify-module-signatures] if: needs.changes.outputs.code_changed == 'true' outputs: run_unit_coverage: ${{ steps.detect-unit.outputs.run_unit_coverage }} @@ -583,6 +618,29 @@ jobs: run: | chmod +x .github/workflows/scripts/generate-release-notes.sh chmod +x .github/workflows/scripts/create-github-release.sh + chmod +x scripts/sign-module.sh + + - name: Sign bundled module manifests (release hardening) + env: + SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} + SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} + run: | + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then + echo "❌ Missing required secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY" + exit 1 + fi + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE}" ]; then + echo "❌ Missing required secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" + exit 1 + fi + python -m pip install --upgrade pip + python -m pip install pyyaml cryptography cffi + mapfile -t MANIFESTS < <(find src/specfact_cli/modules -name 'module-package.yaml' -type f) + if [ "${#MANIFESTS[@]}" -eq 0 ]; then + echo "No bundled module manifests found to sign." + exit 0 + fi + python scripts/sign-modules.py "${MANIFESTS[@]}" - name: Get version from PyPI publish step id: get_version diff --git a/.github/workflows/sign-modules.yml b/.github/workflows/sign-modules.yml index 9c2c65a9..c58b34f1 100644 --- a/.github/workflows/sign-modules.yml +++ b/.github/workflows/sign-modules.yml @@ -1,29 +1,102 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# Sign module manifests for integrity (arch-06). Outputs checksums for manifest integrity fields. -name: Sign Modules +# Harden module signing by enforcing strict verification and deterministic signing output checks. +name: Module Signature Hardening on: workflow_dispatch: {} push: - branches: [main] + branches: [dev, main] paths: - - "src/specfact_cli/modules/**/module-package.yaml" - - "modules/**/module-package.yaml" + - "src/specfact_cli/modules/**" + - "modules/**" + - "resources/keys/**" + - "scripts/sign-modules.py" + - "scripts/verify-modules-signature.py" + - ".github/workflows/sign-modules.yml" + pull_request: + branches: [dev, main] + paths: + - "src/specfact_cli/modules/**" + - "modules/**" + - "resources/keys/**" + - "scripts/sign-modules.py" + - "scripts/verify-modules-signature.py" + - ".github/workflows/sign-modules.yml" jobs: - sign: - name: Sign module manifests + verify: + name: Verify module signatures runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" - - name: Sign module manifests + - name: Install signer dependencies run: | - for f in $(find . -name 'module-package.yaml' -not -path './.git/*' 2>/dev/null | head -20); do - if [ -f "scripts/sign-module.sh" ]; then - bash scripts/sign-module.sh "$f" || true - fi - done + python -m pip install --upgrade pip + python -m pip install pyyaml cryptography cffi + + - name: Verify bundled module signatures + run: | + BASE_REF="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + fi + if [ -n "$BASE_REF" ]; then + python scripts/verify-modules-signature.py --require-signature --enforce-version-bump --version-check-base "$BASE_REF" + else + python scripts/verify-modules-signature.py --require-signature --enforce-version-bump + fi + + reproducibility: + name: Assert signing reproducibility + runs-on: ubuntu-latest + needs: [verify] + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install signer dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml cryptography cffi + + - name: Re-sign manifests and assert no diff + env: + SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} + SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} + run: | + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then + echo "::notice::Skipping reproducibility check because SPECFACT_MODULE_PRIVATE_SIGN_KEY is not configured." + exit 0 + fi + + mapfile -t MANIFESTS < <(find src/specfact_cli/modules modules -name 'module-package.yaml' -type f 2>/dev/null | sort) + if [ "${#MANIFESTS[@]}" -eq 0 ]; then + echo "No module manifests found" + exit 0 + fi + + python scripts/sign-modules.py "${MANIFESTS[@]}" + + if ! git diff --exit-code -- src/specfact_cli/modules modules; then + echo "::error::Module signatures are stale for the configured signing key. Re-sign and commit manifest updates." + git --no-pager diff --name-only -- src/specfact_cli/modules modules + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index 5ce6784e..c70710cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,25 @@ Run all steps in order before committing. Every step must pass with no errors. 5. `hatch run contract-test` # contract-first validation 6. `hatch run smart-test` # targeted test run (use `smart-test-full` for larger modifications) +### Module Signature Gate (Required for Change Finalization) + +Before PR creation, every change MUST pass bundled module signature verification: + +1. Run `hatch run ./scripts/verify-modules-signature.py --require-signature`. +2. If verification fails because module contents changed, re-sign affected manifests: + - `hatch run python scripts/sign-modules.py --key-file ` +3. Re-run verification until green. + +Rules: + +- Do not merge/PR with stale or missing integrity metadata for bundled modules. +- Treat signature verification as a quality gate equal to lint/type-check/tests. +- Module version bump is mandatory before signing changed module contents. Do not keep the same module version when module files or signatures change. +- For any module re-sign/sign operation, increment module version using semver (major/minor/patch) so published/registered versions are immutable. +- Use signer/verifier enforcement paths: + - signer rejects changed modules with unchanged version by default; + - verifier/CI enforces version-bump checks for changed manifests. + ### OpenSpec Workflow Before modifying application code, **always** verify that an active OpenSpec change in `openspec/changes/` **explicitly covers the requested modification**. This is the spec-driven workflow defined in `openspec/config.yaml`. Skip only when the user explicitly says `"skip openspec"` or `"implement without openspec change"`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 49314edd..bc28bc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ 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.37.0] - 2026-02-23 + +### Added + +- Bundled module signing/verification now covers full module payload contents (all files in module directory), not only manifest fields. +- `scripts/sign-module.sh` / `scripts/sign-modules.py` now support encrypted private keys with passphrase input via `--passphrase`, `--passphrase-stdin`, or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE`. +- CI signing/verification workflow wiring now uses dedicated secrets `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE`. +- Signature verification tooling now supports module-version policy checks (`--enforce-version-bump`, `--version-check-base`) to prevent re-signing changed contents under unchanged versions. + +### Changed + +- `specfact init` output now explicitly points users to `specfact module` for module lifecycle commands. +- `specfact module install` / `uninstall` now support explicit scope targeting (`user` or `project`) with `--repo` for project scope. +- `specfact module install` command/help now documents and supports bundled-source resolution controls so users can install shipped modules selectively through the same lifecycle flow as marketplace installs. + +### Fixed + +- `specfact init` now seeds shipped module artifacts into `~/.specfact/modules`, so commands contributed by shipped modules (for example `specfact backlog add`) no longer depend on repository-local `modules/` folders. +- Module installer/discovery now recognizes `~/.specfact/modules` as a canonical per-user root while remaining backward-compatible with legacy module roots. +- Workspace-local module discovery is now restricted to `/.specfact/modules` (not `/modules`), preventing accidental ownership of arbitrary repository folders. +- In repository context, project modules from `/.specfact/modules` now take precedence over user modules from `~/.specfact/modules`. +- Added `specfact module init --scope project [--repo PATH]` so bundled modules can be seeded per-project, while default `specfact module init` continues to seed user scope. +- Startup checks now include bundled-module freshness guidance on CLI version change and at most once per 24 hours, with actionable commands for project and user scopes. +- Removed deprecated `specfact init` lifecycle flags (`--list-modules`, `--enable-module`, `--disable-module`) so module lifecycle management lives only under `specfact module`. +- Added `specfact module list --show-bundled-available` to display bundled modules that are available locally but not yet installed, with user/project scope install hints. +- `specfact module install` now resolves bundled modules before marketplace fallback, enabling subset install of shipped bundles. +- `specfact module uninstall` now blocks ambiguous removals when module IDs exist in both user and project roots unless `--scope` is explicitly selected. +- Module integrity runtime checks now avoid transient runtime artifacts (for example Python cache files) so installed modules do not fail trust checks due to local generated files. +- Uninstall now correctly resolves legacy marketplace install roots when applicable, preventing false-success uninstall outcomes during upgrades. + --- ## [0.36.1] - 2026-02-23 @@ -111,6 +143,8 @@ All notable changes to this project will be documented in this file. - `docs/index.md` - Simplified top-level `README.md` by removing deep architecture implementation details and linking technical readers to architecture docs. +### Fixed + --- ## [0.33.0] - 2026-02-17 diff --git a/docs/README.md b/docs/README.md index e74e2c7b..40cf0372 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,7 +81,6 @@ SpecFact CLI uses a lifecycle-managed module system: - `specfact init` bootstraps local state. - `specfact init ide` handles IDE prompt/template installation and updates. - `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 marketplace-driven module lifecycle and future community module distribution. @@ -106,6 +105,7 @@ For implementation details, see: - [Module Contracts](reference/module-contracts.md) - [Installing Modules](guides/installing-modules.md) - [Module Marketplace](guides/module-marketplace.md) +- [Module Signing and Key Rotation](guides/module-signing-and-key-rotation.md) --- diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index ab1aa639..8e422432 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -133,6 +133,7 @@

@@ -148,6 +149,7 @@

  • Extending ProjectBundle
  • Installing Modules
  • Module Marketplace
  • +
  • Module Signing and Key Rotation
  • Using Module Security and Extensions
  • Working With Existing Code
  • Existing Code Journey
  • diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index e14f8f8c..ef4c006f 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -51,6 +51,7 @@ uvx specfact-cli@latest plan init my-project --interactive - 📖 **[Installation Guide](installation.md)** - Install SpecFact CLI - 📖 **[First Steps](first-steps.md)** - Step-by-step first commands +- 📖 **[Module Bootstrap Checklist](module-bootstrap-checklist.md)** - Verify bundled modules are installed in user/project scope - 📖 **[Tutorial: Using SpecFact with OpenSpec or Spec-Kit](tutorial-openspec-speckit.md)** ⭐ **NEW** - Complete beginner-friendly tutorial - 📖 **[DevOps Backlog Integration](../guides/devops-adapter-integration.md)** 🆕 **NEW FEATURE** - Integrate SpecFact into agile DevOps workflows - 📖 **[Backlog Refinement](../guides/backlog-refinement.md)** 🆕 **NEW FEATURE** - AI-assisted template-driven refinement for standardizing work items diff --git a/docs/getting-started/module-bootstrap-checklist.md b/docs/getting-started/module-bootstrap-checklist.md new file mode 100644 index 00000000..f3735a5c --- /dev/null +++ b/docs/getting-started/module-bootstrap-checklist.md @@ -0,0 +1,90 @@ +--- +layout: default +title: Module Bootstrap Checklist +permalink: /getting-started/module-bootstrap-checklist/ +description: Quick checklist to verify bundled modules are installed and discoverable in user/project scope. +--- + +# Module Bootstrap Checklist + +Use this checklist right after installing or upgrading SpecFact CLI to ensure bundled modules are installed and discoverable. +Use plain `specfact ...` commands below (not `hatch run specfact ...`) so the steps work for pipx, pip, uv tool installs, and packaged environments. + +## 1. Initialize Bundled Modules + +### User scope (default) + +```bash +specfact module init +``` + +This seeds bundled modules into `~/.specfact/modules`. + +### Project scope (optional) + +```bash +specfact module init --scope project --repo . +``` + +This seeds bundled modules into `/.specfact/modules`. + +Use project scope when modules should apply only to a specific codebase/customer repository. + +## 2. Verify Installed Modules + +```bash +specfact module list +``` + +If bundled modules are still available but not installed, you'll see a hint to run: + +```bash +specfact module list --show-bundled-available +``` + +## 3. Inspect Bundled But Not Installed Modules + +```bash +specfact module list --show-bundled-available +``` + +This prints a separate bundled table plus install guidance. + +## 4. Install Specific Modules Only (Optional) + +Install from bundled sources only: + +```bash +specfact module install backlog-core --source bundled +``` + +Install from marketplace only: + +```bash +specfact module install specfact/backlog --source marketplace +``` + +Install with automatic source resolution (`bundled` first, then marketplace): + +```bash +specfact module install backlog +``` + +## 5. Scope-Safe Uninstall + +```bash +specfact module uninstall backlog-core --scope user +# or +specfact module uninstall backlog-core --scope project --repo . +``` + +If the same module exists in both user and project scope, SpecFact requires explicit `--scope` to prevent accidental removal. + +## 6. Startup Freshness Guidance + +SpecFact performs bundled module freshness checks: + +- on first run after a CLI version change +- otherwise at most once per 24 hours + +When modules are missing/outdated, startup output suggests exact refresh commands for project and/or user scope. diff --git a/docs/guides/README.md b/docs/guides/README.md index d2c887bb..c44a33a4 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -29,6 +29,7 @@ Practical guides for using SpecFact CLI effectively. - **[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 +- **[Module Signing and Key Rotation](module-signing-and-key-rotation.md)** - Public key placement, signing workflow, CI verification, rotation, and revocation runbook - **[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 index e16bbca3..fe8b46db 100644 --- a/docs/guides/installing-modules.md +++ b/docs/guides/installing-modules.md @@ -8,6 +8,7 @@ description: Install, list, show, enable, disable, uninstall, and upgrade SpecFa # Installing Modules Use the `specfact module` command group to manage marketplace and locally discovered modules. +Use plain `specfact ...` commands in this guide (not `hatch run specfact ...`) so steps work across pipx, pip, uv tool installs, and packaged runtimes. ## Install Behavior @@ -18,21 +19,51 @@ specfact module install specfact/backlog # Bare names are accepted and normalized to specfact/ specfact module install backlog +# Install into project scope instead of user scope +specfact module install backlog --scope project --repo /path/to/repo + +# Force bundled-only or marketplace-only source resolution +specfact module install backlog --source bundled +specfact module install backlog --source marketplace +specfact module install backlog --source marketplace --trust-non-official + # Install a specific version specfact module install specfact/backlog --version 0.35.0 ``` Notes: +- Install defaults to user scope (`~/.specfact/modules`); use `--scope project` for `/.specfact/modules`. +- Install source defaults to `auto` (bundled first, then marketplace fallback). +- Use `--source bundled` or `--source marketplace` for explicit source selection. +- Use `--trust-non-official` when running non-interactive installs for community/non-official publishers. - 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). +## Security and Trust Controls + +- Denylist file: `~/.specfact/module-denylist.txt` +- Override path: `SPECFACT_MODULE_DENYLIST_FILE=/path/to/denylist.txt` +- Denylisted module ids are blocked in both `specfact module install` and `specfact module init`. + +Publisher trust: + +- Official publisher (`nold-ai`) proceeds without prompt. +- Non-official publishers require one-time trust acknowledgement. +- In non-interactive mode, pass `--trust-non-official` (or set `SPECFACT_TRUST_NON_OFFICIAL=1`). + +Bundled integrity: + +- `specfact module init` and bundled installs verify bundled module integrity metadata before copying. +- For developer workflows, unsigned bundles can be temporarily allowed with `SPECFACT_ALLOW_UNSIGNED=1`. + ## List Modules ```bash specfact module list specfact module list --show-origin specfact module list --source marketplace +specfact module list --show-bundled-available ``` Default columns: @@ -86,14 +117,15 @@ Use `--force` to allow dependency-aware cascades when required. ```bash specfact module uninstall backlog specfact module uninstall specfact/backlog +specfact module uninstall backlog --scope project --repo /path/to/repo ``` -Uninstall only removes marketplace-installed modules. +Uninstall supports user and project scope roots. Clear guidance is provided for: - `built-in` modules (disable instead of uninstall) -- `custom` modules (remove from local module roots) +- collisions where a module exists in both user and project roots (explicit `--scope` required) - unknown/untracked modules (`module list --show-origin`) ## Upgrade Behavior diff --git a/docs/guides/module-marketplace.md b/docs/guides/module-marketplace.md index 9f5841e6..060bcbd1 100644 --- a/docs/guides/module-marketplace.md +++ b/docs/guides/module-marketplace.md @@ -20,9 +20,9 @@ SpecFact supports centralized marketplace distribution with local multi-source d 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`) +2. `project` modules (`/.specfact/modules`) +3. `user` modules (`~/.specfact/modules`) +4. legacy/custom roots (`~/.specfact/marketplace-modules`, `~/.specfact/custom-modules`, `SPECFACT_MODULES_ROOTS`) If module names collide, higher-priority sources win and lower-priority entries are shadowed. @@ -47,15 +47,45 @@ Install workflow enforces integrity and compatibility checks: 2. Download module archive 3. Validate SHA-256 checksum 4. Validate module `core_compatibility` against current CLI version -5. Install into `~/.specfact/marketplace-modules/` +5. Install into selected scope root (`~/.specfact/modules` or `/.specfact/modules`) Checksum mismatch blocks installation. +Additional local hardening: + +- Denylist enforcement via `~/.specfact/module-denylist.txt` (or `SPECFACT_MODULE_DENYLIST_FILE`) +- One-time trust gate for non-official publishers (`--trust-non-official` for non-interactive automation) +- Bundled bootstrap/install verifies bundled integrity metadata before copy + +Release signing automation: + +- `scripts/sign-modules.py` updates manifest integrity metadata (checksum and optional signature) +- Use `python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem ` for local/manual signing +- Wrapper alternative: `bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem ` +- Without key material, the script fails by default and recommends `--key-file`; checksum-only mode is explicit via `--allow-unsigned` (local testing only) +- Encrypted keys are supported with passphrase via `--passphrase`, `--passphrase-stdin`, or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- CI workflows inject private key material via `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and passphrase via `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- Private signing keys must stay in CI secrets and never in repository history + +Public key for runtime verification: + +- Preferred bundled location (repo source): `resources/keys/module-signing-public.pem` +- Installed package location: `specfact_cli/resources/keys/module-signing-public.pem` +- Runtime checks key in this order: explicit arg -> `SPECFACT_MODULE_PUBLIC_KEY_PEM` -> bundled key file + +Scope boundary: + +- This change set hardens local and bundled module safety. +- The online multi-registry ecosystem and production marketplace rollout remain tracked in `marketplace-02`. + ## Marketplace vs Local Modules -- `specfact module install` targets marketplace modules. +- `specfact module install` supports source selection: + - `--source auto` (default): bundled-first, then marketplace fallback + - `--source bundled`: bundled sources only + - `--source marketplace`: marketplace only - 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. +- `specfact module uninstall` supports `--scope user|project` and prevents ambiguous removals when same module id exists in both scopes. ## Module Introspection diff --git a/docs/guides/module-signing-and-key-rotation.md b/docs/guides/module-signing-and-key-rotation.md new file mode 100644 index 00000000..d0fc3324 --- /dev/null +++ b/docs/guides/module-signing-and-key-rotation.md @@ -0,0 +1,131 @@ +--- +layout: default +title: Module Signing and Key Rotation +permalink: /guides/module-signing-and-key-rotation/ +description: Runbook for signing bundled modules, placing public keys, rotating keys, and revoking compromised keys. +--- + +# Module Signing and Key Rotation + +This runbook defines the repeatable process for signing bundled modules and verifying signatures in SpecFact CLI. + +## Key Placement + +Repository/public key path used by CLI verification: + +- `resources/keys/module-signing-public.pem` (repository source path) + +Runtime key resolution order: + +1. Explicit key argument (internal verifier calls) +2. `SPECFACT_MODULE_PUBLIC_KEY_PEM` +3. Bundled key file at `resources/keys/module-signing-public.pem` (source) or `specfact_cli/resources/keys/module-signing-public.pem` (installed package) + +Never store private signing keys in the repository. + +## Generate Keys + +Ed25519 (recommended): + +```bash +openssl genpkey -algorithm ED25519 -out module-signing-private.pem +openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pem +``` + +RSA 4096 (supported): + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out module-signing-private.pem +openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pem +``` + +## Sign Bundled Modules + +Preferred (strict, with private key): + +```bash +python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem src/specfact_cli/modules/*/module-package.yaml +python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem modules/*/module-package.yaml +``` + +Encrypted private key options: + +```bash +# Prompt interactively for passphrase (TTY) +python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem modules/backlog-core/module-package.yaml + +# Explicit passphrase flag (avoid shell history when possible) +python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem --passphrase '***' modules/backlog-core/module-package.yaml + +# Passphrase over stdin (CI-safe pattern) +printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \ + python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem --passphrase-stdin modules/backlog-core/module-package.yaml +``` + +Versioning guard: + +- The signer enforces module version increments for changed module contents. +- If module files changed and version is unchanged, signing fails until version is bumped. +- Override exists for exceptional local workflows: `--allow-same-version` (not recommended). + +Wrapper for single manifest: + +```bash +bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem modules/backlog-core/module-package.yaml +# stdin passphrase: +printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \ + bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem --passphrase-stdin modules/backlog-core/module-package.yaml +``` + +Local test-only unsigned mode: + +```bash +python scripts/sign-modules.py --allow-unsigned modules/backlog-core/module-package.yaml +``` + +## Verify Signatures Locally + +Strict verification (checksum + signature required): + +```bash +python scripts/verify-modules-signature.py --require-signature +``` + +With explicit public key file: + +```bash +python scripts/verify-modules-signature.py --require-signature --public-key-file resources/keys/module-signing-public.pem +``` + +## CI Enforcement + +`pr-orchestrator.yml` contains a strict gate: + +- Job: `verify-module-signatures` +- Command: `python scripts/verify-modules-signature.py --require-signature` + +This runs on PR/push for `dev` and `main` and fails the pipeline if module signatures/checksums are missing or stale. + +## Rotation Procedure + +1. Generate new keypair in secure environment. +2. Replace `resources/keys/module-signing-public.pem` with new public key. +3. Re-sign all bundled module manifests with the new private key. +4. Run verifier locally: `python scripts/verify-modules-signature.py --require-signature`. +5. Commit public key + re-signed manifests in one change. +6. Merge to `dev`, then `main` after CI passes. + +## Revocation Procedure + +If a private key is compromised: + +1. Treat all signatures from that key as untrusted. +2. Generate new keypair immediately. +3. Replace public key file in repo. +4. Re-sign all bundled modules with new private key. +5. Merge emergency fix branch and invalidate prior release artifacts operationally. + +Current limitation: + +- Runtime key-revocation list support is not yet implemented. +- Revocation is currently handled by rotating the trusted public key and re-signing all bundled manifests. diff --git a/docs/index.md b/docs/index.md index a76ddde9..6ec81ba8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,10 +46,11 @@ Recommended command entrypoints: 1. **[Installation](getting-started/installation.md)** - Get started in 60 seconds 2. **[First Steps](getting-started/first-steps.md)** - Run your first command -3. **[Tutorial: Backlog Refine with AI IDE](getting-started/tutorial-backlog-refine-ai-ide.md)** - Integrate backlog refinement with your AI IDE (agile DevOps) -4. **[Tutorial: Daily Standup and Sprint Review](getting-started/tutorial-daily-standup-sprint-review.md)** - Daily standup view, post comments, and Copilot export (GitHub/ADO) -5. **[Working With Existing Code](guides/brownfield-engineer.md)** ⭐ **PRIMARY** - Legacy-first guide -6. **[The Existing Code Journey](guides/brownfield-journey.md)** ⭐ - Complete modernization workflow +3. **[Module Bootstrap Checklist](getting-started/module-bootstrap-checklist.md)** - Quickly verify bundled modules are installed for user/project scope +4. **[Tutorial: Backlog Refine with AI IDE](getting-started/tutorial-backlog-refine-ai-ide.md)** - Integrate backlog refinement with your AI IDE (agile DevOps) +5. **[Tutorial: Daily Standup and Sprint Review](getting-started/tutorial-daily-standup-sprint-review.md)** - Daily standup view, post comments, and Copilot export (GitHub/ADO) +6. **[Working With Existing Code](guides/brownfield-engineer.md)** ⭐ **PRIMARY** - Legacy-first guide +7. **[The Existing Code Journey](guides/brownfield-journey.md)** ⭐ - Complete modernization workflow ### Using GitHub Spec-Kit or OpenSpec? @@ -82,6 +83,7 @@ Why this matters: - **[Using Module Security and Extensions](guides/using-module-security-and-extensions.md)** - How to use verified modules (arch-06) and schema extensions (arch-07) from the CLI and as a module author - **[Extending ProjectBundle](guides/extending-projectbundle.md)** - Declare and use namespaced extension fields on Feature/ProjectBundle +- **[Module Signing and Key Rotation](guides/module-signing-and-key-rotation.md)** - Runbook for public key placement, signing, CI verification, key rotation, and emergency revocation - **[Module Security](reference/module-security.md)** - Publisher, integrity (checksum/signature), and versioned dependencies ### For Technical Readers @@ -98,8 +100,9 @@ SpecFact now supports a central marketplace workflow for module installation and - **[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 +- **[Module Signing and Key Rotation](guides/module-signing-and-key-rotation.md)** - Signing and key management runbook -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. +Module lifecycle note: use `specfact module` (`init`, `install`, `list`, `show`, `search`, `enable`, `disable`, `uninstall`, `upgrade`) for module management. ## 📚 Documentation diff --git a/docs/reference/README.md b/docs/reference/README.md index ec8f0471..80ee2926 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -39,8 +39,8 @@ 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 install [--scope user|project] [--source auto|bundled|marketplace] [--repo PATH]` - Install modules with scope and source control (bare names normalize to `specfact/`) +- `specfact module list [--source ...] [--show-origin] [--show-bundled-available]` - List modules with trust/publisher, optional origin details, and optional bundled-not-installed section - `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 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 91327636..cbf206dd 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -266,6 +266,7 @@ The CLI optimizes startup performance by: - **Template checks**: Only run when CLI version has changed since last check (stored in `~/.specfact/metadata.json`) - **Version checks**: Only run if >= 24 hours since last check (rate-limited to once per day) +- **Bundled module freshness checks**: Run on CLI version change and otherwise at most once per 24 hours; suggests `specfact module init --scope project` and/or `specfact module init` when project/user modules are missing or outdated - **Skip checks**: Use `--skip-checks` to disable all startup checks (useful for CI/CD) This ensures fast startup times (< 2 seconds) while still providing important notifications when needed. @@ -3892,8 +3893,6 @@ 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 is adapter-aware: `github_projects` for GitHub, `ado_scrum` for ADO) - `--custom-config PATH` - Optional custom mapping YAML @@ -3910,8 +3909,6 @@ 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 is adapter-aware: `github_projects` for GitHub, `ado_scrum` for ADO) - `--custom-config PATH` - Optional custom mapping YAML @@ -3926,8 +3923,6 @@ 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 is adapter-aware: `github_projects` for GitHub, `ado_scrum` for ADO) - `--target-items CSV` - Optional comma-separated subset of item IDs @@ -5078,9 +5073,7 @@ Replace `implement tasks` with the new AI IDE bridge workflow: --- -### `init` - Bootstrap and Compatibility Module Lifecycle Aliases - -Bootstrap SpecFact local state and expose compatibility aliases for legacy module lifecycle flags. +### `init` - Bootstrap Local State ```bash specfact init [OPTIONS] @@ -5088,27 +5081,8 @@ 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` - 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:** - -- Default mode is auto-detected from terminal + CI environment. -- In interactive terminals, passing `--enable-module` or `--disable-module` without ids opens an arrow-key selector. -- In non-interactive mode, module ids are required (for example in CI/CD). - -**Dependency-aware behavior:** - -- Safe disable blocks disabling a module that is required by other enabled modules. -- Safe enable blocks enabling a module when required dependencies are disabled. -- `--force` performs dependency-aware cascading: - - disable cascades to enabled dependents - - enable cascades to required dependencies +- `--install-deps` - Install contract enhancement dependencies (prefer `specfact init ide --install-deps`) **Examples:** @@ -5116,27 +5090,15 @@ specfact init [OPTIONS] # Bootstrap only (no IDE prompt/template copy) specfact init -# List lifecycle state -specfact init --list-modules - -# Interactive selection (TTY) -specfact init --enable-module -specfact init --disable-module - -# Non-interactive explicit ids -specfact --no-interactive init --enable-module backlog -specfact --no-interactive init --disable-module upgrade - -# Force dependency cascade -specfact init --enable-module sync --force -specfact init --disable-module plan --force +# Install dependencies during bootstrap +specfact init --install-deps ``` **What it does:** 1. Initializes/updates user-level registry state under `~/.specfact/registry/`. -2. Discovers installed modules and applies enable/disable operations. -3. Enforces module dependency and compatibility constraints. +2. Discovers installed modules and refreshes command help cache. +3. Prints a header note that module management moved to `specfact module`. 4. Reports IDE prompt status and points to `specfact init ide` for prompt/template setup. @@ -5150,23 +5112,35 @@ 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` +- `init [--scope user|project] [--repo PATH] [--trust-non-official]` - Seed bundled modules into user root (default) or project root under `.specfact/modules` +- `install [--scope user|project] [--source auto|bundled|marketplace] [--repo PATH] [--trust-non-official]` - Install module into user or project scope with explicit source selection +- `list [--source builtin|project|user|marketplace|custom] [--show-origin] [--show-bundled-available]` - List modules with `Trust`/`Publisher`, optional `Origin`, and optional bundled-not-installed section - `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 +- `enable [--trust-non-official]` - 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 +- `uninstall [--scope user|project] [--repo PATH]` - Uninstall module from selected scope with ambiguity protection when module exists in both scopes - `upgrade [] [--all]` - Upgrade one module or all marketplace-installed modules **Examples:** ```bash +# Seed bundled modules +specfact module init +specfact module init --scope project +specfact module init --scope project --repo /path/to/repo +specfact module init --scope project --repo /path/to/repo --trust-non-official + # Install and inspect modules specfact module install specfact/backlog specfact module install backlog +specfact module install backlog --source bundled +specfact module install backlog --source marketplace +specfact module install backlog --source marketplace --trust-non-official +specfact module install backlog --scope project --repo /path/to/repo specfact module list specfact module list --show-origin +specfact module list --show-bundled-available specfact module show module-registry # Search and manage @@ -5174,13 +5148,11 @@ specfact module search backlog specfact module enable backlog specfact module disable backlog --force specfact module uninstall specfact/backlog +specfact module uninstall specfact/backlog --scope project --repo /path/to/repo 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. +Module lifecycle and marketplace operations are available under `specfact module ...`. ### `init ide` - IDE Prompt/Template Setup diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index f2a56263..d88c10a3 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -28,11 +28,19 @@ 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 primarily by `specfact module ...` commands (`list`, `install`, `uninstall`, `upgrade`) - - `specfact init --list-modules`, `--enable-module`, and `--disable-module` remain compatibility aliases + - Managed by `specfact module ...` commands (`init`, `list`, `install`, `enable`, `disable`, `uninstall`, `upgrade`) - Supports dependency-safe lifecycle operations with optional `--force` cascading behavior -`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`. +`specfact init` is bootstrap-focused; module lifecycle is canonical under `specfact module`. IDE prompt/template setup is handled by `specfact init ide`. + +**Module artifact roots**: + +- Canonical per-user module root: `/.specfact/modules` +- Optional workspace-local module root: `/.specfact/modules` +- Module denylist file: `/.specfact/module-denylist.txt` (override with `SPECFACT_MODULE_DENYLIST_FILE`) +- Trusted non-official publisher decisions are stored in `/.specfact/metadata.json` +- SpecFact does **not** auto-discover `/modules` to avoid assuming ownership of non-`.specfact` repository paths. +- In repository context, `/.specfact/modules` has higher discovery precedence than `/.specfact/modules`. 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). @@ -457,11 +465,6 @@ specfact init specfact module list specfact module install specfact/backlog specfact module uninstall backlog - -# Compatibility aliases -specfact init --list-modules -specfact init --enable-module -specfact init --disable-module ``` ### `specfact init ide` diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index b7f347c3..bd5d218d 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -7,26 +7,54 @@ description: Trust model, checksum and signature verification, and integrity lif # Module Security -Module packages can carry **publisher** and **integrity** metadata so that installation and registration verify artifact trust before enabling a module. +Module packages carry **publisher** and **integrity** metadata so installation, bootstrap, and runtime discovery verify trust before enabling a module. ## Trust model - **Manifest metadata**: `module-package.yaml` may include `publisher` (name, email, attributes) and `integrity` (checksum, optional signature). -- **Checksum verification**: Before registration or install, the system verifies the manifest (or artifact) checksum when `integrity.checksum` is present. Supported algorithms: `sha256`, `sha384`, `sha512` in `algo:hex` format. -- **Signature verification**: If `integrity.signature` is set and trusted key material is configured, signature verification validates provenance. Without key material, only checksum is enforced and a warning is logged. -- **Unsigned modules**: Modules without `integrity` metadata are allowed (backward compatible). Set `SPECFACT_ALLOW_UNSIGNED=1` to document explicit opt-in when using strict policies. +- **Checksum verification**: Verification computes a deterministic hash of the full module payload (all module files, with manifest canonicalization that excludes `integrity` itself). Supported algorithms: `sha256`, `sha384`, `sha512` in `algo:hex` format. +- **Signature verification**: If `integrity.signature` is present and a public key is configured, signature validation proves provenance over the same full payload. +- **Publisher trust gate**: Non-official publishers require one-time explicit trust (interactive confirmation or `--trust-non-official` / `SPECFACT_TRUST_NON_OFFICIAL`). +- **Denylist gate**: Modules listed in denylist are blocked before install/bootstrap regardless of source. -## Checksum flow +## Integrity flow 1. Discovery reads `module-package.yaml` and parses `integrity.checksum`. -2. At registration time, the installer hashes the manifest content and compares it to the expected checksum. +2. At install/bootstrap/verification time, the tool hashes the full module payload and compares it to `integrity.checksum`. 3. On mismatch, the module is skipped and a security warning is logged. 4. Other modules continue to register; one failing trust does not block the rest. ## Signing automation -- **Script**: `scripts/sign-module.sh ` outputs a `sha256:` checksum suitable for the manifest `integrity.checksum` field. -- **CI**: `.github/workflows/sign-modules.yml` can run on demand or on push to `main` when module manifests change, to produce or validate checksums. +- **Script**: `scripts/sign-module.sh` signs one or more `module-package.yaml` manifests. +- **Payload scope**: Signing covers all files under the module directory (not only the manifest). +- **Encrypted key support**: Passphrase can be provided with: + - `--passphrase` (local only; avoid shell history in CI) + - `--passphrase-stdin` (recommended for secure piping) + - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **Key sources**: + - `--key-file` + - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` (PEM content) + - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE` +- **Version guard**: Changed module contents must have a bumped module version before signing. Override exists only for controlled local cases via `--allow-same-version`. +- **CI secrets**: + - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` + - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **Verification command**: + - `scripts/verify-modules-signature.py --require-signature --enforce-version-bump` + - `--version-check-base ` can be used in CI PR comparisons. + +## Public key and key rotation + +- Store trusted public key in: + - `resources/keys/module-signing-public.pem` +- Optional fallback path: + - `src/specfact_cli/resources/keys/module-signing-public.pem` +- Rotate keys by: + 1. generating a new key pair, + 2. updating trusted public key in repository, + 3. re-signing affected modules with incremented versions, + 4. running signature verification and version-bump checks in CI. ## Versioned dependencies diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml index e547ebfb..428d5486 100644 --- a/modules/backlog-core/module-package.yaml +++ b/modules/backlog-core/module-package.yaml @@ -1,7 +1,7 @@ name: backlog-core version: 0.1.0 commands: - - backlog +- backlog command_help: backlog: Backlog dependency analysis, delta workflows, and release readiness pip_dependencies: [] @@ -22,7 +22,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum_algorithm: sha256 + checksum: sha256:2f40084e130787b41e82f2f496dc80e41635eb7e095adbb3af31a074824cb70d + signature: OyR/QkRQqbdj2OG6Bf3+vmFFdbd51FRQyNMDlguMgWf20koo6pTegEn36F/RJCpXTWnRVrQKpNNr8M27iUSLBQ== 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 bb5b4886..4f304d4c 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -1,9 +1,9 @@ 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: {} @@ -19,7 +19,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum_algorithm: sha256 + checksum: sha256:526ea4f39b04537bb763cbd3bcd4912037408dfd8911ab6f051fc47ba31e348a + signature: Z5LBR5mM/+Js4o66xwoCkPbZKzJrdQXlJfjsY3VMCHVZIG7ccHsmu4PE4NAlSymiyH79toiZbYaBLCzmH48ZCQ== dependencies: [] description: Map backlog items to best-fit modules using scoring heuristics. license: Apache-2.0 diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index f5cdcce4..81241234 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -37,6 +37,7 @@ Changes are grouped by **module** and prefixed with **`-NN-`** so implem | workflow-01-git-worktree-management | implemented 2026-02-18 (archived) | | verification-01-wave1-delta-closure | implemented 2026-02-18 (archived) | | marketplace-01-central-module-registry | implemented 2026-02-22 (archived) | +| backlog-core-05-user-modules-bootstrap | implemented 2026-02-23 (pending archive) | ### Pending @@ -97,7 +98,8 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope |--------|-------|---------------|----------|------------| | backlog-core | 01 | backlog-core-01-dependency-analysis-commands ✅ (implemented 2026-02-18; archived) | [#116](https://github.com/nold-ai/specfact-cli/issues/116) | — | | backlog-core | 02 | backlog-core-02-interactive-issue-creation (implemented 2026-02-22; archived) | [#173](https://github.com/nold-ai/specfact-cli/issues/173) | #116 (optional: #176, #177) | -| backlog-core | 04 | backlog-core-04-installed-runtime-discovery-and-add-prompt | TBD | #173 | +| backlog-core | 04 | backlog-core-04-installed-runtime-discovery-and-add-prompt (implemented 2026-02-23; archived) | [#295](https://github.com/nold-ai/specfact-cli/issues/295) | #173 | +| backlog-core | 05 | backlog-core-05-user-modules-bootstrap (implemented 2026-02-23; pending archive) | [#298](https://github.com/nold-ai/specfact-cli/issues/298) | #173 | ### backlog-scrum diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md new file mode 100644 index 00000000..23f0ed4b --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md @@ -0,0 +1,6 @@ +# Change Validation Report: backlog-core-05-user-modules-bootstrap + +- Status: valid +- Validation command: `openspec validate backlog-core-05-user-modules-bootstrap --strict` +- Validation result: `Change 'backlog-core-05-user-modules-bootstrap' is valid` +- Notes: CLI validation passed; local environment emitted non-blocking telemetry network flush warnings. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md new file mode 100644 index 00000000..a91f6c5d --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md @@ -0,0 +1,125 @@ +# TDD Evidence: backlog-core-05-user-modules-bootstrap + +## Pre-implementation failing run + +- Timestamp: 2026-02-23T11:15:18Z +- Command(s): `hatch test -- tests/unit/registry/test_module_discovery.py tests/unit/registry/test_module_installer.py tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py -q` +- Failure summary: + - `tests/unit/registry/test_module_discovery.py::test_discover_all_modules_scans_user_root` failed because `USER_MODULES_ROOT` is not defined in `module_discovery`. + - `tests/unit/registry/test_module_installer.py::test_install_module_defaults_to_user_modules_root` failed because `USER_MODULES_ROOT` is not defined in `module_installer`. + - `tests/unit/modules/module_registry/test_commands.py::test_module_init_bootstraps_user_modules` failed because the module command group did not yet expose bootstrap behavior. + +## Post-implementation passing run + +- Timestamp: 2026-02-23T11:20:28Z +- Command(s): `hatch test -- tests/unit/registry/test_module_discovery.py tests/unit/registry/test_module_installer.py tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py tests/unit/utils/test_ide_setup.py -q` +- Result summary: `43 passed` (no failures). + +## Follow-up failing run (workspace root boundary hardening) + +- Timestamp: 2026-02-23T12:35:44Z +- Command(s): `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py tests/unit/registry/test_module_discovery.py -q` +- Failure summary: + - `tests/unit/specfact_cli/registry/test_module_packages.py::test_get_modules_roots_includes_workspace_dot_specfact_modules_when_present` failed because `.specfact/modules` was not included as workspace-local discovery root. + - `tests/unit/specfact_cli/registry/test_module_packages.py::test_get_modules_roots_ignores_workspace_plain_modules_directory` failed because legacy `./modules` was still auto-discovered. + - `tests/unit/registry/test_module_discovery.py::test_discover_all_modules_scans_builtin_marketplace_and_custom` and `...handles_missing_optional_paths` exposed nondeterministic user-root leakage in tests. + +## Follow-up passing run (workspace root boundary hardening) + +- Timestamp: 2026-02-23T12:36:32Z +- Command(s): `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py tests/unit/registry/test_module_discovery.py tests/unit/modules/module_registry/test_commands.py -q` +- Result summary: `66 passed, 1 skipped` (no failures). + +## Follow-up failing run (module init scope + startup freshness + precedence) + +- Timestamp: 2026-02-23T12:44:17Z +- Command(s): `hatch test -- tests/unit/modules/module_registry/test_commands.py tests/unit/registry/test_module_discovery.py tests/unit/utils/test_startup_checks.py -q` +- Failure summary: + - `test_module_init_project_scope_defaults_to_cwd_repo` and `test_module_init_project_scope_supports_explicit_repo` failed because `specfact module init` did not yet support `--scope project` / `--repo`. + - `test_discover_all_modules_project_scope_takes_priority_over_user` failed because discovery still prioritized user modules over project modules. + - `test_module_freshness_check_runs_on_version_change` and `test_startup_warns_when_project_or_user_modules_are_stale` failed because startup checks did not yet include bundled module freshness logic. + +## Follow-up passing run (module init scope + startup freshness + precedence) + +- Timestamp: 2026-02-23T12:49:51Z +- Command(s): `hatch test -- tests/unit/modules/module_registry/test_commands.py tests/unit/registry/test_module_discovery.py tests/unit/utils/test_startup_checks.py -q` +- Result summary: `70 passed` (no failures). + +## Follow-up failing run (init lifecycle flag removal + bundled availability list) + +- Timestamp: 2026-02-23T12:55:52+01:00 +- Command(s): + - `hatch test -- tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py -q` + - `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` +- Failure summary: + - `test_init_rejects_deprecated_list_modules_option`, `...enable_module_option`, and `...disable_module_option` failed because `specfact init` still accepted deprecated lifecycle flags. + - `test_init_bootstrap_only_does_not_run_ide_setup` failed because top-level `specfact init` output did not yet include the module command-group migration notice. + - `test_list_command_show_bundled_available_separate_section_with_hints` and `...empty_when_all_installed` failed because `specfact module list` did not yet support bundled-not-installed visibility. + +## Follow-up passing run (init lifecycle flag removal + bundled availability list) + +- Timestamp: 2026-02-23T13:00:49+01:00 +- Command(s): + - `hatch test -- tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py -q` + - `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` + - `hatch test -- tests/unit/specfact_cli/registry/test_command_registry.py tests/unit/specfact_cli/registry/test_init_module_state.py tests/e2e/test_init_command.py -q` +- Result summary: + - `6 passed` (`test_init_module_lifecycle_ux.py`) + - `34 passed` (`test_commands.py`) + - `32 passed` (`test_command_registry.py`, `test_init_module_state.py`, `test_init_command.py`) + +## Follow-up failing run (scoped install/uninstall consistency) + +- Timestamp: 2026-02-23T13:05:00+01:00 +- Command(s): `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` +- Failure summary: + - `test_install_command_project_scope_installs_to_project_modules_root` failed because `module install` did not yet support `--scope project` / `--repo`. + - `test_install_command_prefers_bundled_source_when_available` failed because `module install` did not resolve bundled modules prior to marketplace fallback. + - `test_uninstall_command_requires_scope_when_module_exists_in_user_and_project` failed because `module uninstall` did not yet implement scope-aware ambiguity safeguards. + +## Follow-up passing run (scoped install/uninstall consistency) + +- Timestamp: 2026-02-23T13:05:00+01:00 +- Command(s): + - `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` + - `hatch test -- tests/unit/registry/test_module_installer.py tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py -q` + - `hatch test -- tests/e2e/test_init_command.py tests/unit/specfact_cli/registry/test_command_registry.py -q` +- Result summary: + - `37 passed` (`test_commands.py`) + - `15 passed` (`test_module_installer.py`, `test_init_module_lifecycle_ux.py`) + - `28 passed` (`test_init_command.py`, `test_command_registry.py`) + +## Follow-up failing run (source selection + bundled list visibility UX) + +- Timestamp: 2026-02-23T13:13:27+01:00 +- Command(s): `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` +- Failure summary: + - `test_install_command_project_scope_does_not_skip_when_user_scope_module_exists` failed because project-scope install still skipped when a user-scope copy existed. + - `test_install_command_source_marketplace_skips_bundled_resolution` failed because install did not yet support explicit source selection. + - `test_list_command_without_flag_shows_hint_when_bundled_available` failed because list output had no discoverability hint for bundled-not-installed modules. + +## Follow-up passing run (source selection + bundled list visibility UX) + +- Timestamp: 2026-02-23T13:13:27+01:00 +- Command(s): `hatch test -- tests/unit/modules/module_registry/test_commands.py -q` +- Result summary: `40 passed` (`test_commands.py`). + +## Follow-up failing run (denylist + trust gate + bundled integrity hardening) + +- Timestamp: 2026-02-23T12:44:42Z +- Command(s): `hatch run pytest tests/unit/registry/test_module_installer.py tests/unit/modules/module_registry/test_commands.py -q` +- Failure summary: + - `test_install_module_rejects_denylisted_module` and `test_sync_bundled_modules_rejects_denylisted_module` failed because denylist enforcement hook (`assert_module_allowed`) was not implemented. + - `test_install_bundled_module_enforces_integrity_verification` failed because bundled installs did not verify integrity before copy. + - `test_install_command_requires_explicit_trust_for_non_official_in_non_interactive`, `...passes_trust_flag_to_marketplace_installer`, and `test_module_init_passes_trust_flag_and_non_interactive` failed because CLI trust flag and non-interactive trust flow were not wired. + +## Follow-up passing run (denylist + trust gate + bundled integrity hardening) + +- Timestamp: 2026-02-23T12:50:21Z +- Command(s): + - `hatch run pytest tests/unit/registry/test_module_security.py tests/unit/registry/test_module_installer.py tests/unit/modules/module_registry/test_commands.py -q` + - `hatch run pytest tests/unit/specfact_cli/registry/test_signing_artifacts.py tests/unit/registry/test_module_security.py tests/unit/registry/test_module_installer.py tests/unit/modules/module_registry/test_commands.py -q` + - `hatch run format` +- Result summary: + - `63 passed` across signing-artifacts, module-security, installer, and module command suites. + - Formatting checks passed after implementation. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/design.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/design.md new file mode 100644 index 00000000..75d1cd96 --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/design.md @@ -0,0 +1,95 @@ +# Design: backlog-core-05-user-modules-bootstrap + +## Problem + +Installed runtime behavior currently depends on discovery of repository-local `modules/` folders in some execution contexts. This causes command-surface drift (for example missing `backlog add`) across machines and working directories. + +## Goals + +- Establish `/.specfact/modules` as canonical per-user module artifact root. +- Ensure `specfact module init` can bootstrap shipped modules into that root. +- Restrict workspace-local discovery to `/.specfact/modules` only. +- Add explicit `specfact module init` target scope control (`user` default, optional `project`). +- Add startup module freshness guidance with daily/version-triggered cadence. +- Add optional bundled availability visibility in `specfact module list`. +- Extend `specfact module install` to resolve bundled sources in addition to marketplace. +- Add explicit install/uninstall target scope handling (`user`/`project`) with ambiguity safeguards. +- Add local trust hardening for shipped/bundled modules: signature verification, denylist enforcement, and one-time trust prompts for non-official publishers. +- Keep prompt/resource installation behavior deterministic for project IDE targets. + +## Non-goals + +- Replace the `specfact module` command group lifecycle UX in this change. +- Remove legacy discovery roots immediately (deprecation can follow later). +- Implement full online marketplace ecosystem controls (multi-registry, dependency resolver, publishing automation) already tracked in `marketplace-02`. + +## Approach + +1. Discovery / installer alignment +- Add canonical user root constant shared by discovery + installer logic. +- Make installer default to user root. +- Keep legacy `marketplace-modules`/`custom-modules` roots discoverable as compatibility paths. +- Remove automatic `./modules` discovery and only include workspace root `/.specfact/modules`. +- Ensure project scope is discovered before user scope to give repository-local intent precedence. + +2. Module init bootstrap +- Add `specfact module init` sync that copies shipped module packages into user root when absent or outdated. +- Copy safely (create/update module directories) and avoid destructive deletion of unrelated user modules. +- Keep bootstrap explicit under the `module` command group and leave top-level `init` behavior unchanged. +- Add a scope switch so users can seed project-specific modules into `/.specfact/modules`. +- For project scope, default repo to CWD and allow explicit repo override. + +3. Startup freshness checks +- Extend startup check pipeline with module freshness inspection. +- Reuse current cadence policy style: + - run on CLI version change; + - otherwise run at most once per 24h. +- Compare bundled module manifests against target scopes: + - project: `/.specfact/modules` + - user: `/.specfact/modules` +- Print scope-specific guidance commands when stale/missing modules are detected. + +4. Module list bundled availability +- Add a `module list` switch that computes bundled modules available from package/workspace bundle sources. +- Diff bundled module names against active discovered modules. +- Render bundled-not-installed modules as a separate section/table with install hints for user and project scope init commands. + +5. Scoped install/uninstall consistency +- `module install` accepts explicit scope (`user` default, `project` optional with repo path). +- Install resolution checks bundled sources first for exact module name, then marketplace fallback. +- `module uninstall` accepts explicit scope; when module exists in both scopes and no scope is set, command errors and requires explicit selection. +- Uninstall operation removes only the selected scope artifact. + +6. Local trust and signature hardening +- Add denylist file support (for example under user config) checked before any install/bootstrap copy operation. +- Add one-time trust acknowledgments for non-official publishers (persisted in user config); non-interactive mode must require explicit trust flag. +- Verify shipped/bundled module integrity/signature metadata before install/bootstrap; fail closed on verification errors unless explicit override is provided for developer workflows. +- Treat publisher string (`nold-ai`) as informational only; authenticity is derived from signature verification. + +7. Release-time signing of bundled modules +- Add a repository-local signing step that generates signatures/checksums for bundled modules during release orchestration. +- Private signing key remains externalized (CI secret or secure signing service); never committed in repository. +- Signing outputs are committed as module metadata/signature artifacts consumed by runtime verifier. + +3. Resource parity +- Keep prompt resources sourced from packaged `resources/prompts` in installed runtime. +- Validate that prompt copy to repo-local IDE targets works independently of CWD and module root. + +## Risks + +- Path precedence collisions when the same module exists in multiple roots. +- Migration confusion for users with legacy `./modules` layouts. +- Confusion about whether module init writes globally or per project. +- Startup noise if cadence is not throttled. +- Key management mistakes could weaken trust guarantees. +- Overly strict trust prompts could block CI/non-interactive usage. + +## Mitigations + +- Keep source priority deterministic and explicit in tests. +- Emit user-facing hints that `/.specfact/modules` is primary while legacy paths remain supported. +- Document the workspace-local root as `.specfact/modules` in directory-structure reference. +- Document module init scope switch semantics and target paths in command docs. +- Keep startup warnings concise and only shown when stale modules are detected. +- Support explicit non-interactive trust flags for automation while keeping secure defaults. +- Keep private key outside repository and restrict signing access to release workflows. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md new file mode 100644 index 00000000..965b1927 --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md @@ -0,0 +1,53 @@ +# Change: Backlog Core — User Modules Bootstrap and Prompt Resource Sync + +## Why + + + + +`specfact backlog add` is still missing in installed-runtime contexts when command discovery depends on repository-local `modules/` folders. This makes behavior vary by working directory and machine. + +For production usage, shipped modules and their resources should be managed as user-level artifacts. We need a reliable path where `specfact module init` prepares a per-user module root (not repo-local) so command availability is stable. + +## What Changes + + + + +- **MODIFY**: Add a canonical user module root at `/.specfact/modules` for installed module artifacts. +- **MODIFY**: Ensure discovery and installer flows prefer `/.specfact/modules` and stop treating workspace `./modules` as an automatic discovery root. +- **MODIFY**: Add workspace-local module discovery only under `/.specfact/modules` to avoid claiming ownership of non-SpecFact repository paths. +- **NEW**: Add a `specfact module init` bootstrap step that seeds user module root from packaged/workspace module artifacts so shipped modules (for example `backlog-core`) are available after bootstrap. +- **NEW**: Add a target-scope switch for `specfact module init` so bootstrap defaults to per-user root but can explicitly seed per-project modules under `/.specfact/modules` (repo defaults to current directory). +- **NEW**: Add startup module freshness checks (same optimization model as template/version checks): run on first execution of a new CLI version and at most once per 24h otherwise. +- **NEW**: Startup freshness guidance SHALL check both scopes and recommend the exact command to run: project scope (`specfact module init --scope project`) and user scope (`specfact module init`). +- **MODIFY**: Make workspace project modules (`/.specfact/modules`) higher precedence than user modules so project-specific module intent is honored. +- **NEW**: Add `specfact module list` option to show bundled modules available from local package artifacts but not yet installed in active discovery roots, rendered in a separate section with install guidance. +- **MODIFY**: Extend `specfact module install` to resolve module ids from bundled sources as well as marketplace, so users can install only a subset of shipped modules. +- **MODIFY**: Extend `specfact module install`/`uninstall` with explicit target scope handling (`user` or `project`) to avoid ambiguous writes/removals. +- **NEW**: Add uninstall conflict protection when same module id exists in both `/.specfact/modules` and `/.specfact/modules`; command must require explicit scope selection instead of guessing. +- **NEW**: Enforce module denylist checks before install/bootstrap from any source (bundled, project, user, marketplace, legacy roots) to prevent silently installing known-bad modules. +- **NEW**: Add local trust gate for non-official publishers on first install/enable (one-time explicit acknowledgment persisted in user config). +- **NEW**: Require signature/checksum verification for shipped/bundled modules using release-generated signatures (not publisher-name trust alone). +- **NEW**: Add release signing automation for bundled modules in this repository so module signatures are generated during release orchestration without exposing private keys. +- **NEW**: Support encrypted signing keys with passphrase input via CLI flag, stdin, or environment variable, and wire CI signing steps to dedicated secrets (`SPECFACT_MODULE_PRIVATE_SIGN_KEY`, `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE`). +- **MODIFY**: Document and codify boundary with `marketplace-02`: this change hardens local/shipped module trust and install safety; online multi-registry ecosystem remains in `marketplace-02`. +- **MODIFY**: Add tests for init/module discovery parity that verify `backlog add` availability does not depend on current working directory. +- **MODIFY**: Strengthen prompt resource detection/copy tests so `specfact init ide` consistently finds bundled prompt resources and installs them to project target locations. + +## Capabilities +- **backlog-core** (extended): User-level module availability for `backlog add` and related command surface. +- **init/module-registry** (extended): Stable user-root module lifecycle behavior and bootstrap. +- **ide setup** (extended): Prompt resource detection/copy parity for project prompt targets. + + +--- + +## Source Tracking + + +- **GitHub Issue**: #298 +- **Issue URL**: +- **Last Synced Status**: implemented +- **Sanitized**: false + \ No newline at end of file diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/prompt-resource-sync/spec.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/prompt-resource-sync/spec.md new file mode 100644 index 00000000..d0ff2ed7 --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/prompt-resource-sync/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Prompt Resource Detection and Project Target Copy + +The system SHALL consistently detect bundled prompt resources and copy them to IDE-specific project target paths during IDE initialization. + +#### Scenario: Installed runtime resolves bundled prompt resources + +- **GIVEN** SpecFact is installed and invoked outside repository checkout context +- **WHEN** prompt resource resolution runs during `specfact init ide` +- **THEN** the resolver finds bundled `resources/prompts` templates from installed package locations +- **AND** prompt installation proceeds without requiring repository-local prompt files. + +#### Scenario: IDE setup copies detected prompts to project target + +- **GIVEN** prompt templates are detected +- **WHEN** `specfact init ide` copies templates for a selected IDE +- **THEN** prompt files are created in the expected project target folder for that IDE +- **AND** backlog-related prompts (including `specfact.backlog-add`) are included. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md new file mode 100644 index 00000000..6489d306 --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md @@ -0,0 +1,213 @@ +## ADDED Requirements + +### Requirement: Canonical User Module Root + +The system SHALL use a canonical per-user module root at `/.specfact/modules` for installed module artifacts and discovery. + +#### Scenario: Installer defaults to user module root + +- **GIVEN** a module is installed via module installer workflow without explicit install root override +- **WHEN** installation runs +- **THEN** module artifacts are installed under `/.specfact/modules/` +- **AND** subsequent module discovery includes that module as installed. + +#### Scenario: Discovery includes user root independent of CWD + +- **GIVEN** modules are present under `/.specfact/modules` +- **AND** current working directory has no local `.specfact/modules` folder +- **WHEN** module discovery runs +- **THEN** modules from `/.specfact/modules` are discovered +- **AND** command availability does not depend on repository-local module folders. + +#### Scenario: Workspace root discovery is scoped to .specfact + +- **GIVEN** current working directory contains `/modules/` +- **AND** current working directory does not contain `/.specfact/modules/` +- **WHEN** module discovery runs +- **THEN** `/modules/` is not auto-discovered +- **AND** discovery does not assume ownership of non-`.specfact` repository directories. + +#### Scenario: Workspace-local module discovery uses .specfact/modules + +- **GIVEN** current working directory contains `/.specfact/modules/` +- **WHEN** module discovery runs +- **THEN** `/.specfact/modules/` is discovered as a custom workspace module root. + +### Requirement: Module Init User-Root Bootstrap + +`specfact module init` SHALL bootstrap shipped modules into the canonical user module root so shipped command groups are available after bootstrap. + +#### Scenario: Module init seeds shipped modules to user root + +- **GIVEN** an installed runtime with shipped module artifacts available in packaged or workspace source paths +- **AND** `/.specfact/modules` does not contain those modules yet +- **WHEN** `specfact module init` runs +- **THEN** shipped modules are copied/synced into `/.specfact/modules` +- **AND** module list/enablement includes seeded modules in the same module init run. + +### Requirement: Module Init Target Scope + +`specfact module init` SHALL support explicit bootstrap target scope selection. + +#### Scenario: Module init defaults to user scope + +- **GIVEN** no explicit target-scope switch is provided +- **WHEN** `specfact module init` runs +- **THEN** shipped modules are seeded into `/.specfact/modules`. + +#### Scenario: Module init supports project scope under .specfact + +- **GIVEN** the user chooses project scope +- **AND** no explicit repo path is provided +- **WHEN** `specfact module init` runs +- **THEN** shipped modules are seeded into `/.specfact/modules` +- **AND** project-scope bootstrap does not write into `/modules`. + +#### Scenario: Module init supports explicit repo for project scope + +- **GIVEN** the user chooses project scope +- **AND** an explicit repo path `` is provided +- **WHEN** `specfact module init` runs +- **THEN** shipped modules are seeded into `/.specfact/modules` +- **AND** no module artifacts are written outside `/.specfact/modules` for that operation. + +### Requirement: Project Module Precedence + +Workspace project modules SHALL take precedence over user-scope modules. + +#### Scenario: Project module shadows user module with same id + +- **GIVEN** `/.specfact/modules/` exists +- **AND** `/.specfact/modules/` exists +- **WHEN** module discovery runs in `` +- **THEN** the discovered module source for `` resolves to project scope +- **AND** command behavior uses project module artifacts for that repo context. + +### Requirement: Startup Module Freshness Guidance + +Startup checks SHALL provide module freshness guidance for bundled modules across project and user scopes. + +#### Scenario: Freshness check cadence + +- **GIVEN** startup checks are enabled +- **WHEN** CLI version changed since last startup metadata check +- **THEN** module freshness check runs. + +- **GIVEN** CLI version did not change +- **WHEN** last module freshness timestamp is less than 24 hours old +- **THEN** module freshness check is skipped. + +- **GIVEN** CLI version did not change +- **WHEN** last module freshness timestamp is at least 24 hours old +- **THEN** module freshness check runs. + +#### Scenario: Startup warns for stale project and user roots + +- **GIVEN** bundled modules are missing or outdated in `/.specfact/modules` +- **OR** bundled modules are missing or outdated in `/.specfact/modules` +- **WHEN** startup module freshness check runs +- **THEN** startup output includes actionable guidance with exact commands: +- **AND** project guidance uses `specfact module init --scope project` +- **AND** user guidance uses `specfact module init`. + +### Requirement: Module List Bundled Availability View + +`specfact module list` SHALL optionally show bundled modules that are available locally but not yet installed. + +#### Scenario: Bundled-not-installed modules are shown in separate section + +- **GIVEN** bundled module artifacts are present in package/workspace bundled sources +- **AND** one or more bundled modules are not discovered in active module roots +- **WHEN** the user runs `specfact module list` with the bundled-availability option +- **THEN** CLI output includes a separate table/section of bundled modules not yet installed. + +#### Scenario: Bundled availability section includes install guidance + +- **GIVEN** bundled-not-installed modules are shown +- **WHEN** section is rendered +- **THEN** output includes actionable hints to install bundled modules with: +- **AND** `specfact module init` +- **AND** `specfact module init --scope project`. + +### Requirement: Scoped Module Install Resolution + +`specfact module install` SHALL support scoped installation and resolve modules from bundled or marketplace sources. + +#### Scenario: Install resolves bundled module by name + +- **GIVEN** bundled module artifacts include `` +- **WHEN** the user runs `specfact module install ` +- **THEN** install resolves `` from bundled sources when available +- **AND** installs into selected scope root. + +#### Scenario: Install supports explicit project scope + +- **GIVEN** the user selects project scope +- **WHEN** `specfact module install ` runs +- **THEN** module is installed into `/.specfact/modules` +- **AND** command does not write into user root for that operation. + +### Requirement: Scoped Module Uninstall Safety + +`specfact module uninstall` SHALL support scoped uninstall and guard against ambiguous multi-scope removals. + +#### Scenario: Uninstall requires explicit scope on multi-scope collision + +- **GIVEN** `` exists in both `/.specfact/modules` and `/.specfact/modules` +- **WHEN** user runs `specfact module uninstall ` without explicit scope +- **THEN** command fails with guidance to choose `--scope user` or `--scope project` +- **AND** no module is removed. + +#### Scenario: Uninstall removes only selected scope copy + +- **GIVEN** `` exists in both project and user scope roots +- **WHEN** user runs `specfact module uninstall --scope project` +- **THEN** only `/.specfact/modules/` is removed +- **AND** user-scope copy remains intact. + +### Requirement: Module Denylist Enforcement + +The system SHALL enforce a denylist check before installing or bootstrapping modules from any source. + +#### Scenario: Denylisted module is blocked + +- **GIVEN** `` is present in configured denylist +- **WHEN** user runs `specfact module install ` or `specfact module init` +- **THEN** installation/bootstrap for `` is blocked +- **AND** output includes clear security guidance. + +### Requirement: Non-Official Publisher Trust Prompt + +The system SHALL require explicit one-time trust acknowledgment for non-official publishers. + +#### Scenario: First install of non-official module prompts for trust + +- **GIVEN** module publisher is not official +- **AND** user has no stored trust decision for that publisher/module source +- **WHEN** user runs `specfact module install ` +- **THEN** command prompts for explicit trust acknowledgment +- **AND** stores trust decision for subsequent installs. + +#### Scenario: Non-interactive install requires explicit trust flag + +- **GIVEN** install runs in non-interactive mode +- **AND** trust acknowledgment does not yet exist +- **WHEN** user runs `specfact module install ` +- **THEN** command fails unless explicit trust override flag is provided. + +### Requirement: Bundled Module Signature Verification + +Shipped/bundled modules SHALL be verified by signature/checksum before install/bootstrap. + +#### Scenario: Bundled signature verification passes + +- **GIVEN** bundled module has valid signature/checksum metadata generated by release signing workflow +- **WHEN** user runs `specfact module init` or installs bundled module +- **THEN** module is installed/bootstrapped. + +#### Scenario: Bundled signature verification fails + +- **GIVEN** bundled module signature/checksum verification fails +- **WHEN** user runs `specfact module init` or installs bundled module +- **THEN** operation fails for that module +- **AND** module is not installed silently. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md new file mode 100644 index 00000000..a133b53f --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md @@ -0,0 +1,79 @@ +# Tasks: backlog-core-05-user-modules-bootstrap + +## TDD / SDD order (enforced) + +Per `openspec/config.yaml`, tests before code for behavior changes. + +1. Update spec deltas first. +2. Add tests mapped to scenarios. +3. Run tests and capture failing results in `TDD_EVIDENCE.md`. +4. Implement production code. +5. Re-run tests and quality checks; capture passing evidence in `TDD_EVIDENCE.md`. + +## 1. Branch and scope + +- [x] 1.1 Work on `bugfix/backlog-core-05-user-modules-bootstrap` (or active equivalent) before implementation changes. +- [x] 1.2 Confirm scope is limited to user-root module bootstrap/discovery and prompt resource sync verification. + +## 2. Specs first + +- [x] 2.1 Finalize `specs/user-module-root/spec.md` scenarios for `/.specfact/modules` discovery + bootstrap behavior. +- [x] 2.2 Finalize `specs/prompt-resource-sync/spec.md` scenarios for prompt resource detection and IDE target installation parity. + +## 3. Tests first (must fail before implementation) + +- [x] 3.1 Add/extend tests for module discovery and installer roots (`tests/unit/registry/test_module_discovery.py`, `tests/unit/registry/test_module_installer.py`, and/or `tests/unit/specfact_cli/registry/test_module_packages.py`). +- [x] 3.2 Add/extend module command tests proving shipped module availability after `specfact module init` bootstrap. +- [x] 3.3 Add/extend IDE setup tests for prompt resource detection/copy behavior (`tests/unit/utils/test_ide_setup.py`). +- [x] 3.5 Add/extend tests ensuring workspace `./modules` is ignored and workspace `.specfact/modules` is discovered. +- [x] 3.6 Add/extend module-init tests for target scope switch: default user scope, project scope at CWD, and project scope with explicit repo. +- [x] 3.7 Add/extend discovery tests proving project `.specfact/modules` precedence over user modules in repo context. +- [x] 3.8 Add/extend startup-check tests for module freshness cadence (version-changed and daily) and guidance output for project/user scope updates. +- [x] 3.9 Add/extend module list tests for bundled-not-installed view and install hints. +- [x] 3.10 Add/extend install/uninstall tests for explicit scope handling and multi-scope uninstall ambiguity safeguards. +- [x] 3.11 Add/extend install tests proving bundled module resolution via `specfact module install`. +- [x] 3.12 Add/extend tests for explicit install source selection and bundled-availability hint in `module list` default output. +- [x] 3.13 Add tests for denylist enforcement on `module install` and `module init` bootstrap paths. +- [x] 3.14 Add tests for one-time trust prompt/flag behavior for non-official publishers in interactive and non-interactive modes. +- [x] 3.15 Add tests for bundled signature/checksum verification during install/bootstrap (pass and fail paths). +- [x] 3.4 Run targeted tests and record failing results in `TDD_EVIDENCE.md`. + +## 4. Implementation + +- [x] 4.1 Introduce canonical user module root constant/path (`/.specfact/modules`) and integrate it into module discovery/installer flows. +- [x] 4.2 Add `specfact module init` bootstrap that syncs bundled/workspace modules into user module root without destructive overwrite. +- [x] 4.3 Update user-facing hints/messages to reference `/.specfact/modules` as primary per-user module location. +- [x] 4.4 Ensure prompt resource resolution path remains deterministic for installed runtime and project-target template copy flows. +- [x] 4.5 Remove automatic workspace `./modules` discovery and switch workspace-local root to `/.specfact/modules`. +- [x] 4.6 Add `specfact module init` target-scope switch (`user` default, optional `project`) and optional repo target for project scope. +- [x] 4.7 Implement project-over-user module precedence in discovery ordering. +- [x] 4.8 Implement startup module freshness checker integrated with existing startup check cadence metadata. +- [x] 4.9 Add `specfact module list` bundled-availability switch and separate table for bundled-not-installed modules. +- [x] 4.10 Implement scoped `module install`/`module uninstall` roots with explicit `--scope` and optional `--repo` handling. +- [x] 4.11 Implement uninstall ambiguity protection when module exists in both project and user roots. +- [x] 4.12 Implement bundled-source resolution path in `module install` before marketplace fallback. +- [x] 4.13 Add explicit `module install --source` control (`auto|bundled|marketplace`) and improve `module list` discoverability hint. +- [x] 4.14 Implement denylist loader/checker applied to install/bootstrap flows for all sources. +- [x] 4.15 Implement persisted trust decisions and trust prompt/flag flow for non-official publishers. +- [x] 4.16 Enforce signature/checksum verification for shipped/bundled module install/bootstrap paths. +- [x] 4.17 Add release signing script/workflow integration for bundled modules (private key via CI secret; no key material in repo). +- [x] 4.18 Add encrypted-key passphrase handling in signing scripts (`--passphrase`, `--passphrase-stdin`, env var) and update CI signing secrets wiring. + +## 5. Validation and docs + +- [x] 5.1 Re-run targeted tests and record passing results in `TDD_EVIDENCE.md`. +- [x] 5.2 Run touched-scope quality gates (`hatch run format`, `hatch run type-check`, targeted pytest). +- [x] 5.3 Update docs/command references for user module root, workspace `.specfact/modules`, and init behavior where needed. +- [x] 5.5 Update command docs for module-init scope switch and per-project target behavior under `.specfact/modules`. +- [x] 5.6 Update startup-check docs to include module freshness guidance cadence and command hints. +- [x] 5.7 Update command docs for module-list bundled-availability option and output semantics. +- [x] 5.8 Update command docs for scoped install/uninstall behavior and bundled install resolution. +- [x] 5.9 Align marketplace guide with user/project scope roots and legacy root compatibility note. +- [x] 5.10 Document denylist/trust prompt/signature verification behavior and automation flags for CI. +- [x] 5.11 Document scope boundary with `marketplace-02` (online registry ecosystem vs local/shipped trust hardening). +- [x] 5.4 Run `openspec validate backlog-core-05-user-modules-bootstrap --strict` and update `CHANGE_VALIDATION.md`. + +## 6. Delivery + +- [x] 6.1 Update `openspec/CHANGE_ORDER.md` status and placement. +- [x] 6.2 Prepare PR notes with verification across different working directories/machines. diff --git a/openspec/config.yaml b/openspec/config.yaml index 01653d80..cd9c00bf 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -132,6 +132,13 @@ rules: openspec/changes//TDD_EVIDENCE.md with failing-before and passing-after test commands, timestamps, and short result summaries. - Include quality gate tasks: format, lint, type-check, test coverage + - |- + Module signing quality gate (required before PR): Include tasks to + - (1) run `hatch run ./scripts/verify-modules-signature.py --require-signature`; + - (2) when verification fails after module changes, re-sign affected manifests with + `hatch run python scripts/sign-modules.py --key-file `; + - (3) enforce module version bump before signing changed modules (major/minor/patch as appropriate); + - (4) re-run verification until fully green. - Reference existing test patterns in tests/unit/, tests/integration/, tests/e2e/ - |- Version and changelog (required before PR): Include a task that diff --git a/pyproject.toml b/pyproject.toml index 97a1744a..4c746179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.36.1" +version = "0.37.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" @@ -377,6 +377,9 @@ packages = [ "resources/templates" = "specfact_cli/resources/templates" "resources/schemas" = "specfact_cli/resources/schemas" "resources/mappings" = "specfact_cli/resources/mappings" +"resources/keys" = "specfact_cli/resources/keys" +"modules/backlog-core" = "specfact_cli/resources/modules/backlog-core" +"modules/bundle-mapper" = "specfact_cli/resources/modules/bundle-mapper" # Note: resources/semgrep files are in src/specfact_cli/resources/semgrep/ and are automatically included [tool.hatch.build.targets.sdist] diff --git a/resources/keys/README.md b/resources/keys/README.md new file mode 100644 index 00000000..f0ac859b --- /dev/null +++ b/resources/keys/README.md @@ -0,0 +1,14 @@ +Public verification keys for bundled module signatures. + +Default key path expected by SpecFact CLI runtime: + +- Repo source: `resources/keys/module-signing-public.pem` +- Installed package: `resources/keys/module-signing-public.pem` + +Runtime resolution order: + +1. Explicit public key argument (internal call path) +2. `SPECFACT_MODULE_PUBLIC_KEY_PEM` environment variable +3. Bundled key file (source path first, then installed package path) + +Do not store private signing keys in this repository. diff --git a/resources/keys/module-signing-public.pem b/resources/keys/module-signing-public.pem new file mode 100644 index 00000000..300cb057 --- /dev/null +++ b/resources/keys/module-signing-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAI97L4DRdUtcB3lul4BtBsrpF5hWBu7z5Z5fNA6/AY18= +-----END PUBLIC KEY----- diff --git a/scripts/pre-commit-smart-checks.sh b/scripts/pre-commit-smart-checks.sh old mode 100644 new mode 100755 diff --git a/scripts/run_actionlint.sh b/scripts/run_actionlint.sh old mode 100644 new mode 100755 diff --git a/scripts/sign-module.sh b/scripts/sign-module.sh old mode 100644 new mode 100755 index 3ec8171a..304a14eb --- a/scripts/sign-module.sh +++ b/scripts/sign-module.sh @@ -1,19 +1,113 @@ #!/usr/bin/env bash -# Sign module manifest for integrity (arch-06). Outputs checksum in algo:hex format for manifest integrity field. +# Sign module manifest for integrity metadata (checksum and optional signature). set -euo pipefail -MANIFEST="${1:-}" + +usage() { + cat <<'EOF' +Usage: + scripts/sign-module.sh [--key-file PATH] [--passphrase TEXT|--passphrase-stdin] [--allow-unsigned] [--allow-same-version] + +Options: + --key-file PATH PEM private key used for detached signatures (recommended) + --passphrase TEXT Passphrase for encrypted private key (discouraged in shell history) + --passphrase-stdin Read passphrase from stdin (for secure piping/CI use) + --allow-unsigned Allow checksum-only signing without key (local testing only) + --allow-same-version Bypass version-bump enforcement before signing (not recommended) + -h, --help Show this help message +EOF +} + +KEY_FILE="" +PASSPHRASE="" +PASSPHRASE_STDIN=0 +ALLOW_UNSIGNED=0 +ALLOW_SAME_VERSION=0 +MANIFEST="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --key-file) + shift + if [[ $# -eq 0 ]]; then + echo "Error: --key-file requires a path argument." >&2 + usage >&2 + exit 1 + fi + KEY_FILE="$1" + ;; + --allow-unsigned) + ALLOW_UNSIGNED=1 + ;; + --allow-same-version) + ALLOW_SAME_VERSION=1 + ;; + --passphrase) + shift + if [[ $# -eq 0 ]]; then + echo "Error: --passphrase requires a value." >&2 + usage >&2 + exit 1 + fi + PASSPHRASE="$1" + ;; + --passphrase-stdin) + PASSPHRASE_STDIN=1 + ;; + --*) + echo "Error: unknown option '$1'." >&2 + usage >&2 + exit 1 + ;; + *) + if [[ -n "$MANIFEST" ]]; then + echo "Error: only one manifest path is supported by this wrapper." >&2 + usage >&2 + exit 1 + fi + MANIFEST="$1" + ;; + esac + shift +done + if [[ -z "$MANIFEST" || ! -f "$MANIFEST" ]]; then - echo "Usage: $0 " >&2 + echo "Error: manifest path is required and must exist." >&2 + usage >&2 exit 1 fi -# Produce sha256 checksum for manifest content (integrity.checksum format) -if command -v sha256sum &>/dev/null; then - SUM=$(sha256sum -b < "$MANIFEST" | awk '{print $1}') -elif command -v shasum &>/dev/null; then - SUM=$(shasum -a 256 -b < "$MANIFEST" | awk '{print $1}') -else - echo "No sha256sum/shasum found" >&2 - exit 1 + +ARGS=() +if [[ -n "$KEY_FILE" ]]; then + ARGS+=(--key-file "$KEY_FILE") +fi +if [[ -n "$PASSPHRASE" ]]; then + ARGS+=(--passphrase "$PASSPHRASE") +fi +if [[ "$PASSPHRASE_STDIN" -eq 1 ]]; then + ARGS+=(--passphrase-stdin) +fi +if [[ "$ALLOW_UNSIGNED" -eq 1 ]]; then + ARGS+=(--allow-unsigned) +fi +if [[ "$ALLOW_SAME_VERSION" -eq 1 ]]; then + ARGS+=(--allow-same-version) fi -echo "sha256:$SUM" -echo "checksum: sha256:$SUM" >&2 +python3 scripts/sign-modules.py "${ARGS[@]}" "$MANIFEST" + +# Emit checksum line for legacy pipeline compatibility. +python3 - "$MANIFEST" <<'PY' +from pathlib import Path +import yaml +import sys + +manifest = Path(sys.argv[1]) +data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {} +integrity = data.get("integrity") if isinstance(data, dict) else None +checksum = integrity.get("checksum") if isinstance(integrity, dict) else "" +print(checksum) +print(f"checksum: {checksum}", file=sys.stderr) +PY diff --git a/scripts/sign-modules.py b/scripts/sign-modules.py new file mode 100755 index 00000000..37f6660a --- /dev/null +++ b/scripts/sign-modules.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Sign SpecFact module manifests with checksum/signature over full module payload.""" + +from __future__ import annotations + +import argparse +import base64 +import getpass +import hashlib +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + + +def _canonical_payload(manifest_data: dict[str, Any]) -> bytes: + payload = dict(manifest_data) + payload.pop("integrity", None) + return yaml.safe_dump(payload, sort_keys=True, allow_unicode=False).encode("utf-8") + + +def _module_payload(module_dir: Path) -> bytes: + if not module_dir.exists() or not module_dir.is_dir(): + msg = f"Module directory not found: {module_dir}" + raise ValueError(msg) + entries: list[str] = [] + files = sorted( + (path for path in module_dir.rglob("*") if path.is_file()), + key=lambda p: p.relative_to(module_dir).as_posix(), + ) + for path in files: + rel = path.relative_to(module_dir).as_posix() + if rel in {"module-package.yaml", "metadata.yaml"}: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + msg = f"Invalid manifest YAML: {path}" + raise ValueError(msg) + data = _canonical_payload(raw) + else: + data = path.read_bytes() + entries.append(f"{rel}:{hashlib.sha256(data).hexdigest()}") + return "\n".join(entries).encode("utf-8") + + +def _load_private_key( + key_file: Path | None = None, + *, + passphrase: str | None = None, + prompt_for_passphrase: bool = False, +) -> Any | None: + pem = os.environ.get("SPECFACT_MODULE_PRIVATE_SIGN_KEY", "").strip() + if not pem: + pem = os.environ.get("SPECFACT_MODULE_SIGNING_PRIVATE_KEY_PEM", "").strip() + configured_file = os.environ.get("SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE", "").strip() + if not configured_file: + configured_file = os.environ.get("SPECFACT_MODULE_SIGNING_PRIVATE_KEY_FILE", "").strip() + effective_file = key_file or (Path(configured_file) if configured_file else None) + if not pem and effective_file: + pem = effective_file.read_text(encoding="utf-8") + if not pem: + return None + + try: + from cryptography.hazmat.primitives import serialization + except Exception as exc: + raise ValueError( + "Unable to import cryptography backend for signing. " + "Install signing dependencies (`python3 -m pip install cryptography cffi`) " + "or run via project environment (`hatch run python scripts/sign-modules.py ...`)." + ) from exc + + password_bytes = passphrase.encode("utf-8") if passphrase is not None else None + + try: + return serialization.load_pem_private_key(pem.encode("utf-8"), password=password_bytes) + except Exception as exc: + message = str(exc).lower() + needs_password = "password was not given" in message or "private key is encrypted" in message + if needs_password and prompt_for_passphrase: + prompted = getpass.getpass("Enter signing key passphrase: ") + try: + return serialization.load_pem_private_key( + pem.encode("utf-8"), + password=prompted.encode("utf-8"), + ) + except Exception as retry_exc: + raise ValueError(f"Failed to load private key from PEM: {retry_exc}") from retry_exc + if needs_password and passphrase is None: + raise ValueError( + "Private key is encrypted. Provide passphrase via --passphrase, --passphrase-stdin, " + "or SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE." + ) from exc + raise ValueError(f"Failed to load private key from PEM: {exc}") from exc + + +def _resolve_passphrase(args: argparse.Namespace) -> str | None: + explicit = (args.passphrase or "").strip() + if explicit: + return explicit + env_value = os.environ.get("SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE", "").strip() + if not env_value: + env_value = os.environ.get("SPECFACT_MODULE_SIGNING_PRIVATE_KEY_PASSPHRASE", "").strip() + if env_value: + return env_value + if args.passphrase_stdin: + piped = sys.stdin.read().rstrip("\r\n") + return piped if piped else None + return None + + +def _read_manifest_version(path: Path) -> str | None: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return None + value = raw.get("version") + if value is None: + return None + version = str(value).strip() + return version or None + + +def _read_manifest_version_from_git(head_ref: str, path: Path) -> str | None: + try: + output = subprocess.run( + ["git", "show", f"{head_ref}:{path.as_posix()}"], + check=True, + capture_output=True, + text=True, + ) + except Exception: + return None + try: + raw = yaml.safe_load(output.stdout) + except Exception: + return None + if not isinstance(raw, dict): + return None + value = raw.get("version") + if value is None: + return None + version = str(value).strip() + return version or None + + +def _module_has_git_changes(module_dir: Path) -> bool: + try: + changed = subprocess.run( + ["git", "diff", "--name-only", "HEAD", "--", module_dir.as_posix()], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + untracked = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--", module_dir.as_posix()], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except Exception: + return False + return bool(changed or untracked) + + +def _enforce_version_bump_before_signing(manifest_path: Path, *, allow_same_version: bool) -> None: + if allow_same_version: + return + + current_version = _read_manifest_version(manifest_path) + if not current_version: + raise ValueError(f"Manifest missing version: {manifest_path}") + + previous_version = _read_manifest_version_from_git("HEAD", manifest_path) + if previous_version is None: + return + if current_version != previous_version: + return + + module_dir = manifest_path.parent + if not _module_has_git_changes(module_dir): + return + + raise ValueError( + f"Module version must be incremented before signing changed module contents: {manifest_path} " + f"(current version {current_version})." + ) + + +def _sign_payload(payload: bytes, private_key: Any) -> str: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ed25519, padding, rsa + + if isinstance(private_key, ed25519.Ed25519PrivateKey): + signature = private_key.sign(payload) + elif isinstance(private_key, rsa.RSAPrivateKey): + signature = private_key.sign(payload, padding.PKCS1v15(), hashes.SHA256()) + else: + msg = "Unsupported private key type for signing (RSA and Ed25519 only)" + raise ValueError(msg) + return base64.b64encode(signature).decode("ascii") + + +def sign_manifest(manifest_path: Path, private_key: Any | None) -> None: + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + msg = f"Invalid manifest YAML: {manifest_path}" + raise ValueError(msg) + + payload = _module_payload(manifest_path.parent) + checksum = f"sha256:{hashlib.sha256(payload).hexdigest()}" + integrity: dict[str, str] = {"checksum": checksum} + + if private_key is not None: + integrity["signature"] = _sign_payload(payload, private_key) + + raw["integrity"] = integrity + manifest_path.write_text( + yaml.safe_dump( + raw, + sort_keys=False, + allow_unicode=False, + default_flow_style=False, + indent=2, + ), + encoding="utf-8", + ) + + status = "checksum+signature" if "signature" in integrity else "checksum" + print(f"{manifest_path}: {status}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--key-file", + type=Path, + default=None, + help=( + "Path to PEM private key (overrides SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE). " + "Supported keys: Ed25519 and RSA." + ), + ) + parser.add_argument( + "--passphrase", default="", help="Passphrase for encrypted private key (discouraged in shell history)" + ) + parser.add_argument( + "--passphrase-stdin", + action="store_true", + help="Read private-key passphrase from stdin (for secure piping/CI use)", + ) + parser.add_argument( + "--allow-unsigned", + action="store_true", + help="Allow checksum-only signing without private key (local testing only).", + ) + parser.add_argument( + "--allow-same-version", + action="store_true", + help="Bypass version-bump enforcement for changed module contents (not recommended).", + ) + parser.add_argument("manifests", nargs="+", help="module-package.yaml path(s)") + args = parser.parse_args() + + passphrase = _resolve_passphrase(args) + try: + private_key = _load_private_key( + args.key_file, + passphrase=passphrase, + prompt_for_passphrase=sys.stdin.isatty() and not args.passphrase_stdin, + ) + except ValueError as exc: + parser.error(str(exc)) + if private_key is None and not args.allow_unsigned: + parser.error( + "No signing key provided. Use --key-file (recommended) " + "or set SPECFACT_MODULE_PRIVATE_SIGN_KEY / SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE. " + "For local testing only, re-run with --allow-unsigned." + ) + + for manifest in args.manifests: + try: + manifest_path = Path(manifest) + _enforce_version_bump_before_signing( + manifest_path, + allow_same_version=args.allow_same_version, + ) + sign_manifest(manifest_path, private_key) + except ValueError as exc: + parser.error(str(exc)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-modules-signature.py b/scripts/verify-modules-signature.py new file mode 100755 index 00000000..c0e1d24c --- /dev/null +++ b/scripts/verify-modules-signature.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Verify bundled module checksums/signatures against full module payload.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import os +import subprocess +from pathlib import Path +from typing import Any + +import yaml + + +def _canonical_manifest_payload(manifest_data: dict[str, Any]) -> bytes: + payload = dict(manifest_data) + payload.pop("integrity", None) + return yaml.safe_dump(payload, sort_keys=True, allow_unicode=False).encode("utf-8") + + +def _module_payload(module_dir: Path) -> bytes: + entries: list[str] = [] + files = sorted( + (path for path in module_dir.rglob("*") if path.is_file()), + key=lambda p: p.relative_to(module_dir).as_posix(), + ) + for path in files: + rel = path.relative_to(module_dir).as_posix() + if rel in {"module-package.yaml", "metadata.yaml"}: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Invalid manifest YAML: {path}") + data = _canonical_manifest_payload(raw) + else: + data = path.read_bytes() + entries.append(f"{rel}:{hashlib.sha256(data).hexdigest()}") + return "\n".join(entries).encode("utf-8") + + +def _parse_checksum(checksum: str) -> tuple[str, str]: + if ":" not in checksum: + raise ValueError("Checksum must be in algo:hex format") + algo, digest = checksum.split(":", 1) + algo = algo.strip().lower() + digest = digest.strip().lower() + if algo not in {"sha256", "sha384", "sha512"}: + raise ValueError(f"Unsupported checksum algorithm: {algo}") + if not digest: + raise ValueError("Checksum digest is empty") + return algo, digest + + +def _verify_signature(payload: bytes, signature_b64: str, public_key_pem: str) -> None: + try: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ed25519, padding, rsa + except Exception as exc: + raise ValueError( + "cryptography backend missing; install with `python3 -m pip install cryptography cffi`" + ) from exc + + public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + signature = base64.b64decode(signature_b64, validate=True) + + try: + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify(signature, payload, padding.PKCS1v15(), hashes.SHA256()) + return + if isinstance(public_key, ed25519.Ed25519PublicKey): + public_key.verify(signature, payload) + return + except InvalidSignature as exc: + raise ValueError("Signature validation failed") from exc + raise ValueError("Unsupported public key type (RSA or Ed25519 required)") + + +def _resolve_public_key(args: argparse.Namespace) -> str: + if args.public_key_file: + return Path(args.public_key_file).read_text(encoding="utf-8").strip() + env_key = (args.public_key_pem or "").strip() + if env_key: + return env_key + default_paths = [ + Path("resources/keys/module-signing-public.pem"), + Path("src/specfact_cli/resources/keys/module-signing-public.pem"), + ] + for default_path in default_paths: + if default_path.exists(): + return default_path.read_text(encoding="utf-8").strip() + return "" + + +def _iter_manifests() -> list[Path]: + roots = [Path("src/specfact_cli/modules"), Path("modules")] + manifests: list[Path] = [] + for root in roots: + if not root.exists(): + continue + manifests.extend(sorted(root.rglob("module-package.yaml"))) + return manifests + + +def _read_manifest_version(path: Path) -> str | None: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return None + value = raw.get("version") + if value is None: + return None + version = str(value).strip() + return version or None + + +def _read_manifest_version_from_git(ref: str, manifest_path: Path) -> str | None: + try: + output = subprocess.run( + ["git", "show", f"{ref}:{manifest_path.as_posix()}"], + check=True, + capture_output=True, + text=True, + ) + except Exception: + return None + try: + raw = yaml.safe_load(output.stdout) + except Exception: + return None + if not isinstance(raw, dict): + return None + value = raw.get("version") + if value is None: + return None + version = str(value).strip() + return version or None + + +def _resolve_version_check_base(explicit_base: str | None) -> str: + if explicit_base and explicit_base.strip(): + return explicit_base.strip() + + env_base_ref = (os.environ.get("GITHUB_BASE_REF", "") or "").strip() + if env_base_ref: + return f"origin/{env_base_ref}" + return "HEAD~1" + + +def _changed_manifests_from_git(base_ref: str) -> list[Path]: + try: + output = subprocess.run( + [ + "git", + "diff", + "--name-only", + f"{base_ref}...HEAD", + "--", + "src/specfact_cli/modules/*/module-package.yaml", + "modules/*/module-package.yaml", + ], + check=True, + capture_output=True, + text=True, + ) + except Exception as exc: + raise ValueError(f"Unable to diff manifests against base ref '{base_ref}': {exc}") from exc + + manifests: list[Path] = [] + for line in output.stdout.splitlines(): + path = Path(line.strip()) + if not path: + continue + if path.exists(): + manifests.append(path) + return manifests + + +def _verify_version_bumps(base_ref: str) -> list[str]: + failures: list[str] = [] + for manifest in _changed_manifests_from_git(base_ref): + current_version = _read_manifest_version(manifest) + previous_version = _read_manifest_version_from_git(base_ref, manifest) + if not current_version or not previous_version: + continue + if current_version == previous_version: + failures.append( + f"FAIL {manifest}: module version was not incremented (still {current_version}) compared to {base_ref}" + ) + return failures + + +def verify_manifest(manifest_path: Path, *, require_signature: bool, public_key_pem: str) -> None: + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("manifest YAML must be object") + integrity = raw.get("integrity") + if not isinstance(integrity, dict): + raise ValueError("missing integrity metadata") + + checksum = str(integrity.get("checksum", "")).strip() + if not checksum: + raise ValueError("missing integrity.checksum") + algo, digest = _parse_checksum(checksum) + payload = _module_payload(manifest_path.parent) + actual = hashlib.new(algo, payload).hexdigest().lower() + if actual != digest: + raise ValueError("checksum mismatch") + + signature = str(integrity.get("signature", "")).strip() + if require_signature and not signature: + raise ValueError("missing integrity.signature") + if signature: + if not public_key_pem: + raise ValueError("public key required to verify signature") + _verify_signature(payload, signature, public_key_pem) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--require-signature", action="store_true", help="Require integrity.signature for every manifest" + ) + parser.add_argument("--public-key-file", default="", help="Path to PEM public key") + parser.add_argument( + "--public-key-pem", + default="", + help="Inline PEM public key content (optional; fallback after --public-key-file)", + ) + parser.add_argument( + "--enforce-version-bump", + action="store_true", + help="Fail when changed module manifests keep the same version as base ref", + ) + parser.add_argument( + "--version-check-base", + default="", + help="Git base ref for version-bump checks (default: origin/$GITHUB_BASE_REF or HEAD~1)", + ) + args = parser.parse_args() + + public_key_pem = _resolve_public_key(args) + manifests = _iter_manifests() + if not manifests: + print("No module-package.yaml manifests found.") + return 0 + + failures: list[str] = [] + for manifest in manifests: + try: + verify_manifest(manifest, require_signature=args.require_signature, public_key_pem=public_key_pem) + print(f"OK {manifest}") + except Exception as exc: + failures.append(f"FAIL {manifest}: {exc}") + + if failures: + print("\n".join(failures)) + return 1 + + if args.enforce_version_bump: + base_ref = _resolve_version_check_base(args.version_check_base) + try: + version_failures = _verify_version_bumps(base_ref) + except ValueError as exc: + print(f"FAIL version-check: {exc}") + return 1 + if version_failures: + print("\n".join(version_failures)) + return 1 + + print(f"Verified {len(manifests)} module manifest(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml-tools.sh b/scripts/yaml-tools.sh old mode 100644 new mode 100755 diff --git a/setup.py b/setup.py index f1cb30a7..00d0bb74 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.36.1", + version="0.37.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/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 62a234c6..5d00f77c 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.36.1" +__version__ = "0.37.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 1be94182..ae69924e 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -337,33 +337,6 @@ def __init__(self, cmd_name: str, help_str: str, name: str | None = None, help: def _make_delegate_command(self) -> click.Command: cmd_name = self._lazy_cmd_name - def _normalize_init_optional_module_flags(argv: list[str]) -> list[str]: - """ - Normalize bare init module flags to sentinel values. - - Typer/Click options declared with value types require an argument. To support - UX like `specfact init --enable-module` in interactive mode, rewrite bare flags - to include a sentinel token consumed by init command logic. - """ - if cmd_name != "init": - return argv - out: list[str] = [] - i = 0 - while i < len(argv): - token = argv[i] - if token in ("--enable-module", "--disable-module"): - out.append(token) - if i + 1 < len(argv) and not argv[i + 1].startswith("-"): - out.append(argv[i + 1]) - i += 2 - continue - out.append("__interactive_select__") - i += 1 - continue - out.append(token) - i += 1 - return out - def _invoke(args: tuple[str, ...]) -> None: from typer.main import get_command @@ -380,7 +353,6 @@ def _invoke(args: tuple[str, ...]) -> None: p = getattr(p, "parent", None) prog_name = " ".join(reversed(parts)) if parts else cmd_name args_list = list(args) - args_list = _normalize_init_optional_module_flags(args_list) # When the real app is a single command (e.g. drift has only "detect"), Typer # builds a TyperCommand, not a Group. Then args are ["detect", "bundle", "--repo", ...] # and the command expects ["bundle", "--repo", ...] (no leading "detect"). diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py index 44a54bd5..8ffb4af0 100644 --- a/src/specfact_cli/models/module_package.py +++ b/src/specfact_cli/models/module_package.py @@ -152,7 +152,7 @@ class ModulePackageMetadata(BaseModel): ) 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") + source: str = Field(default="builtin", description="Module source: builtin, project, user, marketplace, or custom") @beartype @ensure(lambda result: isinstance(result, list), "Validated bridges must be returned as a list") @@ -163,6 +163,6 @@ def validate_service_bridges(self) -> list[ServiceBridgeMetadata]: @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") + if self.source not in {"builtin", "project", "user", "marketplace", "custom"}: + raise ValueError("source must be one of: builtin, project, user, 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 8492cdfd..fedfe520 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -1,7 +1,7 @@ name: analyze -version: 0.36.1 +version: 0.37.0 commands: - - analyze +- analyze command_help: analyze: Analyze codebase for contract coverage and quality pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Analyze codebase quality, contracts, and architecture signals. license: Apache-2.0 +integrity: + checksum: sha256:3225f0d57a37469d2ce00c3654d3d36ca7d286c32767443aa0704eefed657d9c + signature: jiYU5za8M0Yo80TnH1Hn+FcFFvwdXJrngEHYRmApSiKbLsQyvoNAMA7mFEg9fX7Ly9qV7NBdO2+m5X8YcRhPCw== diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index daa5921c..85841885 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -1,7 +1,7 @@ name: auth -version: 0.36.1 +version: 0.37.0 commands: - - auth +- auth command_help: auth: Authenticate with DevOps providers (GitHub, Azure DevOps) pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Authenticate SpecFact with supported DevOps providers. license: Apache-2.0 +integrity: + checksum: sha256:ca38c983e10c62d8d65f557417a3643409b76e06ebe47fd54d0615582cb3444c + signature: v1TpsqgswbM089IqRINWuPzqpy/lC01kT2xYM+RjLq+GnK9fbix2HRADomZIp1dzan/VB4WWZwX6vekZ+lHLCw== diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index a41313d1..11731732 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -1,7 +1,7 @@ name: backlog -version: 0.36.1 +version: 0.37.0 commands: - - backlog +- backlog command_help: backlog: Backlog refinement and template management pip_dependencies: [] @@ -9,21 +9,24 @@ module_dependencies: [] tier: community core_compatibility: '>=0.28.0,<1.0.0' service_bridges: - - id: ado - converter_class: specfact_cli.modules.backlog.src.adapters.ado.AdoConverter - description: Azure DevOps backlog payload converter - - id: jira - converter_class: specfact_cli.modules.backlog.src.adapters.jira.JiraConverter - description: Jira issue payload converter - - id: linear - converter_class: specfact_cli.modules.backlog.src.adapters.linear.LinearConverter - description: Linear issue payload converter - - id: github - converter_class: specfact_cli.modules.backlog.src.adapters.github.GitHubConverter - description: GitHub issue payload converter +- id: ado + converter_class: specfact_cli.modules.backlog.src.adapters.ado.AdoConverter + description: Azure DevOps backlog payload converter +- id: jira + converter_class: specfact_cli.modules.backlog.src.adapters.jira.JiraConverter + description: Jira issue payload converter +- id: linear + converter_class: specfact_cli.modules.backlog.src.adapters.linear.LinearConverter + description: Linear issue payload converter +- 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 +integrity: + checksum: sha256:13c85ff70048d91a472fc270892a360581226ee74d8c4d7f28d18c192973ed63 + signature: mcnkVMtd9I/osvO69d1v0uiquQ7wYkY7zkacbgrGydvm+1EX3XaiFFf0o1p1G6xZmkx6i3kQmGmyOimU+K1eBg== diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index 72777c43..0cc27aad 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -1,7 +1,7 @@ name: contract -version: 0.36.1 +version: 0.37.0 commands: - - contract +- contract command_help: contract: Manage OpenAPI contracts for project bundles pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Validate and manage API contracts for project bundles. license: Apache-2.0 +integrity: + checksum: sha256:bbf10617fe322aed484e8991d8094b123c71016cdf9077c42f9e705cebdfe967 + signature: ieQoTk1RRFyhVn39kShgzllb4yKDqvxBvK7PPPow45NCYszXRVxTTscQgb4+BFBcccI1uotgOT9MV5d3up9vCg== diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index 3d460769..ca883b81 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -1,7 +1,7 @@ name: drift -version: 0.36.1 +version: 0.37.0 commands: - - drift +- drift command_help: drift: Detect drift between code and specifications pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Detect and report drift between code, plans, and specs. license: Apache-2.0 +integrity: + checksum: sha256:5c7b8bd466f191028545832f975a81936786f5f97fbec04e83b2e64b9a0f0f5e + signature: s/WrxtR9AgiLQemWUHewcjGJOMTUOjLnQ/elDDumPAhiH7SX92AEbQxN6ZCycgCSUAk3i22XVcS30DrSwm72DA== diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 4923bdda..0b7e72e4 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -1,12 +1,12 @@ name: enforce -version: 0.36.1 +version: 0.37.0 commands: - - enforce +- enforce command_help: enforce: Configure quality gates pip_dependencies: [] module_dependencies: - - plan +- plan tier: community core_compatibility: '>=0.28.0,<1.0.0' publisher: @@ -15,3 +15,6 @@ publisher: email: oss@nold.ai description: Apply governance policies and quality gates to bundles. license: Apache-2.0 +integrity: + checksum: sha256:a0489a0b7d89d858ee9e31aa9c4e6bbdc6d514fd1dd0e133adaafdb859dd4000 + signature: cMle25qRpzZNvgMfsdhiJlrHQGk1fsiFXpeiTplRdSVo5xegyzwWvXO/7jb59nBQX2p/JjqKUnGh/A143G57AA== diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index 37db82ba..a1b00875 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -1,12 +1,12 @@ name: generate -version: 0.36.1 +version: 0.37.0 commands: - - generate +- generate command_help: generate: Generate artifacts from SDD and plans pip_dependencies: [] module_dependencies: - - plan +- plan tier: community core_compatibility: '>=0.28.0,<1.0.0' publisher: @@ -15,3 +15,6 @@ publisher: email: oss@nold.ai description: Generate implementation artifacts from plans and SDD. license: Apache-2.0 +integrity: + checksum: sha256:bc1072b0466ba02299e2cc75d92bba741fd34634547731e420ef27f33f2991ef + signature: cBcnxh6LJ9TYiEp29XXoJSFPWqmwHTV/cnCRM0eLJOhBoqI35URW3s+CX71JVGa1agoExlfk+1g6LKsM1Z0kBg== diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index dee22b1f..fa5cf2fd 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -1,7 +1,7 @@ name: import_cmd -version: 0.36.1 +version: 0.37.0 commands: - - import +- import command_help: import: Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Import projects and requirements from code and external tools. license: Apache-2.0 +integrity: + checksum: sha256:322a490faeb941f9b7fd6c321ebfaeadcb19a8c4c3823b0753fc643457117858 + signature: scipNFsYiWjH7U5y/dvxu1bpFg7b2Kb1YN37E4AxW8Y26wKAU0qJqGzsvtuJH8p8v890xRwyR40QAtSA5paWCQ== diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index c2be3770..e610091a 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,7 +1,7 @@ name: init -version: 0.36.1 +version: 0.37.0 commands: - - init +- init command_help: init: Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup) pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 +integrity: + checksum: sha256:cb1eafe05287aca103cd29e87633cbbe23276d2dbf68c9b3a86c23231ec933ce + signature: VFZMmpfqN7zY5yg4mL5Cv6fQFsodE0T1hRWpsk5zlzyk7Zs50vy73tdWo0tmWyTowaKuA5AnxYEQ5sSM4vnMAQ== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 1e7f00cc..b9b46f5a 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -1,14 +1,8 @@ -""" -Init commands for bootstrap, module lifecycle management, and IDE setup. - -`specfact init` handles bootstrap and module enable/disable lifecycle state. -`specfact init ide` handles IDE prompt/template setup and optional dependency installation. -""" +"""Init commands for bootstrap and IDE setup.""" from __future__ import annotations import subprocess -import sys from pathlib import Path from typing import Any @@ -25,16 +19,8 @@ from specfact_cli.contracts.module_interface import ModuleIOContract from specfact_cli.modules import module_io_shim from specfact_cli.registry.help_cache import run_discovery_and_write_cache -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 +from specfact_cli.registry.module_packages import get_discovered_modules_for_state +from specfact_cli.registry.module_state import write_modules_state from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive from specfact_cli.telemetry import telemetry from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager @@ -44,7 +30,6 @@ copy_templates_to_ide, detect_ide, find_package_resources_path, - get_package_installation_locations, ) @@ -126,16 +111,13 @@ 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. Module lifecycle flags under init are deprecated soon; use `specfact module ...` (use `init ide` for IDE setup)" -) +app = typer.Typer(help="Bootstrap SpecFact (use `init ide` for IDE setup; module lifecycle is under `specfact module`)") console = Console() _MODULE_IO_CONTRACT = ModuleIOContract 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 -MODULE_SELECT_SENTINEL = "__interactive_select__" def _install_contract_enhancement_dependencies(repo_path: Path, env_info: EnvManagerInfo) -> None: @@ -440,13 +422,11 @@ def init_ide( @app.callback(invoke_without_command=True) -@require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") @require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") @ensure(lambda result: result is None, "Command should return None") @beartype def init( ctx: click.Context, - # Target/Input repo: Path = typer.Option( Path("."), "--repo", @@ -455,15 +435,6 @@ def init( file_okay=False, dir_okay=True, ), - # Behavior/Options - force: bool = typer.Option( - False, - "--force", - help=( - "Override module dependency safety checks. In force mode, disable cascades to dependents " - "and enable cascades to required dependencies." - ), - ), install_deps: bool = typer.Option( False, "--install-deps", @@ -472,535 +443,41 @@ def init( "for IDE setup flow." ), ), - # Advanced/Configuration - ide: str = typer.Option( - "auto", - "--ide", - help="IDE type (auto, cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - enable_module: list[str] = typer.Option( - [], - "--enable-module", - help=( - "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=( - "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="DEPRECATED soon: list module state (prefer `specfact module list`).", - ), ) -> None: - """ - 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 - safety checks. Use `specfact init ide` for IDE prompt/template setup. - - Examples: - specfact init # Bootstrap and discover modules - specfact init --list-modules # Show enabled/disabled modules - specfact init --enable-module # Interactive module selector (TTY) - specfact init --disable-module sync # Disable explicit module - specfact init --enable-module plan --force # Cascade-enable dependencies - specfact init ide --ide cursor # IDE prompt/template setup - specfact init ide --install-deps # Install contract enhancement dependencies - """ - telemetry_metadata = { - "ide": ide, - "force": force, - "install_deps": install_deps, - "list_modules": list_modules, - } - - with telemetry.track_command("init", telemetry_metadata) as record: + """Bootstrap SpecFact local state.""" + with telemetry.track_command("init", {"install_deps": install_deps}) as _record: if ctx.invoked_subcommand is not None: return + repo_path = repo.resolve() - module_management_requested = any( - [ - bool(enable_module), - bool(disable_module), - list_modules, - ] - ) - enable_ids = list(enable_module) - disable_ids = list(disable_module) - - requested_enable_interactive = MODULE_SELECT_SENTINEL in enable_ids - requested_disable_interactive = MODULE_SELECT_SENTINEL in disable_ids - enable_ids = [mid for mid in enable_ids if mid and mid != MODULE_SELECT_SENTINEL] - disable_ids = [mid for mid in disable_ids if mid and mid != MODULE_SELECT_SENTINEL] - - if is_non_interactive() and (requested_enable_interactive or requested_disable_interactive): - console.print( - "[red]Error:[/red] Non-interactive mode requires explicit module id values. " - "Use --enable-module or --disable-module ." - ) - raise typer.Exit(1) - - if requested_enable_interactive: - discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) - selected = _select_module_ids_interactive("enable", discovered) - enable_ids.extend(selected) - if selected: - module_management_requested = True - - if requested_disable_interactive: - discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) - selected = _select_module_ids_interactive("disable", discovered) - disable_ids.extend(selected) - if selected: - module_management_requested = True - - 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: - for module_id, missing in blocked_enable.items(): - console.print( - f"[red]Error:[/red] Cannot enable '{module_id}': missing required dependencies: " - f"{', '.join(missing)}" - ) - console.print( - "[dim]Hint: Enable dependencies first, or use --force to auto-enable required dependencies[/dim]" - ) - raise typer.Exit(1) - - if disable_ids: - if force: - disable_ids = expand_disable_with_dependents(disable_ids, packages, enabled_map) - blocked = validate_disable_safe(disable_ids, packages, enabled_map) - if blocked and not force: - for module_id, dependents in blocked.items(): - console.print( - f"[red]Error:[/red] Cannot disable '{module_id}': required by enabled modules: " - f"{', '.join(dependents)}" - ) - console.print("[dim]Hint: Disable dependent modules first, or use --force to override[/dim]") - raise typer.Exit(1) - - # Update module state (enable/disable) and persist; then refresh help cache - modules_list = get_discovered_modules_for_state( - enable_ids=enable_ids, - disable_ids=disable_ids, - ) + modules_list = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) if modules_list: write_modules_state(modules_list) - disabled = [m["id"] for m in modules_list if m.get("enabled") is False] - if disabled: - console.print() - console.print( - f"[dim]The following modules are disabled by your configuration: {', '.join(disabled)}. " - "Re-enable with specfact init --enable-module .[/dim]" - ) + run_discovery_and_write_cache(__version__) + if install_deps: env_info = detect_env_manager(repo_path) _install_contract_enhancement_dependencies(repo_path, env_info) - if list_modules: - console.print() - _render_modules_table(modules_list) - return - if module_management_requested: - console.print("[green]✓[/green] Module state updated.") - return + enabled_count = len([m for m in modules_list if bool(m.get("enabled", True))]) disabled_count = len(modules_list) - enabled_count console.print( f"[green]✓[/green] Bootstrap complete. Modules discovered: {len(modules_list)} " f"(enabled={enabled_count}, disabled={disabled_count})." ) + console.print( + "[cyan]Module management has moved to `specfact module`[/cyan] " + "[dim](for example: `specfact module list`, `specfact module init`)[/dim]" + ) _audit_prompt_installation(repo_path) console.print("[dim]Use `specfact init ide` to install/update IDE prompts and settings.[/dim]") - return - - # Resolve repo path - repo_path = repo.resolve() - # Detect IDE - detected_ide = detect_ide(ide) - ide_config = IDE_CONFIG[detected_ide] - ide_name = ide_config["name"] - console.print() - console.print(Panel("[bold cyan]SpecFact IDE Setup[/bold cyan]", border_style="cyan")) - console.print(f"[cyan]Repository:[/cyan] {repo_path}") - console.print(f"[cyan]IDE:[/cyan] {ide_name} ({detected_ide})") - console.print() - - # Check for environment manager - env_info = detect_env_manager(repo_path) - if env_info.manager == EnvManager.UNKNOWN: - console.print() - console.print( - Panel( - "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", - border_style="yellow", - ) - ) - console.print( - "[yellow]SpecFact CLI works best with projects using standard Python project management tools.[/yellow]" - ) - console.print() - console.print("[dim]Supported tools:[/dim]") - console.print(" - hatch (detected from [tool.hatch] in pyproject.toml)") - console.print(" - poetry (detected from [tool.poetry] in pyproject.toml or poetry.lock)") - console.print(" - uv (detected from [tool.uv] in pyproject.toml, uv.lock, or uv.toml)") - console.print(" - pip (detected from requirements.txt or setup.py)") - console.print() - console.print( - "[dim]Note: SpecFact CLI will still work, but commands like 'specfact repro' may use direct tool invocation.[/dim]" - ) - console.print( - "[dim]Consider adding a pyproject.toml with [tool.hatch], [tool.poetry], or [tool.uv] for better integration.[/dim]" - ) - console.print() - - # Install dependencies if requested - if install_deps: - console.print() - console.print(Panel("[bold cyan]Installing Required Packages[/bold cyan]", border_style="cyan")) - if env_info.message: - console.print(f"[dim]{env_info.message}[/dim]") - - required_packages = [ - "beartype>=0.22.4", - "icontract>=2.7.1", - "crosshair-tool>=0.0.97", - "pytest>=8.4.2", - # Sidecar validation tools - # Note: specmatic may need separate installation (Java-based tool) - # Users may need to install specmatic separately: https://specmatic.in/documentation/getting_started.html - ] - console.print("[dim]Installing packages for contract enhancement:[/dim]") - for package in required_packages: - console.print(f" - {package}") - - # Build install command using environment manager detection - install_cmd = ["pip", "install", "-U", *required_packages] - install_cmd = build_tool_command(env_info, install_cmd) - - console.print(f"[dim]Using command: {' '.join(install_cmd)}[/dim]") - - try: - result = subprocess.run( - install_cmd, - capture_output=True, - text=True, - check=False, - cwd=str(repo_path), - timeout=300, # 5 minute timeout - ) - - if result.returncode == 0: - console.print() - console.print("[green]✓[/green] All required packages installed successfully") - record( - { - "deps_installed": True, - "packages_count": len(required_packages), - "env_manager": env_info.manager.value, - } - ) - else: - console.print() - console.print("[yellow]⚠[/yellow] Some packages failed to install") - console.print("[dim]Output:[/dim]") - if result.stdout: - console.print(result.stdout) - if result.stderr: - console.print(result.stderr) - console.print() - console.print("[yellow]You may need to install packages manually:[/yellow]") - # Provide environment-specific guidance - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record( - { - "deps_installed": False, - "error": result.stderr[:200] if result.stderr else "Unknown error", - "env_manager": env_info.manager.value, - } - ) - except subprocess.TimeoutExpired: - console.print() - console.print("[red]Error:[/red] Installation timed out after 5 minutes") - console.print("[yellow]You may need to install packages manually:[/yellow]") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": "timeout", "env_manager": env_info.manager.value}) - except FileNotFoundError: - console.print() - console.print("[red]Error:[/red] pip not found. Please install packages manually:") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": "pip not found", "env_manager": env_info.manager.value}) - except Exception as e: - console.print() - console.print(f"[red]Error:[/red] Failed to install packages: {e}") - console.print("[yellow]You may need to install packages manually:[/yellow]") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": str(e), "env_manager": env_info.manager.value}) - console.print() - - # Find templates directory - # Priority order: - # 1. Development: relative to project root (resources/prompts) - # 2. Installed package: use importlib.resources to find package location - # 3. Fallback: try relative to this file (for edge cases) - templates_dir: Path | None = None - package_templates_dir: Path | None = None - tried_locations: list[Path] = [] - - # Try 1: Development mode - relative to repo root - dev_templates_dir = (repo_path / "resources" / "prompts").resolve() - tried_locations.append(dev_templates_dir) - debug_print(f"[dim]Debug:[/dim] Trying development path: {dev_templates_dir}") - if dev_templates_dir.exists(): - templates_dir = dev_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - debug_print("[dim]Debug:[/dim] Development path not found, trying installed package...") - # Try 2: Installed package - use importlib.resources - # Note: importlib is part of Python's standard library (since Python 3.1) - # importlib.resources.files() is available since Python 3.9 - # Since we require Python >=3.11, this should always be available - # However, we catch exceptions for robustness (minimal installations, edge cases) - package_templates_dir = None - try: - import importlib.resources - - debug_print("[dim]Debug:[/dim] Using importlib.resources.files() API...") - # Use files() API (Python 3.9+) - recommended approach - resources_ref = importlib.resources.files("specfact_cli") - templates_ref = resources_ref / "resources" / "prompts" - # Convert Traversable to Path - # Traversable objects can be converted to Path via str() - # Use resolve() to handle Windows/Linux/macOS path differences - package_templates_dir = Path(str(templates_ref)).resolve() - tried_locations.append(package_templates_dir) - debug_print(f"[dim]Debug:[/dim] Package templates path: {package_templates_dir}") - if package_templates_dir.exists(): - templates_dir = package_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Package templates path exists but directory not found") - except (ImportError, ModuleNotFoundError) as e: - console.print( - f"[yellow]⚠[/yellow] importlib.resources not available or module not found: {type(e).__name__}: {e}" - ) - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - except (TypeError, AttributeError, ValueError) as e: - console.print(f"[yellow]⚠[/yellow] Error converting Traversable to Path: {e}") - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Unexpected error with importlib.resources: {type(e).__name__}: {e}") - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - - # Fallback: importlib.util.find_spec() + comprehensive package location search - if not templates_dir or not templates_dir.exists(): - try: - import importlib.util - - debug_print("[dim]Debug:[/dim] Using importlib.util.find_spec() fallback...") - spec = importlib.util.find_spec("specfact_cli") - if spec and spec.origin: - # spec.origin points to __init__.py - # Go up to package root, then to resources/prompts - # Use resolve() for cross-platform compatibility - package_root = Path(spec.origin).parent.resolve() - package_templates_dir = (package_root / "resources" / "prompts").resolve() - tried_locations.append(package_templates_dir) - debug_print(f"[dim]Debug:[/dim] Package root from spec.origin: {package_root}") - debug_print(f"[dim]Debug:[/dim] Templates path from spec: {package_templates_dir}") - if package_templates_dir.exists(): - templates_dir = package_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Templates path from spec not found") - else: - console.print("[yellow]⚠[/yellow] Could not find specfact_cli module spec") - if spec is None: - debug_print("[dim]Debug:[/dim] spec is None") - elif not spec.origin: - debug_print("[dim]Debug:[/dim] spec.origin is None or empty") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error with importlib.util.find_spec(): {type(e).__name__}: {e}") - - # Fallback: Comprehensive package location search (cross-platform) - if not templates_dir or not templates_dir.exists(): - try: - debug_print("[dim]Debug:[/dim] Searching all package installation locations...") - package_locations = get_package_installation_locations("specfact_cli") - debug_print(f"[dim]Debug:[/dim] Found {len(package_locations)} possible package location(s)") - for i, loc in enumerate(package_locations, 1): - debug_print(f"[dim]Debug:[/dim] {i}. {loc}") - # Check for resources/prompts in this package location - resource_path = (loc / "resources" / "prompts").resolve() - tried_locations.append(resource_path) - if resource_path.exists(): - templates_dir = resource_path - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - break - if not templates_dir or not templates_dir.exists(): - # Try using the helper function as a final attempt - debug_print("[dim]Debug:[/dim] Trying find_package_resources_path() helper...") - resource_path = find_package_resources_path("specfact_cli", "resources/prompts") - if resource_path and resource_path.exists(): - tried_locations.append(resource_path) - templates_dir = resource_path - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Resources not found in any package location") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error searching package locations: {type(e).__name__}: {e}") - - # Try 3: Fallback - relative to this file (for edge cases) - if not templates_dir or not templates_dir.exists(): - try: - debug_print("[dim]Debug:[/dim] Trying fallback: relative to __file__...") - # Get the directory containing this file (init.py) - # init.py is in: src/specfact_cli/commands/init.py - # Go up: commands -> specfact_cli -> src -> project root - current_file = Path(__file__).resolve() - fallback_dir = (current_file.parent.parent.parent.parent / "resources" / "prompts").resolve() - tried_locations.append(fallback_dir) - debug_print(f"[dim]Debug:[/dim] Current file: {current_file}") - debug_print(f"[dim]Debug:[/dim] Fallback templates path: {fallback_dir}") - if fallback_dir.exists(): - templates_dir = fallback_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Fallback path not found") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error with __file__ fallback: {type(e).__name__}: {e}") - - if templates_dir and templates_dir.exists() and is_debug_mode(): - debug_log_operation("template_resolution", str(templates_dir), "success") - if not templates_dir or not templates_dir.exists(): - if is_debug_mode() and tried_locations: - debug_log_operation( - "template_resolution", - str(tried_locations[-1]) if tried_locations else "unknown", - "failure", - error="Templates directory not found after all attempts", - ) - console.print() - console.print("[red]Error:[/red] Templates directory not found after all attempts") - console.print() - console.print("[yellow]Tried locations:[/yellow]") - for i, location in enumerate(tried_locations, 1): - exists = "✓" if location.exists() else "✗" - console.print(f" {i}. {exists} {location}") - console.print() - console.print("[yellow]Debug information:[/yellow]") - console.print(f" - Python version: {sys.version}") - console.print(f" - Platform: {sys.platform}") - console.print(f" - Current working directory: {Path.cwd()}") - console.print(f" - Repository path: {repo_path}") - console.print(f" - __file__ location: {Path(__file__).resolve()}") - try: - import importlib.util - - spec = importlib.util.find_spec("specfact_cli") - if spec: - console.print(f" - Module spec found: {spec}") - console.print(f" - Module origin: {spec.origin}") - if spec.origin: - console.print(f" - Module location: {Path(spec.origin).parent.resolve()}") - else: - console.print(" - Module spec: Not found") - except Exception as e: - console.print(f" - Error checking module spec: {e}") - console.print() - console.print("[yellow]Expected location:[/yellow] resources/prompts/") - console.print("[yellow]Please ensure SpecFact is properly installed.[/yellow]") - raise typer.Exit(1) - - console.print(f"[cyan]Templates:[/cyan] {templates_dir}") - console.print() - - # Copy templates to IDE location - try: - copied_files, settings_path = copy_templates_to_ide(repo_path, detected_ide, templates_dir, force) - - if not copied_files: - console.print( - "[yellow]No templates copied (all files already exist, use --force to overwrite)[/yellow]" - ) - record({"files_copied": 0, "already_exists": True}) - raise typer.Exit(0) - - record( - { - "detected_ide": detected_ide, - "files_copied": len(copied_files), - "settings_updated": settings_path is not None, - } - ) - - console.print() - console.print(Panel("[bold green]✓ Initialization Complete[/bold green]", border_style="green")) - console.print(f"[green]Copied {len(copied_files)} template(s) to {ide_config['folder']}[/green]") - if settings_path: - console.print(f"[green]Updated VS Code settings:[/green] {settings_path}") - console.print() - - # Copy backlog field mapping templates - _copy_backlog_field_mapping_templates(repo_path, force, console) - - console.print() - console.print("[dim]You can now use SpecFact slash commands in your IDE![/dim]") - console.print("[dim]Example: /specfact.01-import --bundle legacy-api --repo .[/dim]") - - except Exception as e: - console.print(f"[red]Error:[/red] Failed to initialize IDE integration: {e}") - raise typer.Exit(1) from e +__all__ = [ + "app", + "export_from_bundle", + "import_to_bundle", + "sync_with_bundle", + "validate_bundle", +] diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 3fef3bc7..99100064 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -1,7 +1,7 @@ name: migrate -version: 0.36.1 +version: 0.37.0 commands: - - migrate +- migrate command_help: migrate: Migrate project bundles between formats pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Migrate project bundles across supported structure versions. license: Apache-2.0 +integrity: + checksum: sha256:528f7cd48964162e33f31871758e9d613cd96dd06a1344069945c7e603bccf2a + signature: bKrfU8SGoXCn6LBfLPV/4kXzk6djWA3uCs1Uq2ENx2bXrN1gjr+3f7cateZ6WBxw1Kz52QO7xGxqvw8g9DFfCw== diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index 74b5ad32..f0ac34d6 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -1,7 +1,7 @@ name: module-registry -version: 0.36.1 +version: 0.37.0 commands: - - module +- module command_help: module: Manage marketplace modules (install, uninstall, search, list, show, upgrade) pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 +integrity: + checksum: sha256:56470e8cd5f5e64c93b51e7b7e7e831917b0da1ceee51a7e61d24e31d4127fc8 + signature: kB+2MVbab+icq5S0dgrm8/fTKF3Qo4T9EfBVn8nYPcr5zyh7oJKqA5Ja5FYXpL8MA1uLM0I2K/Gyuy3kTaW6DA== diff --git a/src/specfact_cli/modules/module_registry/src/commands.py b/src/specfact_cli/modules/module_registry/src/commands.py index 71c08a81..244e61c2 100644 --- a/src/specfact_cli/modules/module_registry/src/commands.py +++ b/src/specfact_cli/modules/module_registry/src/commands.py @@ -3,6 +3,8 @@ from __future__ import annotations import inspect +import shutil +from pathlib import Path import typer from beartype import beartype @@ -12,13 +14,21 @@ 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_installer import ( + USER_MODULES_ROOT, + get_bundled_module_metadata, + install_bundled_module, + install_module, + sync_bundled_modules_to_user_root, + 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.module_security import ensure_publisher_trusted from specfact_cli.registry.registry import CommandRegistry from specfact_cli.runtime import is_non_interactive @@ -27,22 +37,88 @@ console = Console() +@app.command(name="init") +@beartype +def init_modules( + scope: str = typer.Option("user", "--scope", help="Bootstrap scope: user or project"), + repo: Path | None = typer.Option(None, "--repo", help="Repository path for project scope (default: current dir)"), + trust_non_official: bool = typer.Option( + False, + "--trust-non-official", + help="Trust and persist non-official publishers for this bootstrap operation", + ), +) -> None: + """Bootstrap shipped module artifacts into user or project module root.""" + scope_normalized = scope.strip().lower() + if scope_normalized not in {"user", "project"}: + console.print("[red]Invalid scope. Use 'user' or 'project'.[/red]") + raise typer.Exit(1) + + target_root = USER_MODULES_ROOT + if scope_normalized == "project": + repo_path = (repo or Path.cwd()).resolve() + target_root = repo_path / ".specfact" / "modules" + + try: + seeded = sync_bundled_modules_to_user_root( + target_root=target_root, + trust_non_official=trust_non_official, + non_interactive=is_non_interactive(), + ) + except OSError as exc: + console.print(f"[red]Failed to seed modules into {target_root}: {exc}[/red]") + raise typer.Exit(1) from exc + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"[green]Seeded {seeded} module(s) into {target_root}[/green]") + + @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"), + scope: str = typer.Option("user", "--scope", help="Install scope: user or project"), + source: str = typer.Option("auto", "--source", help="Install source: auto, bundled, or marketplace"), + repo: Path | None = typer.Option(None, "--repo", help="Repository path for project scope (default: current dir)"), + trust_non_official: bool = typer.Option( + False, + "--trust-non-official", + help="Trust and persist non-official publisher for this module install", + ), ) -> None: - """Install a module from marketplace registry.""" + """Install a module from bundled artifacts or marketplace registry.""" + scope_normalized = scope.strip().lower() + if scope_normalized not in {"user", "project"}: + console.print("[red]Invalid scope. Use 'user' or 'project'.[/red]") + raise typer.Exit(1) + source_normalized = source.strip().lower() + if source_normalized not in {"auto", "bundled", "marketplace"}: + console.print("[red]Invalid source. Use 'auto', 'bundled', or 'marketplace'.[/red]") + raise typer.Exit(1) + + repo_path = (repo or Path.cwd()).resolve() + target_root = USER_MODULES_ROOT if scope_normalized == "user" else repo_path / ".specfact" / "modules" + 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] + if (target_root / requested_name / "module-package.yaml").exists(): + console.print(f"[yellow]Module '{requested_name}' is already installed in {target_root}.[/yellow]") + return + 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": + skip_sources = {"builtin", "project", "user", "custom"} + if scope_normalized == "project": + skip_sources.discard("user") + if scope_normalized == "user": + skip_sources.discard("project") + if existing is not None and existing.source in skip_sources: console.print( f"[yellow]Module '{requested_name}' is already available from source '{existing.source}'. " "No marketplace install needed.[/yellow]" @@ -50,7 +126,29 @@ def install( return try: - installed_path = install_module(normalized, version=version) + if source_normalized in {"auto", "bundled"} and install_bundled_module( + requested_name, + target_root=target_root, + trust_non_official=trust_non_official, + non_interactive=is_non_interactive(), + ): + console.print(f"[green]Installed bundled module[/green] {requested_name} -> {target_root / requested_name}") + return + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + if source_normalized == "bundled": + console.print(f"[red]Bundled module '{requested_name}' was not found in packaged bundled sources.[/red]") + raise typer.Exit(1) + + try: + installed_path = install_module( + normalized, + version=version, + install_root=target_root, + trust_non_official=trust_non_official, + non_interactive=is_non_interactive(), + ) except Exception as exc: console.print(f"[red]Failed installing {normalized}: {exc}[/red]") raise typer.Exit(1) from exc @@ -59,7 +157,11 @@ def install( @app.command() @beartype -def uninstall(module_name: str = typer.Argument(..., help="Installed module name (name or namespace/name)")) -> None: +def uninstall( + module_name: str = typer.Argument(..., help="Installed module name (name or namespace/name)"), + scope: str | None = typer.Option(None, "--scope", help="Uninstall scope: user or project"), + repo: Path | None = typer.Option(None, "--repo", help="Repository path for project scope (default: current dir)"), +) -> None: """Uninstall a marketplace module.""" normalized = module_name if "/" in normalized: @@ -68,6 +170,47 @@ def uninstall(module_name: str = typer.Argument(..., help="Installed module name raise typer.Exit(1) normalized = normalized.split("/", 1)[1] + scope_normalized = scope.strip().lower() if scope else None + if scope_normalized is not None and scope_normalized not in {"user", "project"}: + console.print("[red]Invalid scope. Use 'user' or 'project'.[/red]") + raise typer.Exit(1) + + repo_path = (repo or Path.cwd()).resolve() + project_root = repo_path / ".specfact" / "modules" + user_root = USER_MODULES_ROOT + project_module_dir = project_root / normalized + user_module_dir = user_root / normalized + project_exists = project_module_dir.exists() + user_exists = user_module_dir.exists() + + if scope_normalized is None: + if project_exists and user_exists: + console.print( + f"[red]Module '{normalized}' exists in both user and project module roots. " + "Use --scope user or --scope project to uninstall the correct copy.[/red]" + ) + raise typer.Exit(1) + if project_exists: + scope_normalized = "project" + elif user_exists: + scope_normalized = "user" + + if scope_normalized == "project": + if not project_exists: + console.print(f"[red]Module '{normalized}' is not installed in project scope ({project_root}).[/red]") + raise typer.Exit(1) + shutil.rmtree(project_module_dir) + console.print(f"[green]Uninstalled[/green] {normalized} from {project_root}") + return + + if scope_normalized == "user": + if not user_exists: + console.print(f"[red]Module '{normalized}' is not installed in user scope ({user_root}).[/red]") + raise typer.Exit(1) + shutil.rmtree(user_module_dir) + console.print(f"[green]Uninstalled[/green] {normalized} from {user_root}") + return + 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" @@ -77,10 +220,12 @@ def uninstall(module_name: str = typer.Argument(..., help="Installed module name f"[red]Cannot uninstall built-in module '{normalized}'. Use `specfact module disable {normalized}` instead.[/red]" ) raise typer.Exit(1) - if source == "custom": + if source in {"project", "custom"}: + user_modules_root = str(USER_MODULES_ROOT) 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]" + f"[red]Cannot uninstall {source} module '{normalized}' via marketplace uninstall. " + f"Remove it from your local module roots (workspace `.specfact/modules`, `{user_modules_root}`, " + "or custom module roots).[/red]" ) raise typer.Exit(1) if source == "unknown": @@ -103,6 +248,11 @@ def uninstall(module_name: str = typer.Argument(..., help="Installed module name 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"), + trust_non_official: bool = typer.Option( + False, + "--trust-non-official", + help="Trust and persist non-official publishers while enabling modules", + ), ) -> None: """Enable modules in lifecycle state registry.""" enable_ids: list[str] @@ -117,7 +267,17 @@ def enable( if not enable_ids: return + modules_by_id = {str(module.get("id", "")): module for module in get_modules_with_state()} try: + for selected in enable_ids: + selected_row = modules_by_id.get(selected) + if selected_row is None: + continue + ensure_publisher_trusted( + str(selected_row.get("publisher", "")).strip() or None, + trust_non_official=trust_non_official, + non_interactive=is_non_interactive(), + ) apply_module_state_update(enable_ids=enable_ids, disable_ids=[], force=force) except ValueError as exc: console.print(f"[red]{exc}[/red]") @@ -338,8 +498,15 @@ def _derive_module_command_entries(metadata: object) -> list[tuple[str, str]]: @app.command(name="list") @beartype def list_modules( - source: str | None = typer.Option(None, "--source", help="Filter by origin: builtin, marketplace, custom"), + source: str | None = typer.Option( + None, "--source", help="Filter by origin: builtin, project, user, marketplace, custom" + ), show_origin: bool = typer.Option(False, "--show-origin", help="Show raw origin column in addition to trust"), + show_bundled_available: bool = typer.Option( + False, + "--show-bundled-available", + help="Show bundled modules available in package artifacts but not installed in active roots", + ), ) -> None: """List installed modules with trust labels and optional origin details.""" modules = get_modules_with_state() @@ -347,6 +514,32 @@ def list_modules( modules = [m for m in modules if str(m.get("source", "")) == source] render_modules_table(console, modules, show_origin=show_origin) + bundled = get_bundled_module_metadata() + installed_ids = {str(module.get("id", "")).strip() for module in get_modules_with_state()} + available = [meta for name, meta in bundled.items() if name not in installed_ids] + if not show_bundled_available: + if available: + console.print( + "[dim]Bundled modules are available but not installed. " + "Use `specfact module list --show-bundled-available` to inspect them.[/dim]" + ) + return + + if not available: + console.print("[dim]All bundled modules are already installed in active module roots.[/dim]") + return + + available.sort(key=lambda meta: meta.name.lower()) + table = Table(title="Bundled Modules Available (Not Installed)") + table.add_column("Module", style="cyan") + table.add_column("Version", style="magenta") + table.add_column("Description", style="white") + for metadata in available: + table.add_row(metadata.name, metadata.version, metadata.description or "") + console.print(table) + console.print("[dim]Install bundled modules into user scope: specfact module init[/dim]") + console.print("[dim]Install bundled modules into project scope: specfact module init --scope project[/dim]") + @app.command() @beartype diff --git a/src/specfact_cli/modules/patch_mode/module-package.yaml b/src/specfact_cli/modules/patch_mode/module-package.yaml index cf8bbf42..4a8cf367 100644 --- a/src/specfact_cli/modules/patch_mode/module-package.yaml +++ b/src/specfact_cli/modules/patch_mode/module-package.yaml @@ -1,9 +1,10 @@ name: patch-mode -version: 0.36.1 +version: 0.37.0 commands: - - patch +- 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 @@ -14,3 +15,6 @@ publisher: email: oss@nold.ai description: Prepare, review, and apply structured repository patches safely. license: Apache-2.0 +integrity: + checksum: sha256:44ab2e64473ab7b31086ee0f3f2153aa545b7bacd628cff844576c6ac028bdf7 + signature: uNXdthnZTJp0GbFvZ4Jm7jlV/1w0UfSKiME1/TNvLaV1SUOrGTd0nJCEZCVxnhxflIvzB/+CRy1VP5fIl22OBg== diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index 05d5dc02..2cd0d422 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -1,12 +1,12 @@ name: plan -version: 0.36.1 +version: 0.37.0 commands: - - plan +- plan command_help: plan: Manage development plans pip_dependencies: [] module_dependencies: - - sync +- sync tier: community core_compatibility: '>=0.28.0,<1.0.0' publisher: @@ -15,3 +15,6 @@ publisher: email: oss@nold.ai description: Create and manage implementation plans for project execution. license: Apache-2.0 +integrity: + checksum: sha256:4119e9b502b8d69d53a0335501d169a45a2c2fac64268a99e789b393fa2ac577 + signature: Ei+Ec2srSgm4S5ww4EPviCjOSM2U5rFAzZMNIRZYU1UGpdKlVgOGdKd7vOH6i9jblTSwxccKNLfylW+RFKfkDw== diff --git a/src/specfact_cli/modules/policy_engine/module-package.yaml b/src/specfact_cli/modules/policy_engine/module-package.yaml index 04a6620c..d0b8da9b 100644 --- a/src/specfact_cli/modules/policy_engine/module-package.yaml +++ b/src/specfact_cli/modules/policy_engine/module-package.yaml @@ -1,7 +1,7 @@ name: policy-engine -version: 0.36.1 +version: 0.37.0 commands: - - policy +- policy command_help: policy: Policy validation and suggestion workflows (DoR/DoD/Flow/PI) pip_dependencies: [] @@ -9,16 +9,17 @@ module_dependencies: [] 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. +- target: ProjectBundle + field: policy_engine_policy_status + 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 + checksum: sha256:bbdccc5c364a28ff3b66123e31dbd685c4a2d783a8a13a888ca4a4aa5d0841aa + signature: RGQscLzQBcNOfqa5Gq5mpmGHjl2zdW4DFMBMpatNQSIqNhtnZEHUTydYbQcyWpWRzHaSvCUtTOhgjq4CpNJYCw== 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 7fda05d3..71af0888 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -1,7 +1,7 @@ name: project -version: 0.36.1 +version: 0.37.0 commands: - - project +- project command_help: project: Manage project bundles with persona workflows pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Manage project bundles, contexts, and lifecycle workflows. license: Apache-2.0 +integrity: + checksum: sha256:239c30e346e7f4d752cc2729e264d13fdf1cc92543f3630147fbd98f129a2db3 + signature: /atX+KwwHILB8ehUlk7Z0VI0o4dsAWmxiWL/BMduJ0PBwEs5pBfR0NGfBW/lpnkzgsStW60kwYA8hhxsTs4GDg== diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index e5bc6189..a1c33951 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -1,7 +1,7 @@ name: repro -version: 0.36.1 +version: 0.37.0 commands: - - repro +- repro command_help: repro: Run validation suite pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Run reproducible validation and diagnostics workflows end-to-end. license: Apache-2.0 +integrity: + checksum: sha256:6e51217b0d5157827dc6dbb120a5ed8ae58e7e11c71b00ff09ae2599e8a33942 + signature: chxWJIjruzEbRUb8A6BOVA9aprfogBRwgZjpTRcvScmaqZ9GoBocIqbZNjbBgxKVonUAdRWicGV84hjQ2HxaDA== diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 9ba5e0bd..124ef7fb 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -1,7 +1,7 @@ name: sdd -version: 0.36.1 +version: 0.37.0 commands: - - sdd +- sdd command_help: sdd: Manage SDD (Spec-Driven Development) manifests pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Create and validate Spec-Driven Development manifests and mappings. license: Apache-2.0 +integrity: + checksum: sha256:a8a530c2b4637fadce5ab205bde13c657e2c3571863fa2c2fa798550b24796b2 + signature: TsD0B3anf1qpWitiJ/mmtc96pYy2U2QUgTDq4HRkLSt/2DbfmlVbAMsEqQM6EzMy73V+Xxo45BP4L/gPPV+yCg== diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index 8c78242d..eb31d734 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -1,7 +1,7 @@ name: spec -version: 0.36.1 +version: 0.37.0 commands: - - spec +- spec command_help: spec: Specmatic integration for API contract testing pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Integrate and run API specification and contract checks. license: Apache-2.0 +integrity: + checksum: sha256:1e8693dddcb3688413378fb8eead352fec21bba95a517cfcb5330b36af05436a + signature: zjI4qvXPw+NlYG6RtDhxKMu5tRijkk6ZUXBvAz3mbdN3lgUN32fddJ4RK4UPt0i5PvlEwWZvcHWL4mHcpConBA== diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 09f23fd9..8e893979 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -1,13 +1,14 @@ name: sync -version: 0.36.1 +version: 0.37.0 commands: - - sync +- 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 +- plan +- sdd tier: community core_compatibility: '>=0.28.0,<1.0.0' publisher: @@ -16,3 +17,6 @@ publisher: email: oss@nold.ai description: Synchronize repository state with connected external systems. license: Apache-2.0 +integrity: + checksum: sha256:4591703ea03bcd89ea6d950b5ac4ce231ae47af9b4bc69704d5cd3acf708f38d + signature: xJnWHRCNnfUh6+Xsn29Y9lRhL66xfRPgbXlITVDZpgerMVg2uH2/SiTAPoRTxI/CHXKMCyD3Sq5zcbHG6KiyBw== diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index 3d51ff08..c0a35ca5 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,7 +1,7 @@ name: upgrade -version: 0.36.1 +version: 0.37.0 commands: - - upgrade +- upgrade command_help: upgrade: Check for and install SpecFact CLI updates pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Check and apply SpecFact CLI version upgrades. license: Apache-2.0 +integrity: + checksum: sha256:fe896330e409c951ce5395f3f92b91c2e787d17ecb3c200936ccd65606721023 + signature: U6RESOJ+anV2xa4YKtdoI1v9Vg4q7/KUxQn1BVNhQ2w/XGqKB0Jb+OWGbIn7hK3Z56ioczVNbcfqkbCpCxTVBg== diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 9198dce2..3b80e7af 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -1,7 +1,7 @@ name: validate -version: 0.36.1 +version: 0.37.0 commands: - - validate +- validate command_help: validate: Validation commands including sidecar validation pip_dependencies: [] @@ -14,3 +14,6 @@ publisher: email: oss@nold.ai description: Run schema, contract, and workflow validation suites. license: Apache-2.0 +integrity: + checksum: sha256:22066b811f0bf3bcda202e4b1b8087d4183e268b64284441b04efe49f21c2e0a + signature: 4OnHXb/NqELblXF8/nOj9Freb5hC2n29bs46Emf9JrmpD3uO8XJPJN/5pb/KZlWUan02I3WoyYybkl5xX+fpBg== diff --git a/src/specfact_cli/registry/module_discovery.py b/src/specfact_cli/registry/module_discovery.py index 38863bee..55d8380a 100644 --- a/src/specfact_cli/registry/module_discovery.py +++ b/src/specfact_cli/registry/module_discovery.py @@ -1,4 +1,4 @@ -"""Module discovery across built-in, marketplace, and custom roots.""" +"""Module discovery across built-in, user, marketplace, and custom roots.""" from __future__ import annotations @@ -12,6 +12,7 @@ from specfact_cli.models.module_package import ModulePackageMetadata +USER_MODULES_ROOT = Path.home() / ".specfact" / "modules" MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" CUSTOM_MODULES_ROOT = Path.home() / ".specfact" / "custom-modules" @@ -29,31 +30,46 @@ class DiscoveredModule: @ensure(lambda result: isinstance(result, list), "Discovery result must be a list") def discover_all_modules( builtin_root: Path | None = None, + user_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 + from specfact_cli.registry.module_packages import ( + discover_package_metadata, + get_modules_root, + get_modules_roots, + get_workspace_modules_root, + ) logger = get_bridge_logger(__name__) discovered: list[DiscoveredModule] = [] seen_names: set[str] = set() effective_builtin_root = builtin_root or get_modules_root() + effective_project_root = get_workspace_modules_root() + effective_user_root = user_root or USER_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), - ] + roots: list[tuple[str, Path]] = [("builtin", effective_builtin_root)] + if effective_project_root is not None: + roots.append(("project", effective_project_root)) + roots.extend( + [ + ("user", effective_user_root), + ("marketplace", effective_marketplace_root), + ("custom", effective_custom_root), + ] + ) # Keep legacy discovery roots (workspace-level + SPECFACT_MODULES_ROOTS) as custom sources. # 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 + include_legacy_roots = ( + builtin_root is None and user_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} @@ -72,7 +88,7 @@ def discover_all_modules( for package_dir, metadata in entries: module_name = metadata.name if module_name in seen_names: - if source in {"marketplace", "custom"}: + if source in {"user", "marketplace", "custom"}: logger.warning( "Module '%s' from %s at '%s' is shadowed by higher-priority source.", module_name, diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index d4083332..6b1ff78c 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -1,7 +1,9 @@ -"""Module artifact verification and marketplace installation workflows.""" +"""Module artifact verification, user-root bootstrap, and installation workflows.""" from __future__ import annotations +import hashlib +import os import shutil import tarfile import tempfile @@ -16,11 +18,254 @@ 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.crypto_validator import verify_checksum, verify_signature from specfact_cli.registry.marketplace_client import download_module +from specfact_cli.registry.module_security import assert_module_allowed, ensure_publisher_trusted +USER_MODULES_ROOT = Path.home() / ".specfact" / "modules" MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +_IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} + + +@beartype +def _bundled_public_key_path() -> Path: + """Resolve default bundled public key path for module signature verification. + + Resolution order: + 1. Repository source path (`./resources/keys/...`) for local/dev runs. + 2. Installed package path (`specfact_cli/resources/keys/...`) for wheel installs. + """ + key_name = "module-signing-public.pem" + candidates = [ + Path(__file__).resolve().parents[3] / "resources" / "keys" / key_name, + Path(__file__).resolve().parents[1] / "resources" / "keys" / key_name, + ] + for path in candidates: + if path.exists(): + return path + return candidates[0] + + +@beartype +def _load_public_key_pem(public_key_pem: str | None = None) -> str: + """Resolve public key PEM from explicit arg, env var, or bundled key file.""" + explicit = (public_key_pem or "").strip() + if explicit: + return explicit + env_value = os.environ.get("SPECFACT_MODULE_PUBLIC_KEY_PEM", "").strip() + if env_value: + return env_value + key_path = _bundled_public_key_path() + if key_path.exists(): + return key_path.read_text(encoding="utf-8").strip() + return "" + + +@beartype +def _get_bundled_module_sources() -> dict[str, Path]: + """Return bundled module source directories keyed by module name.""" + from specfact_cli.registry.module_packages import discover_package_metadata + + source_roots: list[Path] = [] + package_modules_root = Path(__file__).resolve().parents[1] / "resources" / "modules" + if package_modules_root.exists(): + source_roots.append(package_modules_root) + workspace_modules_root = Path(__file__).resolve().parents[3] / "modules" + if workspace_modules_root.exists(): + source_roots.append(workspace_modules_root) + + sources: dict[str, Path] = {} + for source_root in source_roots: + for module_dir, metadata in discover_package_metadata(source_root): + sources.setdefault(metadata.name, module_dir) + return sources + + +@beartype +@ensure(lambda result: isinstance(result, dict), "Bundled module metadata mapping must be a dict") +def get_bundled_module_metadata() -> dict[str, ModulePackageMetadata]: + """Return bundled module metadata keyed by module name.""" + from specfact_cli.registry.module_packages import discover_package_metadata + + metadata_by_name: dict[str, ModulePackageMetadata] = {} + for source_root in {path.parent for path in _get_bundled_module_sources().values()}: + for _module_dir, metadata in discover_package_metadata(source_root): + metadata_by_name.setdefault(metadata.name, metadata) + return metadata_by_name + + +@beartype +def _canonical_manifest_payload(manifest_path: Path) -> bytes: + """Build deterministic manifest payload for checksum/signature verification.""" + parsed = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(parsed, dict): + raise ValueError("Invalid module manifest format") + parsed.pop("integrity", None) + return yaml.safe_dump(parsed, sort_keys=True, allow_unicode=False).encode("utf-8") + + +@beartype +def _module_artifact_payload(package_dir: Path) -> bytes: + """Build deterministic legacy payload hash source from all files in module directory.""" + if not package_dir.exists() or not package_dir.is_dir(): + raise ValueError(f"Module directory not found: {package_dir}") + + entries: list[str] = [] + for path in sorted( + (p for p in package_dir.rglob("*") if p.is_file()), + key=lambda p: p.relative_to(package_dir).as_posix(), + ): + rel = path.relative_to(package_dir).as_posix() + data = ( + _canonical_manifest_payload(path) if rel in {"module-package.yaml", "metadata.yaml"} else path.read_bytes() + ) + file_digest = hashlib.sha256(data).hexdigest() + entries.append(f"{rel}:{file_digest}") + return "\n".join(entries).encode("utf-8") + + +@beartype +def _module_artifact_payload_stable(package_dir: Path) -> bytes: + """Build deterministic payload excluding generated/cache files.""" + if not package_dir.exists() or not package_dir.is_dir(): + raise ValueError(f"Module directory not found: {package_dir}") + + def _is_hashable(path: Path) -> bool: + rel = path.relative_to(package_dir) + if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): + return False + return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES + + entries: list[str] = [] + for path in sorted( + (p for p in package_dir.rglob("*") if p.is_file() and _is_hashable(p)), + key=lambda p: p.relative_to(package_dir).as_posix(), + ): + rel = path.relative_to(package_dir).as_posix() + data = ( + _canonical_manifest_payload(path) if rel in {"module-package.yaml", "metadata.yaml"} else path.read_bytes() + ) + file_digest = hashlib.sha256(data).hexdigest() + entries.append(f"{rel}:{file_digest}") + return "\n".join(entries).encode("utf-8") + + +@beartype +def install_bundled_module( + module_name: str, + target_root: Path, + *, + trust_non_official: bool = False, + non_interactive: bool = False, +) -> bool: + """Install one bundled module into target root; returns False when module is not bundled.""" + sources = _get_bundled_module_sources() + source_dir = sources.get(module_name) + if source_dir is None: + return False + assert_module_allowed(module_name) + metadata = get_bundled_module_metadata().get(module_name) + if metadata is None: + raise ValueError(f"Bundled module '{module_name}' metadata not found") + publisher_name = metadata.publisher.name if metadata.publisher else None + ensure_publisher_trusted( + publisher_name, + trust_non_official=trust_non_official, + non_interactive=non_interactive, + ) + allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in {"1", "true", "yes"} + if not verify_module_artifact( + source_dir, + metadata, + allow_unsigned=allow_unsigned, + require_integrity=True, + ): + raise ValueError(f"Bundled module '{module_name}' failed integrity verification") + target_root.mkdir(parents=True, exist_ok=True) + _copy_module_dir(source_dir, target_root / module_name) + return True + + +@beartype +@ensure(lambda result: isinstance(result, list), "Result must be a list") +def get_outdated_or_missing_bundled_modules(target_root: Path) -> list[str]: + """Return bundled module names missing or outdated under target root.""" + outdated_or_missing: list[str] = [] + sources = _get_bundled_module_sources() + for module_name, source_dir in sources.items(): + source_manifest = source_dir / "module-package.yaml" + target_manifest = target_root / module_name / "module-package.yaml" + if not target_manifest.exists(): + outdated_or_missing.append(module_name) + continue + if source_manifest.read_text(encoding="utf-8") != target_manifest.read_text(encoding="utf-8"): + outdated_or_missing.append(module_name) + return sorted(outdated_or_missing, key=str.lower) + + +@beartype +def _copy_module_dir(source_dir: Path, target_dir: Path) -> bool: + """Copy/update one module directory; returns True when target changed.""" + source_manifest = source_dir / "module-package.yaml" + target_manifest = target_dir / "module-package.yaml" + + if not target_dir.exists(): + shutil.copytree(source_dir, target_dir) + return True + + source_manifest_text = source_manifest.read_text(encoding="utf-8") + target_manifest_text = target_manifest.read_text(encoding="utf-8") if target_manifest.exists() else "" + if source_manifest_text == target_manifest_text: + return False + + staged_path = target_dir.parent / f".{target_dir.name}.tmp-sync" + if staged_path.exists(): + shutil.rmtree(staged_path) + shutil.copytree(source_dir, staged_path) + if target_dir.exists(): + shutil.rmtree(target_dir) + staged_path.replace(target_dir) + return True + + +@beartype +@ensure(lambda result: result >= 0, "Seeded module count must be non-negative") +def sync_bundled_modules_to_user_root( + target_root: Path | None = None, + *, + trust_non_official: bool = False, + non_interactive: bool = False, +) -> int: + """Seed/update shipped modules into canonical user root (~/.specfact/modules).""" + target = target_root or USER_MODULES_ROOT + target.mkdir(parents=True, exist_ok=True) + copied = 0 + + metadata_by_name = get_bundled_module_metadata() + allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in {"1", "true", "yes"} + for module_name, module_dir in _get_bundled_module_sources().items(): + assert_module_allowed(module_name) + metadata = metadata_by_name.get(module_name) + if metadata is None: + raise ValueError(f"Bundled module '{module_name}' metadata not found") + publisher_name = metadata.publisher.name if metadata.publisher else None + ensure_publisher_trusted( + publisher_name, + trust_non_official=trust_non_official, + non_interactive=non_interactive, + ) + if not verify_module_artifact( + module_dir, + metadata, + allow_unsigned=allow_unsigned, + require_integrity=True, + ): + raise ValueError(f"Bundled module '{module_name}' failed integrity verification") + if _copy_module_dir(module_dir, target / module_name): + copied += 1 + return copied @beartype @@ -40,6 +285,9 @@ def verify_module_artifact( package_dir: Path, meta: ModulePackageMetadata, allow_unsigned: bool = False, + require_integrity: bool = False, + require_signature: bool = False, + public_key_pem: str | None = None, ) -> bool: """Run integrity verification for a module artifact.""" logger = get_bridge_logger(__name__) @@ -48,25 +296,52 @@ def verify_module_artifact( manifest_path = package_dir / "metadata.yaml" if not manifest_path.exists(): logger.warning("Module %s: No manifest file for integrity check (skipped)", meta.name) - return allow_unsigned + return allow_unsigned and not require_integrity if meta.integrity is None: if allow_unsigned: logger.debug("Module %s: No integrity metadata; allowing (allow-unsigned)", meta.name) + return True + if require_integrity: + logger.warning("Module %s: Missing integrity metadata", meta.name) + return False return True try: - data = manifest_path.read_bytes() - verify_checksum(data, meta.integrity.checksum) + legacy_payload = _module_artifact_payload(package_dir) + verify_checksum(legacy_payload, meta.integrity.checksum) + verification_payload = legacy_payload except ValueError as exc: - logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) - return False + try: + stable_payload = _module_artifact_payload_stable(package_dir) + verify_checksum(stable_payload, meta.integrity.checksum) + logger.info( + "Module %s: checksum matched with generated-file exclusions (cache/transient files ignored)", + meta.name, + ) + verification_payload = stable_payload + except ValueError: + logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) + return False if meta.integrity.signature: - logger.warning( - "Module %s: Signature present but key material not configured; checksum-only verification", - meta.name, - ) + key_material = _load_public_key_pem(public_key_pem) + if not key_material: + if require_signature and not allow_unsigned: + logger.warning("Module %s: Signature verification requires public key material", meta.name) + return False + logger.warning( + "Module %s: Signature present but no public key configured; checksum-only verification", meta.name + ) + return True + try: + verify_signature(verification_payload, meta.integrity.signature, key_material) + except ValueError as exc: + logger.warning("Module %s: Signature check failed: %s", meta.name, exc) + return False + elif require_signature and not allow_unsigned: + logger.warning("Module %s: Signature is required but missing", meta.name) + return False return True @@ -80,10 +355,12 @@ def install_module( version: str | None = None, reinstall: bool = False, install_root: Path | None = None, + trust_non_official: bool = False, + non_interactive: bool = False, ) -> Path: - """Install a marketplace module from tarball into marketplace modules root.""" + """Install a marketplace module from tarball into canonical user modules root.""" logger = get_bridge_logger(__name__) - target_root = install_root or MARKETPLACE_MODULES_ROOT + target_root = install_root or USER_MODULES_ROOT target_root.mkdir(parents=True, exist_ok=True) _namespace, module_name = module_id.split("/", 1) @@ -116,11 +393,39 @@ def install_module( metadata = yaml.safe_load(extracted_manifest.read_text(encoding="utf-8")) if not isinstance(metadata, dict): raise ValueError("Invalid module manifest format") + manifest_module_name = str(metadata.get("name", module_name)).strip() or module_name + assert_module_allowed(manifest_module_name) 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") + publisher_name: str | None = None + publisher_raw = metadata.get("publisher") + if isinstance(publisher_raw, dict): + publisher_name = str(publisher_raw.get("name", "")).strip() or None + ensure_publisher_trusted( + publisher_name, + trust_non_official=trust_non_official, + non_interactive=non_interactive, + ) + + try: + metadata_obj = ModulePackageMetadata(**metadata) + except Exception: + metadata_obj = ModulePackageMetadata( + name=manifest_module_name, + version=str(metadata.get("version", "0.1.0")), + commands=[str(command) for command in metadata.get("commands", []) if str(command).strip()], + ) + allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in {"1", "true", "yes"} + if not verify_module_artifact( + extracted_module_dir, + metadata_obj, + allow_unsigned=allow_unsigned, + ): + raise ValueError("Downloaded module failed integrity verification") + staged_path = target_root / f".{module_name}.tmp-install" if staged_path.exists(): shutil.rmtree(staged_path) @@ -147,9 +452,8 @@ def uninstall_module( install_root: Path | None = None, source_map: dict[str, str] | None = None, ) -> None: - """Uninstall a marketplace module from the local marketplace root.""" + """Uninstall a marketplace module from the local canonical user 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 @@ -159,10 +463,23 @@ def uninstall_module( source = source_map.get(module_name) if source == "builtin": raise ValueError("Cannot uninstall built-in module") - if source != "marketplace": + if source not in {"marketplace", "user"}: raise ValueError(f"Cannot uninstall module from source '{source or 'unknown'}'") - module_path = target_root / module_name - if module_path.exists(): + if install_root is not None: + candidate_roots = [install_root] + elif source == "marketplace": + candidate_roots = [MARKETPLACE_MODULES_ROOT, USER_MODULES_ROOT] + else: + candidate_roots = [USER_MODULES_ROOT, MARKETPLACE_MODULES_ROOT] + + for root in candidate_roots: + module_path = root / module_name + if not module_path.exists(): + continue shutil.rmtree(module_path) - logger.info("Uninstalled marketplace module '%s'", module_name) + logger.info("Uninstalled module '%s' from '%s'", module_name, root) + return + + roots_str = ", ".join(str(root) for root in candidate_roots) + raise ValueError(f"Module '{module_name}' is not installed under expected roots: {roots_str}") diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index cdfa2b88..e69405a1 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -103,15 +103,9 @@ def _add_root(path: Path) -> None: # Core packaged modules. _add_root(get_modules_root()) - # Workspace-level modules to support externalized module development. - repo_modules_root = Path(__file__).resolve().parents[3] / "modules" - if repo_modules_root.exists(): - _add_root(repo_modules_root) - - # Installed runtimes can still discover repo-level modules when invoked from a checkout. - cwd_modules_root = Path.cwd() / "modules" - if cwd_modules_root.exists(): - _add_root(cwd_modules_root) + workspace_modules_root = get_workspace_modules_root() + if workspace_modules_root is not None: + _add_root(workspace_modules_root) # Optional extra roots for custom module locations. extra_roots = os.environ.get("SPECFACT_MODULES_ROOTS", "") @@ -126,6 +120,22 @@ def _add_root(path: Path) -> None: return roots +def get_workspace_modules_root(base_path: Path | None = None) -> Path | None: + """Return nearest workspace-local .specfact/modules root from base path upward.""" + start = base_path.resolve() if base_path is not None else Path.cwd().resolve() + for candidate in [start, *start.parents]: + git_dir = candidate / ".git" + if git_dir.exists(): + workspace_modules_root = candidate / ".specfact" / "modules" + if workspace_modules_root.exists(): + return workspace_modules_root + return None + workspace_modules_root = start / ".specfact" / "modules" + if workspace_modules_root.exists(): + return workspace_modules_root + return None + + @beartype def discover_all_package_metadata() -> list[tuple[Path, ModulePackageMetadata]]: """Discover module package metadata across built-in/marketplace/custom roots.""" diff --git a/src/specfact_cli/registry/module_security.py b/src/specfact_cli/registry/module_security.py new file mode 100644 index 00000000..f8f08b29 --- /dev/null +++ b/src/specfact_cli/registry/module_security.py @@ -0,0 +1,121 @@ +"""Module security helpers: denylist enforcement and publisher trust decisions.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from pathlib import Path + +import typer +from beartype import beartype + +from specfact_cli.utils.metadata import get_metadata, update_metadata + + +DEFAULT_DENYLIST_PATH = Path.home() / ".specfact" / "module-denylist.txt" +TRUSTED_PUBLISHERS_KEY = "trusted_module_publishers" +OFFICIAL_PUBLISHERS = {"nold-ai"} +_TRUTHY = {"1", "true", "yes"} + + +@beartype +def is_official_publisher(publisher_name: str | None) -> bool: + """Return True when publisher is official.""" + normalized = (publisher_name or "").strip().lower() + return normalized in OFFICIAL_PUBLISHERS + + +@beartype +def get_denylist_path() -> Path: + """Return configured module denylist path.""" + configured = os.environ.get("SPECFACT_MODULE_DENYLIST_FILE", "").strip() + if configured: + return Path(configured).expanduser() + return DEFAULT_DENYLIST_PATH + + +@beartype +def get_denylisted_modules(path: Path | None = None) -> set[str]: + """Load denylisted module ids from file.""" + denylist_path = path or get_denylist_path() + if not denylist_path.exists(): + return set() + items: set[str] = set() + for raw_line in denylist_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if line: + items.add(line.lower()) + return items + + +@beartype +def assert_module_allowed(module_name: str) -> None: + """Raise when module is denylisted.""" + normalized = module_name.strip().lower() + if not normalized: + raise ValueError("Module name must be non-empty") + denylisted = get_denylisted_modules() + if normalized not in denylisted: + return + denylist_path = get_denylist_path() + raise ValueError( + f"Module '{module_name}' is denylisted and cannot be installed or bootstrapped (denylist: {denylist_path})." + ) + + +@beartype +def get_trusted_publishers() -> set[str]: + """Return persisted trusted non-official publishers.""" + metadata = get_metadata() + raw = metadata.get(TRUSTED_PUBLISHERS_KEY, []) + if not isinstance(raw, list): + return set() + return {str(item).strip().lower() for item in raw if str(item).strip()} + + +@beartype +def _persist_trusted_publishers(publishers: set[str]) -> None: + """Persist trusted publishers in user metadata.""" + update_metadata(**{TRUSTED_PUBLISHERS_KEY: sorted(publishers)}) + + +@beartype +def trust_flag_enabled() -> bool: + """Return True when explicit trust env override is enabled.""" + return os.environ.get("SPECFACT_TRUST_NON_OFFICIAL", "").strip().lower() in _TRUTHY + + +@beartype +def ensure_publisher_trusted( + publisher_name: str | None, + *, + trust_non_official: bool = False, + non_interactive: bool = False, + confirm_callback: Callable[[str], bool] | None = None, +) -> None: + """Ensure non-official publisher is trusted before proceeding.""" + normalized = (publisher_name or "").strip().lower() + if not normalized or is_official_publisher(normalized): + return + + trusted = get_trusted_publishers() + if normalized in trusted: + return + + if trust_non_official or trust_flag_enabled(): + trusted.add(normalized) + _persist_trusted_publishers(trusted) + return + + if non_interactive: + raise ValueError(f"Publisher '{publisher_name}' is non-official. Re-run with --trust-non-official to continue.") + + confirm = confirm_callback or (lambda message: typer.confirm(message, default=False)) + accepted = confirm( + f"Publisher '{publisher_name}' is non-official and may be unsafe. Trust this publisher for future installs?" + ) + if not accepted: + raise ValueError(f"Publisher '{publisher_name}' was not trusted; installation cancelled.") + + trusted.add(normalized) + _persist_trusted_publishers(trusted) diff --git a/src/specfact_cli/utils/metadata.py b/src/specfact_cli/utils/metadata.py index 9b80f7b1..b78784b4 100644 --- a/src/specfact_cli/utils/metadata.py +++ b/src/specfact_cli/utils/metadata.py @@ -137,6 +137,14 @@ def get_last_version_check_timestamp() -> str | None: return metadata.get("last_version_check_timestamp") +@beartype +@ensure(lambda result: result is None or isinstance(result, str), "Must return str or None") +def get_last_module_freshness_check_timestamp() -> str | None: + """Get the last module freshness check timestamp from metadata.""" + metadata = get_metadata() + return metadata.get("last_module_freshness_check_timestamp") + + @beartype @require( lambda timestamp: timestamp is None or isinstance(timestamp, str), diff --git a/src/specfact_cli/utils/startup_checks.py b/src/specfact_cli/utils/startup_checks.py index 6a94a648..c3f73f03 100644 --- a/src/specfact_cli/utils/startup_checks.py +++ b/src/specfact_cli/utils/startup_checks.py @@ -21,9 +21,11 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from specfact_cli import __version__ +from specfact_cli.registry.module_installer import USER_MODULES_ROOT, get_outdated_or_missing_bundled_modules from specfact_cli.utils.ide_setup import IDE_CONFIG, detect_ide, find_package_resources_path from specfact_cli.utils.metadata import ( get_last_checked_version, + get_last_module_freshness_check_timestamp, get_last_version_check_timestamp, is_version_check_needed, update_metadata, @@ -53,6 +55,17 @@ class VersionCheckResult(NamedTuple): error: str | None +class ModuleFreshnessCheckResult(NamedTuple): + """Result of bundled module freshness checks for project/user scopes.""" + + project_outdated: bool + user_outdated: bool + project_outdated_modules: list[str] + user_outdated_modules: list[str] + project_modules_root: Path + user_modules_root: Path + + @beartype def calculate_file_hash(file_path: Path) -> str: """ @@ -267,6 +280,28 @@ def check_pypi_version(package_name: str = "specfact-cli", timeout: int = 3) -> ) +@beartype +def check_module_freshness(repo_path: Path | None = None) -> ModuleFreshnessCheckResult: + """Check bundled module freshness for project and user scopes.""" + if repo_path is None: + repo_path = Path.cwd() + + project_modules_root = repo_path / ".specfact" / "modules" + user_modules_root = USER_MODULES_ROOT + + project_outdated_modules = get_outdated_or_missing_bundled_modules(project_modules_root) + user_outdated_modules = get_outdated_or_missing_bundled_modules(user_modules_root) + + return ModuleFreshnessCheckResult( + project_outdated=bool(project_outdated_modules), + user_outdated=bool(user_outdated_modules), + project_outdated_modules=project_outdated_modules, + user_outdated_modules=user_outdated_modules, + project_modules_root=project_modules_root, + user_modules_root=user_modules_root, + ) + + @beartype def print_startup_checks( repo_path: Path | None = None, @@ -300,6 +335,9 @@ def print_startup_checks( # Check if version check should run (only if >= 24 hours since last check) last_version_check_timestamp = get_last_version_check_timestamp() should_check_version = check_version and is_version_check_needed(last_version_check_timestamp) + # Check modules on version change and otherwise at most once per 24 hours. + last_module_freshness_check_timestamp = get_last_module_freshness_check_timestamp() + should_check_modules = should_check_templates or is_version_check_needed(last_module_freshness_check_timestamp) # Use progress indicator for checks that might take time with Progress( @@ -369,6 +407,35 @@ def print_startup_checks( console.print() console.print(Panel(update_message, border_style=update_type_color)) + module_result = None + if should_check_modules: + modules_task = ( + progress.add_task("[cyan]Checking bundled modules...[/cyan]", total=None) if show_progress else None + ) + module_result = check_module_freshness(repo_path) + if modules_task: + progress.update(modules_task, description="[green]✓[/green] Checked bundled modules") + + if module_result and (module_result.project_outdated or module_result.user_outdated): + guidance: list[str] = [] + if module_result.project_outdated: + guidance.append( + f"- Project scope ({module_result.project_modules_root}): " + "[bold]specfact module init --scope project[/bold]" + ) + if module_result.user_outdated: + guidance.append(f"- User scope ({module_result.user_modules_root}): [bold]specfact module init[/bold]") + guidance_text = "\n".join(guidance) + console.print() + console.print( + Panel( + "[bold yellow]⚠ Bundled Modules Need Refresh[/bold yellow]\n\n" + "Some bundled modules are missing or outdated.\n\n" + f"{guidance_text}", + border_style="yellow", + ) + ) + # Update metadata after checks complete from datetime import datetime @@ -377,6 +444,8 @@ def print_startup_checks( metadata_updates["last_checked_version"] = __version__ if should_check_version: metadata_updates["last_version_check_timestamp"] = datetime.now(UTC).isoformat() + if should_check_modules: + metadata_updates["last_module_freshness_check_timestamp"] = datetime.now(UTC).isoformat() if metadata_updates: update_metadata(**metadata_updates) diff --git a/tests/unit/modules/module_registry/test_commands.py b/tests/unit/modules/module_registry/test_commands.py index 44d812b5..99603950 100644 --- a/tests/unit/modules/module_registry/test_commands.py +++ b/tests/unit/modules/module_registry/test_commands.py @@ -6,7 +6,9 @@ from typer.testing import CliRunner +from specfact_cli.models.module_package import ModulePackageMetadata from specfact_cli.modules.module_registry.src.commands import app +from specfact_cli.registry.module_installer import USER_MODULES_ROOT runner = CliRunner() @@ -16,7 +18,7 @@ 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], + lambda module_id, version=None, install_root=None, **_kwargs: tmp_path / module_id.split("/")[-1], ) result = runner.invoke(app, ["install", "specfact/backlog"]) @@ -29,12 +31,16 @@ def test_install_command_integration(monkeypatch, tmp_path: Path) -> None: def test_install_command_accepts_bare_module_name(monkeypatch, tmp_path: Path) -> None: captured: dict[str, str | None] = {"module_id": None} - def _install(module_id: str, version=None): + def _install(module_id: str, version=None, install_root=None, **_kwargs): 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) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_bundled_module", + lambda module_name, target_root, **_kwargs: False, + ) result = runner.invoke(app, ["install", "bundle-mapper"]) @@ -64,7 +70,7 @@ class _Entry: called = {"install": False} - def _install(module_id: str, version=None): + def _install(module_id: str, version=None, **_kwargs): called["install"] = True return tmp_path / module_id.split("/")[-1] @@ -78,6 +84,192 @@ def _install(module_id: str, version=None): assert "already available" in result.stdout +def test_install_command_project_scope_installs_to_project_modules_root(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {"install_root": None, "module_id": None} + + def _install(module_id: str, version=None, install_root=None, **_kwargs): + captured["module_id"] = module_id + captured["install_root"] = install_root + return tmp_path / "installed" + + 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) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_bundled_module", + lambda module_name, target_root, **_kwargs: False, + raising=False, + ) + + repo_path = tmp_path / "repo" + repo_path.mkdir(parents=True) + result = runner.invoke(app, ["install", "backlog", "--scope", "project", "--repo", str(repo_path)]) + + assert result.exit_code == 0 + assert captured["module_id"] == "specfact/backlog" + assert captured["install_root"] == repo_path / ".specfact" / "modules" + + +def test_install_command_prefers_bundled_source_when_available(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.get_bundled_module_metadata", + lambda: { + "bundle-mapper": ModulePackageMetadata( + name="bundle-mapper", + version="0.1.0", + description="Bundled mapper", + ) + }, + raising=False, + ) + + called = {"bundled": False, "marketplace": False} + + def _install_bundled(module_name: str, target_root: Path, **_kwargs) -> bool: + called["bundled"] = module_name == "bundle-mapper" + return True + + def _install_marketplace(*_args, **_kwargs): + called["marketplace"] = True + raise AssertionError("Marketplace installer must not be called when bundled module exists") + + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_bundled_module", + _install_bundled, + raising=False, + ) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install_marketplace) + + result = runner.invoke(app, ["install", "bundle-mapper"]) + + assert result.exit_code == 0 + assert called["bundled"] is True + assert called["marketplace"] is False + + +def test_install_command_project_scope_does_not_skip_when_user_scope_module_exists(monkeypatch, tmp_path: Path) -> None: + class _Meta: + name = "bundle-mapper" + + class _Entry: + metadata = _Meta() + source = "user" + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", lambda: [_Entry()]) + + called = {"marketplace": False} + + def _install_marketplace(module_id: str, version=None, install_root=None, **_kwargs): + called["marketplace"] = True + return tmp_path / module_id.split("/")[-1] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install_marketplace) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.install_bundled_module", + lambda module_name, target_root, **_kwargs: False, + ) + + repo_path = tmp_path / "repo" + repo_path.mkdir(parents=True) + result = runner.invoke(app, ["install", "bundle-mapper", "--scope", "project", "--repo", str(repo_path)]) + + assert result.exit_code == 0 + assert called["marketplace"] is True + + +def test_install_command_source_marketplace_skips_bundled_resolution(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.get_bundled_module_metadata", + lambda: {"bundle-mapper": ModulePackageMetadata(name="bundle-mapper", version="0.1.0")}, + ) + + called = {"bundled": False, "marketplace": False} + + def _bundled(*_args, **_kwargs): + called["bundled"] = True + return True + + def _marketplace(module_id: str, version=None, install_root=None, **_kwargs): + called["marketplace"] = True + return tmp_path / module_id.split("/")[-1] + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_bundled_module", _bundled) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _marketplace) + + result = runner.invoke(app, ["install", "bundle-mapper", "--source", "marketplace"]) + + assert result.exit_code == 0 + assert called["bundled"] is False + assert called["marketplace"] is True + + +def test_install_command_requires_explicit_trust_for_non_official_in_non_interactive( + 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.is_non_interactive", lambda: True) + + def _install(module_id: str, version=None, install_root=None, trust_non_official=False, non_interactive=False): + if not trust_non_official and non_interactive: + raise ValueError("requires --trust-non-official") + 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.install_bundled_module", + lambda module_name, target_root, **_kwargs: False, + raising=False, + ) + + result = runner.invoke(app, ["install", "community-module", "--source", "marketplace"]) + + assert result.exit_code == 1 + assert "--trust-non-official" in result.stdout + + +def test_install_command_passes_trust_flag_to_marketplace_installer(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.is_non_interactive", lambda: True) + captured: dict[str, bool | None] = {"trust_non_official": None, "non_interactive": None} + + def _install(module_id: str, version=None, install_root=None, trust_non_official=False, non_interactive=False): + captured["trust_non_official"] = trust_non_official + captured["non_interactive"] = non_interactive + 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.install_bundled_module", + lambda module_name, target_root, **_kwargs: False, + raising=False, + ) + + result = runner.invoke(app, ["install", "community-module", "--source", "marketplace", "--trust-non-official"]) + + assert result.exit_code == 0 + assert captured["trust_non_official"] is True + assert captured["non_interactive"] is True + + +def test_module_init_passes_trust_flag_and_non_interactive(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {"trust_non_official": None, "non_interactive": None} + + def _sync(*, target_root, trust_non_official=False, non_interactive=False): + captured["trust_non_official"] = trust_non_official + captured["non_interactive"] = non_interactive + return 1 + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.sync_bundled_modules_to_user_root", _sync) + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: True) + + result = runner.invoke(app, ["init", "--scope", "project", "--repo", str(tmp_path), "--trust-non-official"]) + + assert result.exit_code == 0 + assert captured["trust_non_official"] is True + assert captured["non_interactive"] is True + + def test_uninstall_command_with_source_validation(monkeypatch) -> None: called = {"ok": False} @@ -100,6 +292,30 @@ def fake_uninstall(module_name: str, **_kwargs) -> None: assert called["ok"] is True +def test_uninstall_command_requires_scope_when_module_exists_in_user_and_project(monkeypatch, tmp_path: Path) -> None: + repo_path = tmp_path / "repo" + project_modules = repo_path / ".specfact" / "modules" / "bundle-mapper" + user_modules = tmp_path / "user-modules" / "bundle-mapper" + project_modules.mkdir(parents=True) + user_modules.mkdir(parents=True) + + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.USER_MODULES_ROOT", tmp_path / "user-modules" + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.discover_all_modules", + list, + ) + + result = runner.invoke(app, ["uninstall", "bundle-mapper", "--repo", str(repo_path)]) + + assert result.exit_code == 1 + assert "exists in both user and project module roots" in result.stdout + assert "--scope" in result.stdout + assert "user" in result.stdout + assert "project" in result.stdout + + def test_uninstall_command_custom_module_has_clear_guidance(monkeypatch) -> None: class _Meta: name = "bundle-mapper" @@ -402,6 +618,99 @@ def test_list_command_source_filter(monkeypatch) -> None: assert "init" not in result.stdout +def test_list_command_show_bundled_available_separate_section_with_hints(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", + }, + ], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_bundled_module_metadata", + lambda: { + "init": ModulePackageMetadata(name="init", version="0.1.0", description="Core init module"), + "backlog-core": ModulePackageMetadata( + name="backlog-core", + version="0.2.0", + description="Backlog workflows", + ), + }, + raising=False, + ) + + result = runner.invoke(app, ["list", "--show-bundled-available"]) + + assert result.exit_code == 0 + assert "Bundled Modules Available" in result.stdout + assert "backlog-core" in result.stdout + assert "specfact module init" in result.stdout + assert "specfact module init --scope project" in result.stdout + + +def test_list_command_show_bundled_available_empty_when_all_installed(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", + }, + ], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_bundled_module_metadata", + lambda: { + "init": ModulePackageMetadata(name="init", version="0.1.0", description="Core init module"), + }, + raising=False, + ) + + result = runner.invoke(app, ["list", "--show-bundled-available"]) + + assert result.exit_code == 0 + assert "Bundled Modules Available" not in result.stdout + assert "All bundled modules are already installed" in result.stdout + + +def test_list_command_without_flag_shows_hint_when_bundled_available(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", + }, + ], + ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.get_bundled_module_metadata", + lambda: { + "init": ModulePackageMetadata(name="init", version="0.1.0", description="Core init module"), + "backlog-core": ModulePackageMetadata(name="backlog-core", version="0.2.0", description="Backlog"), + }, + ) + + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "--show-bundled-available" 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", @@ -680,6 +989,10 @@ def test_enable_command_interactive_mode_selection(monkeypatch) -> None: "specfact_cli.modules.module_registry.src.commands.select_module_ids_interactive", lambda *_args, **_kwargs: ["backlog"], ) + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.ensure_publisher_trusted", + lambda *_args, **_kwargs: None, + ) captured = {"enable_ids": None} @@ -702,3 +1015,53 @@ def test_disable_command_non_interactive_requires_module_id(monkeypatch) -> None assert result.exit_code == 1 assert "Non-interactive mode requires explicit module id value" in result.stdout + + +def test_module_init_bootstraps_user_modules(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.modules.module_registry.src.commands.sync_bundled_modules_to_user_root", + lambda **_kwargs: 2, + ) + + result = runner.invoke(app, ["init"]) + + assert result.exit_code == 0 + assert f"Seeded 2 module(s) into {USER_MODULES_ROOT}" in result.stdout + + +def test_module_init_project_scope_defaults_to_cwd_repo(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + captured: dict[str, Path | None] = {"target_root": None} + + def _sync(target_root=None, **_kwargs): + captured["target_root"] = target_root + return 1 + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.sync_bundled_modules_to_user_root", _sync) + + result = runner.invoke(app, ["init", "--scope", "project"]) + + assert result.exit_code == 0 + assert captured["target_root"] == tmp_path / ".specfact" / "modules" + assert "Seeded 1 module(s) into" in result.stdout + assert str(tmp_path / ".specfact" / "modules") in result.stdout + + +def test_module_init_project_scope_supports_explicit_repo(monkeypatch, tmp_path: Path) -> None: + explicit_repo = tmp_path / "customer-a" + explicit_repo.mkdir(parents=True) + captured: dict[str, Path | None] = {"target_root": None} + + def _sync(target_root=None, **_kwargs): + captured["target_root"] = target_root + return 1 + + monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.sync_bundled_modules_to_user_root", _sync) + + result = runner.invoke(app, ["init", "--scope", "project", "--repo", str(explicit_repo)]) + + assert result.exit_code == 0 + assert captured["target_root"] == explicit_repo / ".specfact" / "modules" + compact_output = result.stdout.replace("\n", "") + assert "Seeded 1 module(s) into" in compact_output + assert str(explicit_repo / ".specfact" / "modules").replace("\n", "") in compact_output diff --git a/tests/unit/registry/test_module_discovery.py b/tests/unit/registry/test_module_discovery.py index 0a06700d..4c11be05 100644 --- a/tests/unit/registry/test_module_discovery.py +++ b/tests/unit/registry/test_module_discovery.py @@ -4,6 +4,7 @@ from pathlib import Path +from specfact_cli.registry import module_discovery from specfact_cli.registry.module_discovery import discover_all_modules @@ -28,8 +29,10 @@ def test_discover_all_modules_scans_builtin_marketplace_and_custom(tmp_path: Pat discovered = discover_all_modules( builtin_root=builtin_root, + user_root=tmp_path / "missing-user", marketplace_root=marketplace_root, custom_root=custom_root, + include_legacy_roots=False, ) names = {entry.metadata.name for entry in discovered} @@ -49,7 +52,9 @@ def test_discover_all_modules_builtin_takes_priority(tmp_path: Path) -> None: discovered = discover_all_modules( builtin_root=builtin_root, + user_root=tmp_path / "missing-user", marketplace_root=marketplace_root, + include_legacy_roots=False, ) backlog_entries = [entry for entry in discovered if entry.metadata.name == "backlog"] @@ -64,9 +69,49 @@ def test_discover_all_modules_handles_missing_optional_paths(tmp_path: Path) -> discovered = discover_all_modules( builtin_root=builtin_root, + user_root=tmp_path / "missing-user", marketplace_root=tmp_path / "missing-marketplace", custom_root=tmp_path / "missing-custom", + include_legacy_roots=False, ) assert [entry.metadata.name for entry in discovered] == ["init"] assert discovered[0].source == "builtin" + + +def test_discover_all_modules_scans_user_root(tmp_path: Path, monkeypatch) -> None: + """Discovery should include canonical user module root.""" + builtin_root = tmp_path / "builtin" + user_root = tmp_path / "user-modules" + _write_manifest(builtin_root, "init") + _write_manifest(user_root, "backlog-core") + monkeypatch.setattr(module_discovery, "USER_MODULES_ROOT", user_root) + + discovered = discover_all_modules(builtin_root=builtin_root) + + names = {entry.metadata.name for entry in discovered} + assert names == {"init", "backlog-core"} + sources = {entry.metadata.name: entry.source for entry in discovered} + assert sources["init"] == "builtin" + assert sources["backlog-core"] == "user" + + +def test_discover_all_modules_project_scope_takes_priority_over_user(tmp_path: Path, monkeypatch) -> None: + """Workspace project modules should shadow user modules with same id.""" + repo_root = tmp_path / "repo" + project_root = repo_root / ".specfact" / "modules" + builtin_root = tmp_path / "builtin" + user_root = tmp_path / "user-modules" + _write_manifest(builtin_root, "init") + _write_manifest(project_root, "backlog-core") + _write_manifest(user_root, "backlog-core") + + monkeypatch.chdir(repo_root) + discovered = discover_all_modules( + builtin_root=builtin_root, + user_root=user_root, + include_legacy_roots=True, + ) + + sources = {entry.metadata.name: entry.source for entry in discovered} + assert sources["backlog-core"] == "project" diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index 6ff4b5c9..3effdf8c 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -8,6 +8,8 @@ import pytest +from specfact_cli.models.module_package import IntegrityInfo, ModulePackageMetadata +from specfact_cli.registry import module_installer from specfact_cli.registry.module_installer import install_module, uninstall_module @@ -127,3 +129,146 @@ def test_install_module_validates_core_compatibility(monkeypatch, tmp_path: Path with pytest.raises(ValueError, match="incompatible with current SpecFact CLI version"): install_module("specfact/policy", install_root=tmp_path / "marketplace-modules") + + +def test_install_module_defaults_to_user_modules_root(monkeypatch, tmp_path: Path) -> None: + """Installer should default to canonical user modules root when no install_root is provided.""" + tarball = _create_module_tarball(tmp_path, "policy") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + user_root = tmp_path / "modules" + monkeypatch.setattr(module_installer, "USER_MODULES_ROOT", user_root) + + installed = install_module("specfact/policy") + + assert installed == user_root / "policy" + + +def test_install_module_rejects_denylisted_module(monkeypatch, tmp_path: Path) -> None: + tarball = _create_module_tarball(tmp_path, "blocked") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + monkeypatch.setattr( + "specfact_cli.registry.module_installer.assert_module_allowed", + lambda module_name: (_ for _ in ()).throw(ValueError("denylisted module: blocked")), + ) + + with pytest.raises(ValueError, match="denylisted module"): + install_module("specfact/blocked", install_root=tmp_path / "modules") + + +def test_sync_bundled_modules_rejects_denylisted_module(monkeypatch, tmp_path: Path) -> None: + bundled = tmp_path / "bundled" / "blocked" + bundled.mkdir(parents=True) + (bundled / "module-package.yaml").write_text( + "name: blocked\nversion: '0.1.0'\ncommands: [blocked]\n", encoding="utf-8" + ) + monkeypatch.setattr( + "specfact_cli.registry.module_installer._get_bundled_module_sources", + lambda: {"blocked": bundled}, + ) + monkeypatch.setattr( + "specfact_cli.registry.module_installer.assert_module_allowed", + lambda module_name: (_ for _ in ()).throw(ValueError("denylisted module: blocked")), + ) + + with pytest.raises(ValueError, match="denylisted module"): + module_installer.sync_bundled_modules_to_user_root(target_root=tmp_path / "target") + + +def test_install_bundled_module_enforces_integrity_verification(monkeypatch, tmp_path: Path) -> None: + bundled = tmp_path / "bundled" / "secure" + bundled.mkdir(parents=True) + (bundled / "module-package.yaml").write_text( + "name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8" + ) + monkeypatch.setattr( + "specfact_cli.registry.module_installer._get_bundled_module_sources", + lambda: {"secure": bundled}, + ) + metadata = module_installer.ModulePackageMetadata(name="secure", version="0.1.0", commands=["secure"]) + monkeypatch.setattr( + "specfact_cli.registry.module_installer.get_bundled_module_metadata", + lambda: {"secure": metadata}, + ) + monkeypatch.setattr( + "specfact_cli.registry.module_installer.verify_module_artifact", + lambda *args, **kwargs: False, + ) + + with pytest.raises(ValueError, match="integrity"): + module_installer.install_bundled_module("secure", target_root=tmp_path / "target") + + +def test_verify_module_artifact_detects_tamper_in_non_manifest_file(tmp_path: Path) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + payload = module_installer._module_artifact_payload(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True + + source.write_text("print('tampered')\n", encoding="utf-8") + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is False + + +def test_verify_module_artifact_ignores_runtime_cache_files(tmp_path: Path) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + payload = module_installer._module_artifact_payload(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + + pycache_file = module_dir / "__pycache__" / "main.cpython-312.pyc" + pycache_file.parent.mkdir(parents=True) + pycache_file.write_bytes(b"\x00\x01\x02") + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True + + +def test_uninstall_module_falls_back_to_legacy_marketplace_root(tmp_path: Path, monkeypatch) -> None: + user_root = tmp_path / "modules" + legacy_marketplace_root = tmp_path / "marketplace-modules" + monkeypatch.setattr(module_installer, "USER_MODULES_ROOT", user_root) + monkeypatch.setattr(module_installer, "MARKETPLACE_MODULES_ROOT", legacy_marketplace_root) + + module_dir = legacy_marketplace_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", source_map={"backlog": "marketplace"}) + assert not module_dir.exists() + + +def test_load_public_key_pem_prefers_explicit_then_env_then_bundled(monkeypatch, tmp_path: Path) -> None: + key_file = tmp_path / "module-signing-public.pem" + key_file.write_text("PUBLIC-KEY-FROM-FILE", encoding="utf-8") + monkeypatch.setattr(module_installer, "_bundled_public_key_path", lambda: key_file) + + monkeypatch.setenv("SPECFACT_MODULE_PUBLIC_KEY_PEM", "PUBLIC-KEY-FROM-ENV") + assert module_installer._load_public_key_pem("PUBLIC-KEY-EXPLICIT") == "PUBLIC-KEY-EXPLICIT" + assert module_installer._load_public_key_pem(None) == "PUBLIC-KEY-FROM-ENV" + + monkeypatch.delenv("SPECFACT_MODULE_PUBLIC_KEY_PEM", raising=False) + assert module_installer._load_public_key_pem(None) == "PUBLIC-KEY-FROM-FILE" diff --git a/tests/unit/registry/test_module_security.py b/tests/unit/registry/test_module_security.py new file mode 100644 index 00000000..16274780 --- /dev/null +++ b/tests/unit/registry/test_module_security.py @@ -0,0 +1,63 @@ +"""Tests for module denylist and publisher trust checks.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specfact_cli.registry import module_security + + +def test_get_denylisted_modules_parses_lines_and_comments(tmp_path: Path) -> None: + denylist = tmp_path / "denylist.txt" + denylist.write_text( + "\n# comment\nblocked\nanother-blocked # inline comment\n\n", + encoding="utf-8", + ) + + values = module_security.get_denylisted_modules(denylist) + + assert values == {"blocked", "another-blocked"} + + +def test_assert_module_allowed_raises_for_denylisted(monkeypatch) -> None: + monkeypatch.setattr( + "specfact_cli.registry.module_security.get_denylisted_modules", + lambda path=None: {"blocked"}, + ) + monkeypatch.setattr( + "specfact_cli.registry.module_security.get_denylist_path", + lambda: Path("/tmp/denylist.txt"), + ) + + with pytest.raises(ValueError, match="denylisted"): + module_security.assert_module_allowed("blocked") + + +def test_ensure_publisher_trusted_requires_flag_in_non_interactive(monkeypatch) -> None: + monkeypatch.setattr("specfact_cli.registry.module_security.get_trusted_publishers", lambda: set()) + + with pytest.raises(ValueError, match="--trust-non-official"): + module_security.ensure_publisher_trusted( + "community-dev", + trust_non_official=False, + non_interactive=True, + ) + + +def test_ensure_publisher_trusted_persists_when_flag_enabled(monkeypatch) -> None: + persisted: dict[str, list[str]] = {"trusted_module_publishers": []} + monkeypatch.setattr("specfact_cli.registry.module_security.get_trusted_publishers", lambda: set()) + monkeypatch.setattr( + "specfact_cli.registry.module_security._persist_trusted_publishers", + lambda publishers: persisted.update({"trusted_module_publishers": sorted(publishers)}), + ) + + module_security.ensure_publisher_trusted( + "community-dev", + trust_non_official=True, + non_interactive=True, + ) + + assert persisted["trusted_module_publishers"] == ["community-dev"] diff --git a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py index 884df55a..fedae7e3 100644 --- a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py +++ b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py @@ -1,4 +1,4 @@ -"""Tests for init module lifecycle UX: listing and interactive/non-interactive selection.""" +"""Tests for `specfact init` bootstrap UX and `init ide` parity.""" from __future__ import annotations @@ -7,107 +7,38 @@ from typer.testing import CliRunner from specfact_cli.cli import app -from specfact_cli.registry.module_packages import ModulePackageMetadata +from specfact_cli.modules.init.src import commands as init_commands from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo runner = CliRunner() -def test_init_list_modules_shows_enabled_disabled(tmp_path: Path, monkeypatch) -> None: - """`specfact init --list-modules` prints discovered module statuses and exits.""" - - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", - lambda enable_ids=None, disable_ids=None: [ - {"id": "sync", "version": "0.1.0", "enabled": True}, - {"id": "generate", "version": "0.1.0", "enabled": False}, - ], - ) +def test_init_rejects_deprecated_list_modules_option(tmp_path: Path) -> None: + """`specfact init --list-modules` is removed; lifecycle lives under `specfact module`.""" result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--list-modules"]) - assert result.exit_code == 0 - assert "sync" in result.stdout - assert "generate" in result.stdout - assert "enabled" in result.stdout.lower() - assert "disabled" in result.stdout.lower() - - -def test_init_non_interactive_bare_enable_module_requires_id(tmp_path: Path, monkeypatch) -> None: - """Non-interactive mode rejects bare --enable-module and requires explicit id.""" - - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands._select_module_ids_interactive", - lambda action, modules: (_ for _ in ()).throw(AssertionError("must not prompt in non-interactive mode")), - ) - - result = runner.invoke(app, ["--no-interactive", "init", "--repo", str(tmp_path), "--enable-module"]) - assert result.exit_code == 1 - assert "--enable-module " in result.stdout or "--disable-module " in result.stdout - + assert result.exit_code != 0 + assert "No such option: --list-modules" in result.output -def test_init_enable_module_bare_interactive_adds_selected_module(tmp_path: Path, monkeypatch) -> None: - """Bare --enable-module in interactive mode triggers selector and applies selected ids.""" - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: False) - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands._select_module_ids_interactive", - lambda action, modules: ["generate"], - ) +def test_init_rejects_deprecated_enable_module_option(tmp_path: Path) -> None: + """`specfact init --enable-module` is removed; use `specfact module enable`.""" - observed_enable_ids: list[str] = [] + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "sync"]) - def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): - nonlocal observed_enable_ids - observed_enable_ids = list(enable_ids or []) - return [ - {"id": "sync", "version": "0.1.0", "enabled": True}, - {"id": "generate", "version": "0.1.0", "enabled": True}, - ] + assert result.exit_code != 0 + assert "No such option: --enable-module" in result.output - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", - _fake_get_discovered_modules_for_state, - ) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module"]) +def test_init_rejects_deprecated_disable_module_option(tmp_path: Path) -> None: + """`specfact init --disable-module` is removed; use `specfact module disable`.""" - assert result.exit_code == 0 - assert "generate" in observed_enable_ids + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "sync"]) - -def test_init_disable_module_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: - """Module state updates should not trigger template copy or IDE setup side effects.""" - - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", - lambda enable_ids=None, disable_ids=None: [ - {"id": "upgrade", "version": "0.1.0", "enabled": False}, - ], - ) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.validate_disable_safe", - lambda disable_ids, packages, enabled_map: {}, - ) - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.discover_all_package_metadata", - list, - ) - - def _fail_copy(*args, **kwargs): - raise AssertionError("copy_templates_to_ide must not be called for module-state-only operations") - - monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) - - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "upgrade"]) - - assert result.exit_code == 0 + assert result.exit_code != 0 + assert "No such option: --disable-module" in result.output def test_init_bootstrap_only_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: @@ -130,6 +61,7 @@ def _fail_copy(*args, **kwargs): result = runner.invoke(app, ["init", "--repo", str(tmp_path)]) assert result.exit_code == 0 assert "Use `specfact init ide`" in result.stdout + assert "module management has moved" in result.stdout.lower() def test_init_install_deps_runs_without_ide_template_copy(tmp_path: Path, monkeypatch) -> None: @@ -176,148 +108,13 @@ def _fail_copy(*args, **kwargs): assert calls[0][:4] == ["pip", "install", "-U", "beartype>=0.22.4"] -def test_init_force_disable_cascades_to_dependents(tmp_path: Path, monkeypatch) -> None: - """Force-disabling a dependency provider should cascade-disable dependents.""" - - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) - packages = [ - ( - Path("/tmp/plan"), - ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), - ), - ( - Path("/tmp/sync"), - ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), - ), - ] - monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_all_package_metadata", lambda: packages) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) - - observed_disable_ids: list[str] = [] - - def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): - nonlocal observed_disable_ids - observed_disable_ids = list(disable_ids or []) - return [ - {"id": "plan", "version": "0.1.0", "enabled": False}, - {"id": "sync", "version": "0.1.0", "enabled": False}, - ] - - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", - _fake_get_discovered_modules_for_state, - ) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) - - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "sync", "--force"]) - assert result.exit_code == 0 - assert "sync" in observed_disable_ids - assert "plan" in observed_disable_ids - - -def test_init_force_enable_cascades_to_dependencies(tmp_path: Path, monkeypatch) -> None: - """Force-enabling a module should auto-enable transitive dependencies.""" - - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) - packages = [ - ( - Path("/tmp/plan"), - ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), - ), - ( - Path("/tmp/sync"), - ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), - ), - ] - monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_all_package_metadata", lambda: packages) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) - - observed_enable_ids: list[str] = [] - - def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): - nonlocal observed_enable_ids - observed_enable_ids = list(enable_ids or []) - return [ - {"id": "plan", "version": "0.1.0", "enabled": True}, - {"id": "sync", "version": "0.1.0", "enabled": True}, - ] - - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", - _fake_get_discovered_modules_for_state, - ) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) - - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan", "--force"]) - assert result.exit_code == 0 - assert "plan" in observed_enable_ids - assert "sync" in observed_enable_ids - - -def test_init_enable_without_force_blocks_when_dependency_disabled(tmp_path: Path, monkeypatch) -> None: - """Enable should fail without force when required dependency is disabled.""" - - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) - packages = [ - ( - Path("/tmp/plan"), - ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), - ), - ( - Path("/tmp/sync"), - ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), - ), - ] - monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_all_package_metadata", lambda: packages) - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.read_modules_state", lambda: {"sync": {"enabled": False}} - ) - - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan"]) - - assert result.exit_code == 1 - assert "Cannot enable 'plan'" in result.stdout - assert "--force" in result.stdout - - -def test_init_list_modules_includes_workspace_level_modules(tmp_path: Path, monkeypatch) -> None: - """specfact init --list-modules includes modules from SPECFACT_MODULES_ROOTS (init-module-discovery-alignment).""" - modules_root = tmp_path / "ws_modules" - modules_root.mkdir() - extra_dir = modules_root / "extra_ws" - extra_dir.mkdir() - (extra_dir / "module-package.yaml").write_text( - "name: extra_ws\nversion: '0.1.0'\ncommands: [dummy]\n", encoding="utf-8" - ) - monkeypatch.setenv("SPECFACT_MODULES_ROOTS", str(modules_root)) - reg_dir = tmp_path / "registry" - reg_dir.mkdir() - monkeypatch.setenv("SPECFACT_REGISTRY_DIR", str(reg_dir)) - - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--list-modules"]) - - assert result.exit_code == 0 - assert "extra_ws" in result.stdout - - -def test_init_enable_workspace_level_module_succeeds(tmp_path: Path, monkeypatch) -> None: - """init --enable-module for a workspace-level module succeeds when discovery uses all roots.""" - modules_root = tmp_path / "ws_modules" - modules_root.mkdir() - extra_dir = modules_root / "extra_ws" - extra_dir.mkdir() - (extra_dir / "module-package.yaml").write_text( - "name: extra_ws\nversion: '0.1.0'\ncommands: [dummy]\n", encoding="utf-8" - ) - monkeypatch.setenv("SPECFACT_MODULES_ROOTS", str(modules_root)) - reg_dir = tmp_path / "registry" - reg_dir.mkdir() - monkeypatch.setenv("SPECFACT_REGISTRY_DIR", str(reg_dir)) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) - monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) +def test_resolve_templates_dir_uses_package_fallback_when_repo_templates_missing(tmp_path: Path, monkeypatch) -> None: + """Template resolution should fallback to package resource lookup for installed runtime parity.""" + fallback_templates = tmp_path / "installed" / "resources" / "prompts" + fallback_templates.mkdir(parents=True) + monkeypatch.setattr(init_commands, "find_package_resources_path", lambda *_args: fallback_templates) + monkeypatch.setattr("importlib.resources.files", lambda *_args: (_ for _ in ()).throw(RuntimeError("boom"))) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "extra_ws"]) + resolved = init_commands._resolve_templates_dir(tmp_path) - assert result.exit_code == 0, result.output + assert resolved == fallback_templates diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 0cce372f..9ec27d80 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -46,9 +46,11 @@ def test_get_modules_root_under_specfact_cli(): assert root.exists() or not root.exists() -def test_get_modules_roots_includes_cwd_modules_when_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - """Discovery roots include current-working-directory modules root when it exists.""" - cwd_modules = tmp_path / "modules" +def test_get_modules_roots_includes_workspace_dot_specfact_modules_when_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Discovery roots include workspace-local .specfact/modules when it exists.""" + cwd_modules = tmp_path / ".specfact" / "modules" cwd_modules.mkdir(parents=True) monkeypatch.chdir(tmp_path) monkeypatch.delenv("SPECFACT_MODULES_ROOTS", raising=False) @@ -58,6 +60,19 @@ def test_get_modules_roots_includes_cwd_modules_when_present(tmp_path: Path, mon assert cwd_modules.resolve() in roots +def test_get_modules_roots_ignores_workspace_plain_modules_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Discovery roots should not claim workspace ./modules as a SpecFact-managed root.""" + (tmp_path / "modules").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SPECFACT_MODULES_ROOTS", raising=False) + + roots = [path.resolve() for path in get_modules_roots()] + + assert (tmp_path / "modules").resolve() not in roots + + def test_discover_package_metadata_finds_example(tmp_path: Path): """Discovery finds packages that have module-package.yaml with name and commands.""" (tmp_path / "example_pkg").mkdir() diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py index 6aa0d1d7..1f90d5d1 100644 --- a/tests/unit/specfact_cli/registry/test_signing_artifacts.py +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -11,7 +11,10 @@ REPO_ROOT = Path(__file__).resolve().parents[4] SIGN_SCRIPT = REPO_ROOT / "scripts" / "sign-module.sh" +SIGN_PYTHON_SCRIPT = REPO_ROOT / "scripts" / "sign-modules.py" +VERIFY_PYTHON_SCRIPT = REPO_ROOT / "scripts" / "verify-modules-signature.py" SIGN_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "sign-modules.yml" +PR_ORCHESTRATOR_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml" def test_sign_module_script_exists(): @@ -34,9 +37,145 @@ def test_sign_module_script_invocation_prints_or_produces_checksum(tmp_path: Pat cwd=REPO_ROOT, timeout=10, ) - assert result.returncode == 0 or result.stderr or result.stdout - if result.returncode == 0 and result.stdout: - assert "sha256:" in result.stdout or "checksum" in result.stdout.lower() + assert result.returncode != 0 + assert "--allow-unsigned" in result.stderr or "--key-file" in result.stderr + + allow_unsigned = subprocess.run( + ["bash", str(SIGN_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert allow_unsigned.returncode == 0 + assert "sha256:" in allow_unsigned.stdout or "checksum" in allow_unsigned.stdout.lower() + + +def test_sign_module_script_supports_key_file_flag_order(tmp_path: Path): + """Wrapper SHALL accept --key-file option before manifest and fail clearly on bad key path.""" + if not SIGN_SCRIPT.exists(): + pytest.skip("sign-module.sh not present") + manifest = tmp_path / "module-package.yaml" + manifest.write_text("name: test\nversion: 0.1.0\ncommands: [c]\n", encoding="utf-8") + + import subprocess + + result = subprocess.run( + ["bash", str(SIGN_SCRIPT), "--key-file", str(tmp_path / "missing.pem"), str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert result.returncode != 0 + assert "No such file or directory" in result.stderr or "missing.pem" in result.stderr + + +def test_sign_module_script_help_mentions_passphrase_options(): + """Wrapper help SHALL document passphrase options for encrypted private keys.""" + if not SIGN_SCRIPT.exists(): + pytest.skip("sign-module.sh not present") + import subprocess + + result = subprocess.run( + ["bash", str(SIGN_SCRIPT), "--help"], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert result.returncode == 0 + assert "--passphrase" in result.stdout + assert "--passphrase-stdin" in result.stdout + + +def test_sign_modules_py_requires_key_unless_allow_unsigned(tmp_path: Path): + """sign-modules.py SHALL fail without key unless --allow-unsigned is passed.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + manifest = tmp_path / "module-package.yaml" + manifest.write_text("name: test\nversion: 0.1.0\ncommands: [c]\n", encoding="utf-8") + import subprocess + + no_key = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert no_key.returncode != 0 + assert "--key-file" in no_key.stderr or "--allow-unsigned" in no_key.stderr + + with_override = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert with_override.returncode == 0 + + +def test_sign_modules_py_help_mentions_passphrase_sources(): + """sign-modules.py help SHALL expose passphrase flag and stdin mode.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + import subprocess + + result = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--help"], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert result.returncode == 0 + assert "--passphrase" in result.stdout + assert "--passphrase-stdin" in result.stdout + assert "--allow-same-version" in result.stdout + + +def test_sign_modules_py_checksum_changes_when_module_files_change(tmp_path: Path): + """Checksum SHALL reflect full module payload, not only manifest metadata.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + module_dir = tmp_path / "sample-module" + module_dir.mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + source.parent.mkdir(parents=True) + manifest.write_text("name: sample\nversion: 0.1.0\ncommands: [sample]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + import subprocess + + import yaml + + first = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert first.returncode == 0 + first_data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + first_checksum = first_data.get("integrity", {}).get("checksum") + assert isinstance(first_checksum, str) and first_checksum.startswith("sha256:") + + source.write_text("print('v2')\n", encoding="utf-8") + second = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert second.returncode == 0 + second_data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + second_checksum = second_data.get("integrity", {}).get("checksum") + assert isinstance(second_checksum, str) and second_checksum.startswith("sha256:") + assert second_checksum != first_checksum def test_sign_modules_workflow_exists(): @@ -53,3 +192,30 @@ def test_sign_modules_workflow_valid_yaml(): data = yaml.safe_load(SIGN_WORKFLOW.read_text(encoding="utf-8")) assert data is not None assert isinstance(data, dict) + + +def test_verify_modules_script_exists(): + """Verification script SHALL exist for CI signature validation.""" + assert VERIFY_PYTHON_SCRIPT.exists(), "scripts/verify-modules-signature.py must exist" + + +def test_pr_orchestrator_contains_verify_module_signatures_job(): + """PR orchestrator SHALL include module signature verification gate.""" + if not PR_ORCHESTRATOR_WORKFLOW.exists(): + pytest.skip("pr-orchestrator workflow not present") + content = PR_ORCHESTRATOR_WORKFLOW.read_text(encoding="utf-8") + assert "verify-module-signatures" in content + assert "verify-modules-signature.py --require-signature" in content + assert "--enforce-version-bump" in content + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in content + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in content + + +def test_sign_modules_workflow_uses_private_key_and_passphrase_secrets(): + """sign-modules workflow SHALL use encrypted-key secret and passphrase secret.""" + if not SIGN_WORKFLOW.exists(): + pytest.skip("workflow not present") + content = SIGN_WORKFLOW.read_text(encoding="utf-8") + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in content + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in content + assert "--enforce-version-bump" in content diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index 49f96f61..4569916b 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -238,6 +238,19 @@ def test_copy_templates_overwrites_with_force(self, tmp_path): content = (cursor_dir / "specfact.01-import.md").read_text() assert "New Content" in content or "# New Content" in content + def test_copy_templates_includes_backlog_add_prompt_when_template_exists(self, tmp_path): + """Copy flow should install backlog-add prompt into IDE target when template exists.""" + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.backlog-add.md").write_text( + "---\ndescription: Add backlog item\n---\n# Backlog Add\n$ARGUMENTS" + ) + + copied_files, _settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=True) + + assert any(path.name == "specfact.backlog-add.md" for path in copied_files) + assert (tmp_path / ".cursor" / "commands" / "specfact.backlog-add.md").exists() + def test_specfact_commands_includes_backlog_add_prompt() -> None: """IDE setup command list includes backlog-add prompt template.""" diff --git a/tests/unit/utils/test_startup_checks.py b/tests/unit/utils/test_startup_checks.py index a206689e..7aa95f70 100644 --- a/tests/unit/utils/test_startup_checks.py +++ b/tests/unit/utils/test_startup_checks.py @@ -15,6 +15,7 @@ update_metadata, ) from specfact_cli.utils.startup_checks import ( + ModuleFreshnessCheckResult, TemplateCheckResult, VersionCheckResult, calculate_file_hash, @@ -453,8 +454,13 @@ class TestPrintStartupChecks: @patch("specfact_cli.utils.startup_checks.check_ide_templates") @patch("specfact_cli.utils.startup_checks.check_pypi_version") @patch("specfact_cli.utils.startup_checks.console") + @patch("specfact_cli.utils.startup_checks.update_metadata") def test_print_startup_checks_no_issues( - self, mock_console: MagicMock, mock_version: MagicMock, mock_templates: MagicMock + self, + _mock_update_metadata: MagicMock, + mock_console: MagicMock, + mock_version: MagicMock, + mock_templates: MagicMock, ): """Test when no issues are found.""" mock_templates.return_value = None @@ -476,8 +482,10 @@ def test_print_startup_checks_no_issues( @patch("specfact_cli.utils.startup_checks.check_ide_templates") @patch("specfact_cli.utils.startup_checks.check_pypi_version") @patch("specfact_cli.utils.startup_checks.console") + @patch("specfact_cli.utils.startup_checks.update_metadata") def test_print_startup_checks_outdated_templates( self, + _mock_update_metadata: MagicMock, mock_console: MagicMock, mock_version: MagicMock, mock_templates: MagicMock, @@ -524,8 +532,10 @@ def test_print_startup_checks_outdated_templates( @patch("specfact_cli.utils.startup_checks.check_ide_templates") @patch("specfact_cli.utils.startup_checks.check_pypi_version") @patch("specfact_cli.utils.startup_checks.console") + @patch("specfact_cli.utils.startup_checks.update_metadata") def test_print_startup_checks_version_update_major( self, + _mock_update_metadata: MagicMock, mock_console: MagicMock, mock_version: MagicMock, mock_templates: MagicMock, @@ -563,8 +573,10 @@ def test_print_startup_checks_version_update_major( @patch("specfact_cli.utils.startup_checks.check_ide_templates") @patch("specfact_cli.utils.startup_checks.check_pypi_version") @patch("specfact_cli.utils.startup_checks.console") + @patch("specfact_cli.utils.startup_checks.update_metadata") def test_print_startup_checks_version_update_minor( self, + _mock_update_metadata: MagicMock, mock_console: MagicMock, mock_version: MagicMock, mock_templates: MagicMock, @@ -621,8 +633,13 @@ def test_print_startup_checks_version_update_no_type( @patch("specfact_cli.utils.startup_checks.get_last_checked_version", return_value=None) @patch("specfact_cli.utils.startup_checks.check_ide_templates") @patch("specfact_cli.utils.startup_checks.check_pypi_version") + @patch("specfact_cli.utils.startup_checks.update_metadata") def test_print_startup_checks_version_check_disabled( - self, mock_version: MagicMock, mock_templates: MagicMock, _mock_version_meta: MagicMock + self, + _mock_update_metadata: MagicMock, + mock_version: MagicMock, + mock_templates: MagicMock, + _mock_version_meta: MagicMock, ): """Test that version check can be disabled.""" print_startup_checks(check_version=False) @@ -828,3 +845,98 @@ def test_metadata_updated_after_checks( call_kwargs = mock_update_metadata.call_args[1] assert "last_checked_version" in call_kwargs assert "last_version_check_timestamp" in call_kwargs + + @patch("specfact_cli.utils.startup_checks.check_ide_templates") + @patch("specfact_cli.utils.startup_checks.check_pypi_version") + @patch("specfact_cli.utils.startup_checks.update_metadata") + def test_module_freshness_check_runs_on_version_change( + self, + mock_update_metadata: MagicMock, + mock_check_version: MagicMock, + mock_check_templates: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Module freshness check should run when CLI version changed.""" + from specfact_cli.utils import startup_checks + + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + update_metadata(last_checked_version="0.0.1") + mock_check_templates.return_value = None + mock_check_version.return_value = VersionCheckResult( + current_version="1.0.0", + latest_version="1.0.0", + update_available=False, + update_type=None, + error=None, + ) + called = {"module_freshness": False} + + def _module_freshness(_repo_path: Path): + called["module_freshness"] = True + return + + monkeypatch.setattr(startup_checks, "check_module_freshness", _module_freshness, raising=False) + + print_startup_checks(repo_path=tmp_path, check_version=True) + + assert called["module_freshness"] is True + + @patch("specfact_cli.utils.startup_checks.check_ide_templates") + @patch("specfact_cli.utils.startup_checks.check_pypi_version") + @patch("specfact_cli.utils.startup_checks.update_metadata") + @patch("specfact_cli.utils.startup_checks.console") + def test_startup_warns_when_project_or_user_modules_are_stale( + self, + mock_console: MagicMock, + mock_update_metadata: MagicMock, + mock_check_version: MagicMock, + mock_check_templates: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Startup guidance should include both project and user module init commands when stale.""" + from specfact_cli.utils import startup_checks + + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + mock_check_templates.return_value = None + mock_check_version.return_value = VersionCheckResult( + current_version="1.0.0", + latest_version="1.0.0", + update_available=False, + update_type=None, + error=None, + ) + monkeypatch.setattr( + startup_checks, + "check_module_freshness", + lambda _repo_path: ModuleFreshnessCheckResult( + project_outdated=True, + user_outdated=True, + project_outdated_modules=["backlog-core"], + user_outdated_modules=["bundle-mapper"], + project_modules_root=tmp_path / ".specfact" / "modules", + user_modules_root=mock_home / ".specfact" / "modules", + ), + raising=False, + ) + monkeypatch.setattr(startup_checks, "get_last_checked_version", lambda: None, raising=False) + monkeypatch.setattr(startup_checks, "get_last_version_check_timestamp", lambda: None, raising=False) + + print_startup_checks(repo_path=tmp_path, check_version=True) + + for call in mock_console.print.call_args_list: + args = call[0] if call[0] else [] + for arg in args: + if hasattr(arg, "renderable"): + renderable_str = str(arg.renderable) + if ( + "specfact module init --scope project" in renderable_str + and "specfact module init" in renderable_str + ): + return + pytest.fail("Module freshness guidance message not found in console output") From 23d445c2df1812ab2fed4461a1b68fa572701b79 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Feb 2026 00:10:04 +0100 Subject: [PATCH 2/3] fix: stabilize module signature hashing across environments --- modules/backlog-core/module-package.yaml | 6 +-- modules/bundle-mapper/module-package.yaml | 6 +-- scripts/sign-modules.py | 11 ++++- scripts/verify-modules-signature.py | 11 ++++- .../modules/analyze/module-package.yaml | 4 +- .../modules/auth/module-package.yaml | 4 +- .../modules/backlog/module-package.yaml | 4 +- .../modules/contract/module-package.yaml | 4 +- .../modules/drift/module-package.yaml | 4 +- .../modules/enforce/module-package.yaml | 4 +- .../modules/generate/module-package.yaml | 4 +- .../modules/import_cmd/module-package.yaml | 4 +- .../modules/init/module-package.yaml | 4 +- .../modules/migrate/module-package.yaml | 4 +- .../module_registry/module-package.yaml | 4 +- .../modules/patch_mode/module-package.yaml | 4 +- .../modules/plan/module-package.yaml | 4 +- .../modules/policy_engine/module-package.yaml | 4 +- .../modules/project/module-package.yaml | 4 +- .../modules/repro/module-package.yaml | 4 +- .../modules/sdd/module-package.yaml | 4 +- .../modules/spec/module-package.yaml | 4 +- .../modules/sync/module-package.yaml | 4 +- .../modules/upgrade/module-package.yaml | 4 +- .../modules/validate/module-package.yaml | 4 +- .../registry/test_signing_artifacts.py | 45 +++++++++++++++++++ 26 files changed, 113 insertions(+), 50 deletions(-) diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml index 428d5486..f455134a 100644 --- a/modules/backlog-core/module-package.yaml +++ b/modules/backlog-core/module-package.yaml @@ -1,5 +1,5 @@ name: backlog-core -version: 0.1.0 +version: 0.1.1 commands: - backlog command_help: @@ -22,8 +22,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:2f40084e130787b41e82f2f496dc80e41635eb7e095adbb3af31a074824cb70d - signature: OyR/QkRQqbdj2OG6Bf3+vmFFdbd51FRQyNMDlguMgWf20koo6pTegEn36F/RJCpXTWnRVrQKpNNr8M27iUSLBQ== + checksum: sha256:9f973a09f2fbb3ac3c727b3cecd44907956c7e751930c31f2f78b7713c2fdf18 + signature: 3NTfSnYBVJ5mjD0pVBIrSybBIWNgTfH3mYFqUFQkQi802OAq1xjf3/3IuGF1ZjjlgLV+yMkSgc8sfCzP6Dc/Ag== 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 4f304d4c..39396a98 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -1,5 +1,5 @@ name: bundle-mapper -version: 0.1.0 +version: 0.1.1 commands: [] pip_dependencies: [] module_dependencies: [] @@ -19,8 +19,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:526ea4f39b04537bb763cbd3bcd4912037408dfd8911ab6f051fc47ba31e348a - signature: Z5LBR5mM/+Js4o66xwoCkPbZKzJrdQXlJfjsY3VMCHVZIG7ccHsmu4PE4NAlSymiyH79toiZbYaBLCzmH48ZCQ== + checksum: sha256:bcf7bd40d4c9d137581b2e4d34f3708f7af9ea99941742500bf43e3e09f448ea + signature: v1mMck2+ltn5N3tAdFWZ7OKZziUC2bY+2n05U8ioETL9HvhxP3iM6TfgQt9Cl8/O/ZJrkhPJz4Uh0oMJnAZxAQ== dependencies: [] description: Map backlog items to best-fit modules using scoring heuristics. license: Apache-2.0 diff --git a/scripts/sign-modules.py b/scripts/sign-modules.py index 37f6660a..f310abc2 100755 --- a/scripts/sign-modules.py +++ b/scripts/sign-modules.py @@ -15,6 +15,9 @@ import yaml +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +_IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} + def _canonical_payload(manifest_data: dict[str, Any]) -> bytes: payload = dict(manifest_data) @@ -26,9 +29,15 @@ def _module_payload(module_dir: Path) -> bytes: if not module_dir.exists() or not module_dir.is_dir(): msg = f"Module directory not found: {module_dir}" raise ValueError(msg) + def _is_hashable(path: Path) -> bool: + rel = path.relative_to(module_dir) + if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): + return False + return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES + entries: list[str] = [] files = sorted( - (path for path in module_dir.rglob("*") if path.is_file()), + (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), key=lambda p: p.relative_to(module_dir).as_posix(), ) for path in files: diff --git a/scripts/verify-modules-signature.py b/scripts/verify-modules-signature.py index c0e1d24c..b6f685fd 100755 --- a/scripts/verify-modules-signature.py +++ b/scripts/verify-modules-signature.py @@ -13,6 +13,9 @@ import yaml +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +_IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} + def _canonical_manifest_payload(manifest_data: dict[str, Any]) -> bytes: payload = dict(manifest_data) @@ -21,9 +24,15 @@ def _canonical_manifest_payload(manifest_data: dict[str, Any]) -> bytes: def _module_payload(module_dir: Path) -> bytes: + def _is_hashable(path: Path) -> bool: + rel = path.relative_to(module_dir) + if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): + return False + return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES + entries: list[str] = [] files = sorted( - (path for path in module_dir.rglob("*") if path.is_file()), + (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), key=lambda p: p.relative_to(module_dir).as_posix(), ) for path in files: diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index fedfe520..d30d9c03 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Analyze codebase quality, contracts, and architecture signals. license: Apache-2.0 integrity: - checksum: sha256:3225f0d57a37469d2ce00c3654d3d36ca7d286c32767443aa0704eefed657d9c - signature: jiYU5za8M0Yo80TnH1Hn+FcFFvwdXJrngEHYRmApSiKbLsQyvoNAMA7mFEg9fX7Ly9qV7NBdO2+m5X8YcRhPCw== + checksum: sha256:f6b3bbe0d380cd6ce305be4af8649fc6343b8fb1829da6218278ce37213bbec5 + signature: z5Lmf++lduoS+dlL0iNRP535Yug4yN/L6B4jJs1KAoIeVtIfZ/xA/veSxUD/24WSAGx4fPHZE6kwvb74hC07CA== diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index 85841885..715db2d9 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Authenticate SpecFact with supported DevOps providers. license: Apache-2.0 integrity: - checksum: sha256:ca38c983e10c62d8d65f557417a3643409b76e06ebe47fd54d0615582cb3444c - signature: v1TpsqgswbM089IqRINWuPzqpy/lC01kT2xYM+RjLq+GnK9fbix2HRADomZIp1dzan/VB4WWZwX6vekZ+lHLCw== + checksum: sha256:ebb79ba19875e6669778e9ed490d51a676fdd0ac91823521a049a3565b486bb3 + signature: 86T1P4Tem3AD92shR1R5ie9eLzRJ6nX9/FRjW00quaYyZRgvFgePdvc+cXHUAkaPRDtu+kMG0kKvF1TkH5ovAw== diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index 11731732..73ea33a4 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -28,5 +28,5 @@ publisher: description: Manage backlog ceremonies, refinement, and dependency insights. license: Apache-2.0 integrity: - checksum: sha256:13c85ff70048d91a472fc270892a360581226ee74d8c4d7f28d18c192973ed63 - signature: mcnkVMtd9I/osvO69d1v0uiquQ7wYkY7zkacbgrGydvm+1EX3XaiFFf0o1p1G6xZmkx6i3kQmGmyOimU+K1eBg== + checksum: sha256:7d03208b0b6b801a9c6e146846391211f7cffe57c88640337ce43e7448c3f673 + signature: ZOQNPi4ydBZqqbhJ9Li8JuT2+X1os27/1VCYt2HRojJLAeJvIjNd7NsXuoZorAC6frm7thJTjqEnJB/805leCA== diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index 0cc27aad..6fe47c55 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Validate and manage API contracts for project bundles. license: Apache-2.0 integrity: - checksum: sha256:bbf10617fe322aed484e8991d8094b123c71016cdf9077c42f9e705cebdfe967 - signature: ieQoTk1RRFyhVn39kShgzllb4yKDqvxBvK7PPPow45NCYszXRVxTTscQgb4+BFBcccI1uotgOT9MV5d3up9vCg== + checksum: sha256:7125e2fbf321727e41bbb2546577dc11d04a51669e57f809f77ad4da3c0232ed + signature: yQGYWpda3Kgg+HK5HSw9Oqo3L0tghwt/3yMXPo9I7bn1bzFduc8cdVe3E8bP9kLZ+QL2MnSrp8Ojxvd0shvdBw== diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index ca883b81..fa31f9cc 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Detect and report drift between code, plans, and specs. license: Apache-2.0 integrity: - checksum: sha256:5c7b8bd466f191028545832f975a81936786f5f97fbec04e83b2e64b9a0f0f5e - signature: s/WrxtR9AgiLQemWUHewcjGJOMTUOjLnQ/elDDumPAhiH7SX92AEbQxN6ZCycgCSUAk3i22XVcS30DrSwm72DA== + checksum: sha256:71f48b98ece0d271d1013a93021cc082d178c7876895d1033065d4be26adc1a0 + signature: 8yhxRp9pvVuKVBSjgi+6L5vJDjBgtepyWzHLVNC+OHv/e5zg5vUilw89V1tuxxvZHo89Cp8tAcsQgKxk2VVLDg== diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 0b7e72e4..05269112 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -16,5 +16,5 @@ publisher: description: Apply governance policies and quality gates to bundles. license: Apache-2.0 integrity: - checksum: sha256:a0489a0b7d89d858ee9e31aa9c4e6bbdc6d514fd1dd0e133adaafdb859dd4000 - signature: cMle25qRpzZNvgMfsdhiJlrHQGk1fsiFXpeiTplRdSVo5xegyzwWvXO/7jb59nBQX2p/JjqKUnGh/A143G57AA== + checksum: sha256:ad5d5ca9629147a1163dda4c03ee63914048e456e07fdcbb52c3cb7016a56fc9 + signature: IIeQPnCLs09tqK9h9NJRRB48gutllpgmLiORM0Mj1d9d6Rr2XDPbAlrQiX2NhB3OcDjBQW32Co3rPQHXcy/WDQ== diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index a1b00875..44463800 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -16,5 +16,5 @@ publisher: description: Generate implementation artifacts from plans and SDD. license: Apache-2.0 integrity: - checksum: sha256:bc1072b0466ba02299e2cc75d92bba741fd34634547731e420ef27f33f2991ef - signature: cBcnxh6LJ9TYiEp29XXoJSFPWqmwHTV/cnCRM0eLJOhBoqI35URW3s+CX71JVGa1agoExlfk+1g6LKsM1Z0kBg== + checksum: sha256:d840cf1cf34cc1c19411a9e99a71ade573cd6f877a9b76f5b3a5db338ba176c1 + signature: LqNTq2SXu2h47H74Tgenh0EYyIgxnQ1HGG2U7PECuPH187hchgY5azT+JSvXR6mE+jX2jXRX1/pYWY0pVt+HBQ== diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index fa5cf2fd..1f44a5f6 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Import projects and requirements from code and external tools. license: Apache-2.0 integrity: - checksum: sha256:322a490faeb941f9b7fd6c321ebfaeadcb19a8c4c3823b0753fc643457117858 - signature: scipNFsYiWjH7U5y/dvxu1bpFg7b2Kb1YN37E4AxW8Y26wKAU0qJqGzsvtuJH8p8v890xRwyR40QAtSA5paWCQ== + checksum: sha256:0afde8c650b21b7e2a9e921ced829b47967b07dc0b39c5fd73570b20de75f51a + signature: 5upSkjAkCRIN/BJZwAHnJ9vt61i+cESzvt922Cjvez84OX5u4fd3NjSGNsseMUpmrUzExLNY4zYahxEyOTLZCQ== diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index e610091a..530bcad6 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:cb1eafe05287aca103cd29e87633cbbe23276d2dbf68c9b3a86c23231ec933ce - signature: VFZMmpfqN7zY5yg4mL5Cv6fQFsodE0T1hRWpsk5zlzyk7Zs50vy73tdWo0tmWyTowaKuA5AnxYEQ5sSM4vnMAQ== + checksum: sha256:6d984c7e66a51171cdc0d9a01f7085bb25ba8e62cdaff66b56d94cda5cf7536c + signature: KF4bJEJD3rM0JsVm5IFV9qn5qdp8hZorBDZA8cXBAwP6nJfH4tGcTXkGFu60kFKsRpxf8C3qlR6aVHRWAcbtDA== diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 99100064..6ba26fbb 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Migrate project bundles across supported structure versions. license: Apache-2.0 integrity: - checksum: sha256:528f7cd48964162e33f31871758e9d613cd96dd06a1344069945c7e603bccf2a - signature: bKrfU8SGoXCn6LBfLPV/4kXzk6djWA3uCs1Uq2ENx2bXrN1gjr+3f7cateZ6WBxw1Kz52QO7xGxqvw8g9DFfCw== + checksum: sha256:c4199b9d28d97ae3f3198c4b6d4086cf20a71b14eba3c78b27111368419c3767 + signature: cs2H0NB10jZTrh0tkk8TybW8wogCax2Dt9gjxR/0pS9Z0zz1ZDBbV5JgoibHRU48UWXwuUiPkbLRJYwWnsVqBw== diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index f0ac34d6..b8d7b0ff 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 integrity: - checksum: sha256:56470e8cd5f5e64c93b51e7b7e7e831917b0da1ceee51a7e61d24e31d4127fc8 - signature: kB+2MVbab+icq5S0dgrm8/fTKF3Qo4T9EfBVn8nYPcr5zyh7oJKqA5Ja5FYXpL8MA1uLM0I2K/Gyuy3kTaW6DA== + checksum: sha256:1dff9652feedc61a8c9431148732a611588a2a5f5f0ed6a65c431b4d123a6254 + signature: ctvLzRFWkRqVAZFUF0Tj/RNmaa/BDGieRDoNNjbtB++AtxmK4KhF1c4bqYkjhJ5SkG9xGHC6nK06P4UOEFcBAA== diff --git a/src/specfact_cli/modules/patch_mode/module-package.yaml b/src/specfact_cli/modules/patch_mode/module-package.yaml index 4a8cf367..452e2ca5 100644 --- a/src/specfact_cli/modules/patch_mode/module-package.yaml +++ b/src/specfact_cli/modules/patch_mode/module-package.yaml @@ -16,5 +16,5 @@ publisher: description: Prepare, review, and apply structured repository patches safely. license: Apache-2.0 integrity: - checksum: sha256:44ab2e64473ab7b31086ee0f3f2153aa545b7bacd628cff844576c6ac028bdf7 - signature: uNXdthnZTJp0GbFvZ4Jm7jlV/1w0UfSKiME1/TNvLaV1SUOrGTd0nJCEZCVxnhxflIvzB/+CRy1VP5fIl22OBg== + checksum: sha256:b77c31a7d7e3d96e5d34dab9507e241dd939c3d45a1e9e8f69e04d5817488d21 + signature: Hdb11DFA7/yJlrFywntr0GKrupDUnKgyFAQ/y6DEqretqjKA/+x+FICDKAGCq7Mm5uoPcD7/ZCMsZJdjHDyYAA== diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index 2cd0d422..7b78e3e1 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -16,5 +16,5 @@ publisher: description: Create and manage implementation plans for project execution. license: Apache-2.0 integrity: - checksum: sha256:4119e9b502b8d69d53a0335501d169a45a2c2fac64268a99e789b393fa2ac577 - signature: Ei+Ec2srSgm4S5ww4EPviCjOSM2U5rFAzZMNIRZYU1UGpdKlVgOGdKd7vOH6i9jblTSwxccKNLfylW+RFKfkDw== + checksum: sha256:e2a19eda7ab371371e7e387b46326471e49224163afbbf9934bf776e433f21e0 + signature: th+bRnk+OtJXXWU1tfQWDwOstfJ2fIy2v1T4nLSqpoaMT1dcV4mPS0yNBenUbhok0DA5mEoRggILwVm1LlneBQ== diff --git a/src/specfact_cli/modules/policy_engine/module-package.yaml b/src/specfact_cli/modules/policy_engine/module-package.yaml index d0b8da9b..cd2f17cd 100644 --- a/src/specfact_cli/modules/policy_engine/module-package.yaml +++ b/src/specfact_cli/modules/policy_engine/module-package.yaml @@ -18,8 +18,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:bbdccc5c364a28ff3b66123e31dbd685c4a2d783a8a13a888ca4a4aa5d0841aa - signature: RGQscLzQBcNOfqa5Gq5mpmGHjl2zdW4DFMBMpatNQSIqNhtnZEHUTydYbQcyWpWRzHaSvCUtTOhgjq4CpNJYCw== + checksum: sha256:c799eff0bf16445c403b7b3037524c1bca71b51673ae5c55885860886e2c5dc8 + signature: 5Osen9Zt1GZjIxAiBQUYZKJZMcP/zkvQXzHhn0NYLK4M5jFeLgPsZiEH9l4NRBkQBJREIyQ+2z/npLZPCfwTBw== 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 71af0888..7a2e28e1 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Manage project bundles, contexts, and lifecycle workflows. license: Apache-2.0 integrity: - checksum: sha256:239c30e346e7f4d752cc2729e264d13fdf1cc92543f3630147fbd98f129a2db3 - signature: /atX+KwwHILB8ehUlk7Z0VI0o4dsAWmxiWL/BMduJ0PBwEs5pBfR0NGfBW/lpnkzgsStW60kwYA8hhxsTs4GDg== + checksum: sha256:356ab38ff9a044f26c4c9750ad6eec7192303a5f6b4b25f829cca5aee3e41192 + signature: AmbkEPXATKCZsTVmAzUpB/Ou8DVnLM/ffEpP05lOqM/f5XQvTbNyPpuNq6tq85KKaJJDSLKNACHCOHv3dlmFCg== diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index a1c33951..ccae2815 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Run reproducible validation and diagnostics workflows end-to-end. license: Apache-2.0 integrity: - checksum: sha256:6e51217b0d5157827dc6dbb120a5ed8ae58e7e11c71b00ff09ae2599e8a33942 - signature: chxWJIjruzEbRUb8A6BOVA9aprfogBRwgZjpTRcvScmaqZ9GoBocIqbZNjbBgxKVonUAdRWicGV84hjQ2HxaDA== + checksum: sha256:f9c90f35d3285ea9bfa4ebfcc0f304f43394848ab3bbe6063acd515404a85f06 + signature: lj69naFWhg9Va5c3XSCkCNJ68UOhRIwUbIP+yANLTpmK24eAEpfHgUzyQEKCZh9X+a4+f7IDv4elYGvLyl2gDg== diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 124ef7fb..6fd6756a 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Create and validate Spec-Driven Development manifests and mappings. license: Apache-2.0 integrity: - checksum: sha256:a8a530c2b4637fadce5ab205bde13c657e2c3571863fa2c2fa798550b24796b2 - signature: TsD0B3anf1qpWitiJ/mmtc96pYy2U2QUgTDq4HRkLSt/2DbfmlVbAMsEqQM6EzMy73V+Xxo45BP4L/gPPV+yCg== + checksum: sha256:044352d6e7dd4d3a3b29019ca95676a4c96ff4594e692829e31467dd9d348c28 + signature: YUNrc6PF7TJdGSwrGnh320g7VeSbUFoyeKOYWhcP64DD4vonRUPqeuUj6IvXFczTGEcbkGJ6zrrOktuxyUSUAQ== diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index eb31d734..6cd48ac9 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Integrate and run API specification and contract checks. license: Apache-2.0 integrity: - checksum: sha256:1e8693dddcb3688413378fb8eead352fec21bba95a517cfcb5330b36af05436a - signature: zjI4qvXPw+NlYG6RtDhxKMu5tRijkk6ZUXBvAz3mbdN3lgUN32fddJ4RK4UPt0i5PvlEwWZvcHWL4mHcpConBA== + checksum: sha256:e43ae430e82e59f4cf546df353e94a5be3c36ff5b6e3efba37ad5ca84a894e3c + signature: ltxYz9kloJQ+OCWWnB/B9P95PVNxM+lsQ0VOGjlsU0uZL5EaByAUjCuoAF5/NsQ1fsmaLpOWRsJYviYYL+6WBA== diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 8e893979..4fd40136 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -18,5 +18,5 @@ publisher: description: Synchronize repository state with connected external systems. license: Apache-2.0 integrity: - checksum: sha256:4591703ea03bcd89ea6d950b5ac4ce231ae47af9b4bc69704d5cd3acf708f38d - signature: xJnWHRCNnfUh6+Xsn29Y9lRhL66xfRPgbXlITVDZpgerMVg2uH2/SiTAPoRTxI/CHXKMCyD3Sq5zcbHG6KiyBw== + checksum: sha256:748a6875c0dc83cc24992445f6a5f28fdfd01be52eec22bd956bc9965c6d4120 + signature: GwFyWP/QN5MHgP3zQt6nfWRTutNxFysinBTZrf6O80MpNmr0+6PwciQ3zLl0OrMVmml6Rm0TzjHszh3XypA1Bg== diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index c0a35ca5..28a34c6f 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Check and apply SpecFact CLI version upgrades. license: Apache-2.0 integrity: - checksum: sha256:fe896330e409c951ce5395f3f92b91c2e787d17ecb3c200936ccd65606721023 - signature: U6RESOJ+anV2xa4YKtdoI1v9Vg4q7/KUxQn1BVNhQ2w/XGqKB0Jb+OWGbIn7hK3Z56ioczVNbcfqkbCpCxTVBg== + checksum: sha256:76305e1fb38f2b6e8d30180beee67c6cfd60636d7ca8d4becdde7b02246f152a + signature: vflvfJCcZA2km2ClnJQQ4eFAY5GbTUOTvH2Mj1onu1FRpFsJ0K62yay9DFLrinobcTOyMVfdYNUe+0B54Wo+Cg== diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 3b80e7af..0a27498a 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -15,5 +15,5 @@ publisher: description: Run schema, contract, and workflow validation suites. license: Apache-2.0 integrity: - checksum: sha256:22066b811f0bf3bcda202e4b1b8087d4183e268b64284441b04efe49f21c2e0a - signature: 4OnHXb/NqELblXF8/nOj9Freb5hC2n29bs46Emf9JrmpD3uO8XJPJN/5pb/KZlWUan02I3WoyYybkl5xX+fpBg== + checksum: sha256:0cfd33fa7ea7a02016c96324b7853875019e7666909f4af8cc50c4c85bf740e1 + signature: KXkCKbsoPR2J7U3FnH6r8NtVJaGHMFp3o5yb+Hfj1XXN+pUL2SlV1ZqpzTseh3ZKkoNdKB8yLytkKl0jVa/DBA== diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py index 1f90d5d1..d9e96d94 100644 --- a/tests/unit/specfact_cli/registry/test_signing_artifacts.py +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -178,6 +178,51 @@ def test_sign_modules_py_checksum_changes_when_module_files_change(tmp_path: Pat assert second_checksum != first_checksum +def test_sign_modules_py_ignores_transient_cache_files(tmp_path: Path): + """Checksum SHALL ignore generated cache files such as __pycache__/*.pyc.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + module_dir = tmp_path / "sample-module" + module_dir.mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + source.parent.mkdir(parents=True) + manifest.write_text("name: sample\nversion: 0.1.0\ncommands: [sample]\n", encoding="utf-8") + source.write_text("print('stable')\n", encoding="utf-8") + + import subprocess + import yaml + + first = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert first.returncode == 0 + first_data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + first_checksum = first_data.get("integrity", {}).get("checksum") + assert isinstance(first_checksum, str) and first_checksum.startswith("sha256:") + + cache_dir = module_dir / "src" / "__pycache__" + cache_dir.mkdir(parents=True) + (cache_dir / "main.cpython-312.pyc").write_bytes(b"\x00\x01cache") + + second = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert second.returncode == 0 + second_data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + second_checksum = second_data.get("integrity", {}).get("checksum") + assert isinstance(second_checksum, str) and second_checksum.startswith("sha256:") + assert second_checksum == first_checksum + + def test_sign_modules_workflow_exists(): """CI workflow .github/workflows/sign-modules.yml SHALL exist.""" assert SIGN_WORKFLOW.exists(), "sign-modules.yml workflow must exist" From f71ef25b92a57f38118bb7c89cfbc397c4ff0588 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Feb 2026 00:46:23 +0100 Subject: [PATCH 3/3] fix: stabilize bundle module signature verification in CI --- modules/backlog-core/module-package.yaml | 4 +-- modules/bundle-mapper/module-package.yaml | 4 +-- scripts/sign-modules.py | 33 +++++++++++++++---- scripts/verify-modules-signature.py | 32 ++++++++++++++---- src/specfact_cli/registry/module_installer.py | 2 +- .../registry/test_signing_artifacts.py | 18 ++++++++++ 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml index f455134a..bfc1710d 100644 --- a/modules/backlog-core/module-package.yaml +++ b/modules/backlog-core/module-package.yaml @@ -22,8 +22,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:9f973a09f2fbb3ac3c727b3cecd44907956c7e751930c31f2f78b7713c2fdf18 - signature: 3NTfSnYBVJ5mjD0pVBIrSybBIWNgTfH3mYFqUFQkQi802OAq1xjf3/3IuGF1ZjjlgLV+yMkSgc8sfCzP6Dc/Ag== + checksum: sha256:0a7682f56e9d5fb3d4da59ae673825a652351fede244f4efd5382cae2560e062 + signature: Cph0v8bwE0tddnSm5P2FRHRs1PPk760GvK3/+JU1KEdAs5UAk3f/BDQzdDlAX9jMTsis9qlCU3Ji5AJkI+QZCA== 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 39396a98..85567227 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -19,8 +19,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:bcf7bd40d4c9d137581b2e4d34f3708f7af9ea99941742500bf43e3e09f448ea - signature: v1mMck2+ltn5N3tAdFWZ7OKZziUC2bY+2n05U8ioETL9HvhxP3iM6TfgQt9Cl8/O/ZJrkhPJz4Uh0oMJnAZxAQ== + checksum: sha256:47ae7b777a2e04b9686cc0c14e6edeff685dd36fd93029178cf70ac88bec8d7c + signature: U6yBPMJW1en5KpsNKNL4XYY6NRund4SucU4axWOeW4860ds3IXO2q8ZN06Tr3ngqlt4IC671xb1FIfWl9KbcAA== dependencies: [] description: Map backlog items to best-fit modules using scoring heuristics. license: Apache-2.0 diff --git a/scripts/sign-modules.py b/scripts/sign-modules.py index f310abc2..23c6528a 100755 --- a/scripts/sign-modules.py +++ b/scripts/sign-modules.py @@ -15,7 +15,8 @@ import yaml -_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} + +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "logs"} _IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} @@ -29,19 +30,37 @@ def _module_payload(module_dir: Path) -> bytes: if not module_dir.exists() or not module_dir.is_dir(): msg = f"Module directory not found: {module_dir}" raise ValueError(msg) + module_dir_resolved = module_dir.resolve() + def _is_hashable(path: Path) -> bool: - rel = path.relative_to(module_dir) + rel = path.resolve().relative_to(module_dir_resolved) if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): return False return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES entries: list[str] = [] - files = sorted( - (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), - key=lambda p: p.relative_to(module_dir).as_posix(), - ) + + files: list[Path] + try: + listed = subprocess.run( + ["git", "ls-files", module_dir.as_posix()], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + git_files = [(Path.cwd() / line.strip()) for line in listed if line.strip()] + files = sorted( + (path for path in git_files if path.is_file() and _is_hashable(path)), + key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix(), + ) + except Exception: + files = sorted( + (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), + key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix(), + ) + for path in files: - rel = path.relative_to(module_dir).as_posix() + rel = path.resolve().relative_to(module_dir_resolved).as_posix() if rel in {"module-package.yaml", "metadata.yaml"}: raw = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(raw, dict): diff --git a/scripts/verify-modules-signature.py b/scripts/verify-modules-signature.py index b6f685fd..061e8f19 100755 --- a/scripts/verify-modules-signature.py +++ b/scripts/verify-modules-signature.py @@ -13,7 +13,8 @@ import yaml -_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} + +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "logs"} _IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} @@ -24,19 +25,36 @@ def _canonical_manifest_payload(manifest_data: dict[str, Any]) -> bytes: def _module_payload(module_dir: Path) -> bytes: + module_dir_resolved = module_dir.resolve() + def _is_hashable(path: Path) -> bool: - rel = path.relative_to(module_dir) + rel = path.resolve().relative_to(module_dir_resolved) if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): return False return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES entries: list[str] = [] - files = sorted( - (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), - key=lambda p: p.relative_to(module_dir).as_posix(), - ) + files: list[Path] + try: + listed = subprocess.run( + ["git", "ls-files", module_dir.as_posix()], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + git_files = [(Path.cwd() / line.strip()) for line in listed if line.strip()] + files = sorted( + (path for path in git_files if path.is_file() and _is_hashable(path)), + key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix(), + ) + except Exception: + files = sorted( + (path for path in module_dir.rglob("*") if path.is_file() and _is_hashable(path)), + key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix(), + ) + for path in files: - rel = path.relative_to(module_dir).as_posix() + rel = path.resolve().relative_to(module_dir_resolved).as_posix() if rel in {"module-package.yaml", "metadata.yaml"}: raw = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(raw, dict): diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index 6b1ff78c..06c688c3 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -25,7 +25,7 @@ USER_MODULES_ROOT = Path.home() / ".specfact" / "modules" MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" -_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +_IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "logs"} _IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py index d9e96d94..e725f3d3 100644 --- a/tests/unit/specfact_cli/registry/test_signing_artifacts.py +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -191,6 +191,7 @@ def test_sign_modules_py_ignores_transient_cache_files(tmp_path: Path): source.write_text("print('stable')\n", encoding="utf-8") import subprocess + import yaml first = subprocess.run( @@ -222,6 +223,23 @@ def test_sign_modules_py_ignores_transient_cache_files(tmp_path: Path): assert isinstance(second_checksum, str) and second_checksum.startswith("sha256:") assert second_checksum == first_checksum + logs_dir = module_dir / "logs" / "tests" / "junit" + logs_dir.mkdir(parents=True) + (logs_dir / "test-results.xml").write_text("", encoding="utf-8") + + third = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--allow-unsigned", str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert third.returncode == 0 + third_data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + third_checksum = third_data.get("integrity", {}).get("checksum") + assert isinstance(third_checksum, str) and third_checksum.startswith("sha256:") + assert third_checksum == second_checksum + def test_sign_modules_workflow_exists(): """CI workflow .github/workflows/sign-modules.yml SHALL exist."""