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
27 changes: 27 additions & 0 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ enforce symlink behavior on those operations. Shared infrastructure, registratio
managed storage, catalogs, scripts and runtime operations retain their existing
behavior until their dependent migration stages.

Preset registry reads and writes use a project-root-scoped deny-mode helper.
For the documented `.specify/presets` registry location, the root is two
parents above that directory, never the preset
storage directory itself. Registry metadata and its parent hierarchy must
not be links, independently of the operational `allow_symlinks` setting. Saves
validate the destination before file-change observers or directory creation
and preserve the existing registry text I/O and metadata behavior. This
migration covers registry persistence only, not the remaining preset lifecycle
filesystem operations. Shared artifact list/info operations translate registry
`FileHelperError` failures into their existing resolution errors, preserving
the CLI JSON envelope and MCP `artifact_resolution_failed` code. Contribution
lookup likewise translates registry failures into an artifact resolution error.
Registry reads and writes translate `SymlinkDeniedError` at the persistence
boundary, preserving the registry path and directing users to replace linked
entries with regular files/directories, not enable operational symlinks.
The preset CLI renders that diagnostic without rewriting it, including for
manager/resolver construction failures; integration registration warnings and
bundle errors receive the same guidance. JSON list/info retain a single error
envelope with no stdout output.

Integration upgrade layout guards reuse the same deny-mode registry text
reader, while retaining their stricter ownership/JSON validation. Only genuine
absence means no installed presets. Symlinked files or parents, including
dangling links, and other read/parse failures become
`_PresetRegistryUnreadableError`, refusing command-root or command/skills
migrations before mutation while preset ownership is unknown.

Only `specify init --allow-symlinks` exposes the setting; init defaults to deny
and persists the literal JSON boolean `allow_symlinks` in
`.specify/init-options.json`. Re-init without the flag records `false`.
Expand Down
8 changes: 8 additions & 0 deletions docs/integrations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ persisted only after the upgrade regenerates the managed files successfully.

If an upgrade would change an integration between command and skills layouts while preset artifacts are registered for it, the upgrade is rejected before changing files. Remove the affected presets, run the layout-changing upgrade, then reinstall them.

Command-root and command/skills migrations also refuse to proceed when preset
ownership cannot be determined. A symlinked `.specify/presets/.registry` or
parent directory, including a dangling link, is an unreadable registry rather
than evidence that no presets are installed. This restriction applies even
with `--force` and `allow_symlinks` enabled. Replace linked entries with regular
files or directories and restore a readable, valid registry before retrying.
A genuinely absent registry or a valid empty registry does not block migration.

For external adapters, forced upgrade or uninstall can recover damaged or
unloadable packages using user-local ownership records. Without that proof,
old-only generated files are preserved with a manual-cleanup warning. Recovery
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ may omit the digest.

> **Note:** All preset commands require a project already initialized with `specify init`.

The preset registry at `.specify/presets/.registry` and its parent directories
must not be symlinks, including dangling links. This metadata restriction
applies even when `allow_symlinks` is enabled for project operations. Replace
linked registry entries with regular files or directories; enabling operational
symlinks does not make registry links usable. Registry access failures exit 1
with a human-readable diagnostic, or a single JSON error on stderr for
`preset list --json` and `preset info --json`.

### Exclude development files with `.presetignore`

Place a UTF-8 `.presetignore` (with or without a byte-order mark) beside
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/artifacts/_operation_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Literal

from .._file_policy import ProjectPolicyError, project_files
from ..file_helper import FileHelperError
from ..presets import PresetError
from . import (
AmbiguousArtifactError,
Expand Down Expand Up @@ -536,7 +537,7 @@ def get_artifact_info(request: ArtifactInfoRequest) -> ArtifactInfoResult:
raise ArtifactInfoResolutionError(
project_directory, request.identifier, message=exc.message,
) from exc
except (OSError, PresetError) as exc:
except (FileHelperError, OSError, PresetError) as exc:
raise ArtifactInfoResolutionError(
project_directory,
request.identifier,
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/artifacts/_operation_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Literal, TypedDict, cast

from .._file_policy import ProjectPolicyError, project_files
from ..file_helper import FileHelperError
from ..presets import PresetError
from . import (
ArtifactCatalog,
Expand Down Expand Up @@ -245,7 +246,7 @@ def list_artifacts(request: ArtifactListRequest) -> ArtifactListResult:
raise ArtifactListResolutionError(project_directory, message=str(exc)) from exc
except NotASpecKitProjectError as exc:
raise ArtifactListProjectError(project_directory) from exc
except (ArtifactResolutionError, OSError, PresetError) as exc:
except (ArtifactResolutionError, FileHelperError, OSError, PresetError) as exc:
raise ArtifactListResolutionError(project_directory) from exc

all_rows = tuple(cast(ArtifactListItem, row) for row in rows)
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/artifacts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import yaml

from ..file_helper import FileHelperError
from ._identifiers import (
IdentifierComponentError,
derive_hook_lookup_id,
Expand Down Expand Up @@ -430,7 +431,7 @@ def get_contribution_info(self, lookup_id: str) -> dict[str, Any]:
resolved = self._find_extension_contribution(
resolver, source_id, kind, name
)
except (OSError, PresetError) as exc:
except (FileHelperError, OSError, PresetError) as exc:
raise ArtifactResolutionError() from exc
if resolved is None:
raise ContributionNotFoundError(lookup_id)
Expand Down
14 changes: 6 additions & 8 deletions src/specify_cli/integrations/_command_upgrade_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,17 @@ def _installed_presets_affecting_agent(
preset-overridden files while their registry state can't be reconciled —
the exact inconsistency the guard exists to prevent.
"""
from ..file_helper import FileHelper
from ..presets import PresetRegistry
from ..presets._registry import _read_registry_text

registry_path = (
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
Path(project_root).absolute() / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
)
# Genuinely absent registry → no presets installed → safe to proceed.
if not registry_path.exists():
return []

# The registry exists: any failure to read or parse it must surface as an
# error, not be swallowed into an empty ("no presets") result.
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
data = json.loads(_read_registry_text(FileHelper(project_root), registry_path))
except FileNotFoundError:
return []
except (OSError, ValueError) as exc:
raise _PresetRegistryUnreadableError(str(exc)) from exc
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
Expand Down
18 changes: 18 additions & 0 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,34 @@
from __future__ import annotations

import typer
from rich.markup import escape as _escape_markup
from typer.core import TyperGroup

from .._console import console
from .._download_security import read_response_limited as _read_response_limited
from ..file_helper import FileHelperError

read_response_limited = _read_response_limited


class _PresetGroup(TyperGroup):
"""Report filesystem policy failures across the entire preset invocation."""

def invoke(self, ctx):
try:
return super().invoke(ctx)
except FileHelperError as error:
console.print(
f"[red]Error:[/red] {_escape_markup(str(error))}", soft_wrap=True,
)
raise typer.Exit(1) from error


preset_app = typer.Typer(
name="preset",
help="Manage spec-kit presets",
add_completion=False,
cls=_PresetGroup,
)

# Lowest priority a user may request. Lower numbers win resolution, so the
Expand Down
47 changes: 34 additions & 13 deletions src/specify_cli/presets/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@
from typing import Dict, List, Optional

from ..extensions import normalize_priority
from ..file_helper import FileHelper, SymlinkDeniedError


def _registry_symlink_error(registry_path: Path) -> SymlinkDeniedError:
return SymlinkDeniedError(
f"Cannot access preset registry {registry_path}: registry files and parent "
"directories must not be symlinks. Replace linked entries with regular "
"files or directories; allow_symlinks does not apply to preset registry metadata."
)


def _read_registry_text(files: FileHelper, registry_path: Path) -> str:
"""Read registry text under the caller's root without following links."""
try:
return files.read_text(registry_path, encoding="utf-8")
except SymlinkDeniedError as error:
raise _registry_symlink_error(registry_path) from error


class PresetRegistry:
Expand All @@ -23,19 +40,16 @@ def __init__(self, packs_dir: Path):
"""
self.packs_dir = packs_dir
self.registry_path = packs_dir / self.REGISTRY_FILE
# Scope above .specify so neither it nor presets becomes a trusted alias.
self._files = FileHelper(packs_dir.absolute().parent.parent)
self.data = self._load()

def _load(self) -> dict:
"""Load registry from disk."""
if not self.registry_path.exists():
return {
"schema_version": self.SCHEMA_VERSION,
"presets": {}
}

try:
with open(self.registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
data = json.loads(
_read_registry_text(self._files, self.registry_path.absolute())
)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
return {
Expand All @@ -61,11 +75,18 @@ def _save(self):
"""Save registry to disk."""
from ..integrations._file_changes import after_file_change, before_file_change

before_file_change(self.registry_path)
self.packs_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, indent=2)
after_file_change(self.registry_path)
path = self.registry_path.absolute()
try:
try:
self._files.read_bytes(path)
except FileNotFoundError:
pass
before_file_change(self.registry_path)
self._files.mkdir(self.packs_dir.absolute(), parents=True, exist_ok=True)
self._files.overwrite_text(path, json.dumps(self.data, indent=2), encoding="utf-8")
after_file_change(self.registry_path)
except SymlinkDeniedError as error:
raise _registry_symlink_error(path) from error

def add(self, pack_id: str, metadata: dict):
"""Add preset to registry.
Expand Down
6 changes: 4 additions & 2 deletions src/specify_cli/presets/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ def resolve(
return override

# Priority 2: Installed presets (sorted by priority — lower number wins)
if not skip_presets and self.presets_dir.exists():
if not skip_presets and (
self.presets_dir.exists() or self.presets_dir.is_symlink()
):
for pack_id, _metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
Expand Down Expand Up @@ -559,7 +561,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
})

# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
if self.presets_dir.exists():
if self.presets_dir.exists() or self.presets_dir.is_symlink():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/presets/command_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rich.markup import escape as _escape_markup

from .._console import console
from ..file_helper import FileHelperError
from ._commands import preset_app


Expand Down Expand Up @@ -63,6 +64,8 @@ def preset_resolve(
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name, template_type)
except FileHelperError:
raise
except Exception as exc: # noqa: BLE001 - render composition failures
composed = None
console.print(
Expand Down
4 changes: 4 additions & 0 deletions src/specify_cli/presets/command_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from rich.markup import escape as _escape_markup

from .._console import console
from ..file_helper import FileHelperError
from . import _commands
from ._commands import preset_app

Expand Down Expand Up @@ -90,6 +91,9 @@ def report_add_failure() -> None:
dev=dev,
priority=priority,
)
except FileHelperError:
report_add_failure()
raise
except typer.Exit as error:
report_add_failure()
raise typer.Exit(error.exit_code or 1)
Expand Down
31 changes: 31 additions & 0 deletions tests/specify_cli/artifacts/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Iterator
from pathlib import Path

import pytest
Expand All @@ -23,3 +24,33 @@ def non_project(tmp_path: Path) -> Path:
root = tmp_path / "not-proj"
root.mkdir()
return root


@pytest.fixture(
params=["presets", ".registry", "dangling-presets", "dangling-registry"]
)
def symlinked_preset_registry(
spec_kit_project: Path, tmp_path: Path, request: pytest.FixtureRequest,
) -> Iterator[Path]:
outside = tmp_path / "outside-presets"
outside.mkdir()
sentinel = outside / ".registry"
sentinel.write_bytes(b"not registry JSON")
packs_dir = spec_kit_project / ".specify" / "presets"
is_directory = request.param.endswith("presets")
link = packs_dir if is_directory else packs_dir / ".registry"
target = outside if is_directory else sentinel
if request.param.startswith("dangling"):
target = outside / "missing"
if is_directory:
packs_dir.rmdir()
try:
link.symlink_to(target, target_is_directory=is_directory)
except (OSError, NotImplementedError) as error:
pytest.skip(f"Symlinks unavailable: {error}")

yield spec_kit_project

assert link.is_symlink()
assert sentinel.read_bytes() == b"not registry JSON"
assert not (outside / "missing").exists()
14 changes: 14 additions & 0 deletions tests/specify_cli/artifacts/test_command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,20 @@ def test_info_corrupt_extension_registry_uses_json_error_envelope(
assert result.stdout == ""
assert json.loads(result.stderr) == {"error": "artifact resolution failed"}

def test_info_symlinked_registry_uses_json_error_envelope(
self, symlinked_preset_registry: Path, monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.chdir(symlinked_preset_registry)

result = CliRunner().invoke(
app, ["artifact", "info", "speckit.plan", "--json"],
catch_exceptions=False,
)

assert result.exit_code == 1
assert result.stdout == ""
assert result.stderr == '{"error": "artifact resolution failed"}\n'

def test_stdout_empty_on_error(
self, non_project: Path, monkeypatch: pytest.MonkeyPatch
):
Expand Down
13 changes: 13 additions & 0 deletions tests/specify_cli/artifacts/test_command_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,19 @@ def test_list_corrupt_extension_registry_uses_json_error_envelope(
assert result.stderr.count("\n") == 1
assert "\x1b[" not in result.stderr

def test_list_symlinked_registry_uses_json_error_envelope(
self, symlinked_preset_registry: Path, monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.chdir(symlinked_preset_registry)

result = CliRunner().invoke(
app, ["artifact", "list", "--json"], catch_exceptions=False,
)

assert result.exit_code == 1
assert result.stdout == ""
assert result.stderr == '{"error": "artifact resolution failed"}\n'

def test_cli_context_filesystem_failure_uses_json_error_envelope(
self, monkeypatch: pytest.MonkeyPatch
):
Expand Down
Loading
Loading