Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions presets/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ preset:

requires:
speckit_version: ">=0.1.0" # Required spec-kit version
extensions: # Optional: extensions this preset needs
- "companion-extension"

provides:
templates:
Expand All @@ -93,6 +95,34 @@ tags: # 2-5 relevant tags
- ✅ Command names use dot notation (e.g. `speckit.specify`)
- ✅ Tags are lowercase and descriptive

#### Declaring extension dependencies

If your preset overrides commands that call into an extension, declare it in
`requires.extensions`. Without the extension the preset still installs and the
overrides fall through to the core workflow, so nothing errors — the feature
just silently does less than the user expects. Declaring the dependency makes
`specify preset add` say so, and name the command that fixes it.

Use a bare id, or a mapping when you need a version floor or an optional
dependency:

```yaml
requires:
speckit_version: ">=0.9.0"
extensions:
- "companion-extension" # required, any version
- id: "other-extension"
version: ">=1.2.0" # optional PEP 440 specifier
required: false # optional, defaults to true
```

Notes:

- The field is optional. A preset that declares nothing behaves exactly as before.
- A missing or version-unsatisfied dependency produces a **warning, not a failure** — the install still succeeds.
- `required: false` documents an enhancing-but-optional extension and is never warned about.
- Declare it in `preset.yml`, not only in your catalog entry. The catalog is not consulted for `--dev` and `--from <url>` installs, so the manifest is the only copy present on every install path.

### 3. Test Locally

```bash
Expand Down
189 changes: 189 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,14 @@ def _validate(self):
f"got {type(requires['speckit_version']).__name__}"
)

# Validate the optional extension dependency list. A preset that
# overrides commands calling into an extension is inert without it, and
# until now the only place that could be said was the README -- see
# issue #4231. Absent means "no dependencies", so every existing preset
# stays valid.
if "extensions" in requires:
self._validate_requires_extensions(requires["extensions"])

# Validate provides section
provides = self.data["provides"]
if "templates" not in provides:
Expand Down Expand Up @@ -524,11 +532,116 @@ def author(self) -> str:
"""Get preset author."""
return self.data["preset"].get("author", "")

@staticmethod
def _validate_requires_extensions(declared: Any) -> None:
"""Validate the optional ``requires.extensions`` list.

Accepts either a bare extension id or a mapping carrying an optional
version specifier and an optional ``required`` flag:

.. code-block:: yaml

requires:
extensions:
- speckit-inventory
- id: other-ext
version: ">=1.2.0"
required: false

Raises:
PresetValidationError: If the list or any entry is malformed.
"""
if not isinstance(declared, list):
raise PresetValidationError(
"Invalid requires.extensions: expected a list, "
f"got {type(declared).__name__}"
)

for index, entry in enumerate(declared):
label = f"requires.extensions[{index}]"

if isinstance(entry, str):
entry = {"id": entry}
elif not isinstance(entry, dict):
raise PresetValidationError(
f"Invalid {label}: expected a string or a mapping, "
f"got {type(entry).__name__}"
)

if "id" not in entry:
raise PresetValidationError(f"Missing {label}.id")
extension_id = entry["id"]
if not isinstance(extension_id, str):
raise PresetValidationError(
f"Invalid {label}.id: expected a string, "
f"got {type(extension_id).__name__}"
)
# Same id shape the extension loader enforces, so a dependency can
# never name something that could not be installed in the first place.
if not re.match(r'^[a-z0-9-]+$', extension_id):
raise PresetValidationError(
f"Invalid {label}.id '{extension_id}': "
"must be lowercase alphanumeric with hyphens only"
)

if "version" in entry:
constraint = entry["version"]
# Mirrors the requires.speckit_version reasoning: a non-string
# escapes InvalidSpecifier two ways -- scalars raise TypeError
# from the constructor, and a list/dict is iterable so it
# constructs and only fails later inside .contains().
if not isinstance(constraint, str) or not constraint.strip():
raise PresetValidationError(
f"Invalid {label}.version: expected a non-empty string, "
f"got {type(constraint).__name__}"
)
try:
SpecifierSet(constraint)
except InvalidSpecifier:
raise PresetValidationError(
f"Invalid {label}.version '{constraint}': "
"not a valid version specifier"
)

if "required" in entry and not isinstance(entry["required"], bool):
raise PresetValidationError(
f"Invalid {label}.required: expected a boolean, "
f"got {type(entry['required']).__name__}"
)

@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
return self.data["requires"]["speckit_version"]

@property
def requires_extensions(self) -> List[Dict[str, Any]]:
"""Get declared extension dependencies, normalized to mappings.

Returns:
One entry per dependency with ``id``, ``version`` (``None`` when
unconstrained), and ``required`` (defaulting to ``True``). Empty
when the manifest declares no dependencies.
"""
declared = self.data["requires"].get("extensions")
if not isinstance(declared, list):
return []

normalized: List[Dict[str, Any]] = []
for entry in declared:
if isinstance(entry, str):
entry = {"id": entry}
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
continue
normalized.append(
{
"id": entry["id"],
"version": entry.get("version"),
"required": entry.get("required", True),
}
)
return normalized

@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of provided templates."""
Expand Down Expand Up @@ -838,6 +951,82 @@ def check_compatibility(

return True

def find_unmet_extension_dependencies(
self,
manifest: PresetManifest
) -> List[Dict[str, Any]]:
"""Find declared extension dependencies that are not satisfied.

Reports rather than raises. A preset whose overrides call into an
extension is written to degrade safely -- without the extension the
core workflow still runs -- so a missing dependency is a warning, not
an install failure. See issue #4231.

Args:
manifest: Preset manifest to inspect

Returns:
One entry per unsatisfied dependency, each with ``id``, the
requested ``version`` specifier (``None`` when unconstrained), the
``installed`` version (``None`` when absent), and a ``reason`` of
``"missing"``, ``"disabled"``, or ``"version"``. Optional
dependencies (``required: false``) are never reported.
"""
# Defense in depth, mirroring check_compatibility(): this method is
# public and also reachable with a hand-built manifest object that
# predates this field. A manifest without it declares nothing.
candidates = getattr(manifest, "requires_extensions", None)
if not isinstance(candidates, list):
return []

declared = [
dep for dep in candidates
if isinstance(dep, dict) and dep.get("required", True)
]
if not declared:
return []

registry = ExtensionRegistry(self.project_root / ".specify" / "extensions")
unmet: List[Dict[str, Any]] = []

for dep in declared:
metadata = registry.get(dep["id"])
if metadata is None:
unmet.append({**dep, "installed": None, "reason": "missing"})
Comment on lines +992 to +995
continue

installed_version = metadata.get("version")
installed_version = (
installed_version if isinstance(installed_version, str) else None
)

# A disabled extension is registered but contributes nothing:
# resolution skips it (see _collect_extension_layers), so the
# preset is just as inert as if it were absent. Report it before
# any version check -- enabling it is the prerequisite, and the
# version may well be fine once it is.
if not metadata.get("enabled", True):
unmet.append(
{**dep, "installed": installed_version, "reason": "disabled"}
)
continue

constraint = dep["version"]
if not constraint:
continue

if installed_version is None:
# A registry entry without a usable version cannot be compared.
# Treat it as satisfied rather than inventing a failure, since
# the extension is demonstrably installed and enabled.
continue
if not version_satisfies(installed_version, constraint):
unmet.append(
{**dep, "installed": installed_version, "reason": "version"}
)

return unmet

def _register_commands(
self,
manifest: PresetManifest,
Expand Down
53 changes: 53 additions & 0 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,53 @@
preset_app.add_typer(preset_catalog_app, name="catalog")


def _warn_unmet_extension_dependencies(manager, manifest) -> None:
"""Warn when a preset's declared extension dependencies are unsatisfied.

A preset whose command overrides call into an extension is inert without
it, but the overrides still fall through to the core workflow, so nothing
breaks -- it just silently does less than the user expects. Naming the
missing extension and the command that installs it turns that silence into
something actionable. See issue #4231.
"""
unmet = manager.find_unmet_extension_dependencies(manifest)
if not unmet:
return

console.print()
console.print("[yellow]![/yellow] This preset depends on extensions that are not satisfied:")
for dep in unmet:
extension_id = _escape_markup(dep["id"])
reason = dep["reason"]
# The remediation has to match the reason. `extension add` refuses an
# already-installed extension without --force, and `extension update`
# only moves forward to the catalog release. A general PEP 440
# constraint may require an exact version, an upper bound, or a
# downgrade, so do not promise that update will satisfy it.
if reason == "missing":
console.print(f" [yellow]{extension_id}[/yellow] is not installed")
remedy = f"specify extension add {extension_id}"
elif reason == "disabled":
console.print(f" [yellow]{extension_id}[/yellow] is installed but disabled")
remedy = f"specify extension enable {extension_id}"
else:
console.print(
f" [yellow]{extension_id}[/yellow] "
f"{_escape_markup(dep['installed'])} does not satisfy "
f"{_escape_markup(dep['version'])}"
)
remedy = (
"install a release of "
f"{extension_id} satisfying {_escape_markup(dep['version'])}"
)
console.print(f" Fix with: {remedy}")
console.print()
console.print(
"[dim]The preset is installed and safe to use; the parts that rely on "
"these extensions will do nothing until this is resolved.[/dim]"
)


# ===== Preset Commands =====


Expand Down Expand Up @@ -283,6 +330,12 @@ def _validate_download_redirect(old_url, new_url):
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)

# Every install path above binds `manifest` and the no-source branch
# exits, so one call here covers --dev, --from, and catalog installs
# alike. Warns rather than fails: the preset is installed and its
# overrides fall through to the core workflow without the extension.
_warn_unmet_extension_dependencies(manager, manifest)

except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
Expand Down
Loading