From fca96eb7c0785e5d53c56522a1b5204f1afeee99 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Thu, 13 Aug 2026 12:00:15 +0300 Subject: [PATCH 1/6] feat(presets): add preset stacks engine (stacks.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces named, reusable "preset stacks" defined in .specify/preset-stacks.yml: PresetStackEntry/PresetStack/PresetStacksConfig dataclasses, load_stacks_config() validation, and apply_stack() which drives installs through the existing PresetManager.install_from_directory/ install_from_zip/remove primitives only — no new install/uninstall logic. apply_stack() diffs against .stack-state.json on reapply so entries dropped from a stack are uninstalled, unless another applied stack still lists them. download_pack() gains bypass_install_allowed (default False) so a stack entry resolved through the catalog can skip the install_allowed gate: listing a preset in one's own stack is itself the trust decision (FR-2025). --- src/specify_cli/presets/__init__.py | 10 +- src/specify_cli/presets/stacks.py | 386 +++++++++++++++ tests/test_preset_stacks.py | 696 ++++++++++++++++++++++++++++ 3 files changed, 1090 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/presets/stacks.py create mode 100644 tests/test_preset_stacks.py diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 6a359f5b29..8e15a7dc76 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4804,13 +4804,19 @@ def get_pack_info( return None def download_pack( - self, pack_id: str, target_dir: Optional[Path] = None + self, + pack_id: str, + target_dir: Optional[Path] = None, + bypass_install_allowed: bool = False, ) -> Path: """Download a preset archive from a catalog. Args: pack_id: ID of the preset to download target_dir: Directory to save the archive + bypass_install_allowed: Skip the `install_allowed` gate. Used only by + stack-driven installs, where listing a preset in one's own stack is + itself the trust decision (FR-2025). Returns: Path to the downloaded archive @@ -4836,7 +4842,7 @@ def download_pack( f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" ) - if not pack_info.get("_install_allowed", True): + if not bypass_install_allowed and not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") raise PresetError( f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " diff --git a/src/specify_cli/presets/stacks.py b/src/specify_cli/presets/stacks.py new file mode 100644 index 0000000000..1742dd7319 --- /dev/null +++ b/src/specify_cli/presets/stacks.py @@ -0,0 +1,386 @@ +""" +Reusable preset stacks: named, ordered lists of preset installs. + +Loads and validates `.specify/preset-stacks.yml`, and applies a named stack by +calling existing `PresetManager`/`PresetCatalog` install/remove primitives — +no new install/uninstall logic lives here. +""" + +import json +import os +import shutil +import tempfile +import urllib.error +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +STACK_STATE_FILENAME = ".stack-state.json" +STACKS_CONFIG_FILENAME = "preset-stacks.yml" +RESERVED_STACK_NAMES = ("none",) + + +@dataclass +class PresetStackEntry: + """A single member of a stack.""" + + preset: str + priority: int = 10 + source: Optional[str] = None + + +@dataclass +class PresetStack: + """A named, ordered collection of entries.""" + + name: str + entries: list[PresetStackEntry] = field(default_factory=list) + + +@dataclass +class PresetStacksConfig: + """The parsed contents of `.specify/preset-stacks.yml`.""" + + stacks: list[PresetStack] = field(default_factory=list) + + +def load_stacks_config(project_root: Path) -> PresetStacksConfig: + """Load and validate `.specify/preset-stacks.yml` (missing file -> empty config). + + Mirrors `PresetCatalog._load_catalog_config`'s (`presets/__init__.py:4262`) + error-identifies-the-file validation style (FR-3000/1020/1025/1030). + """ + from . import PresetValidationError + + config_path = project_root / ".specify" / STACKS_CONFIG_FILENAME + if not config_path.exists(): + return PresetStacksConfig(stacks=[]) + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as e: + raise PresetValidationError(f"Failed to read {config_path}: {e}") + + if not isinstance(data, dict): + raise PresetValidationError( + f"Invalid {config_path}: expected a mapping at root, got {type(data).__name__}" + ) + + stacks_data = data.get("stacks", []) + if not isinstance(stacks_data, list): + raise PresetValidationError( + f"Invalid {config_path}: 'stacks' must be a list, got {type(stacks_data).__name__}" + ) + + stacks: list[PresetStack] = [] + seen_names: set[str] = set() + for idx, stack_item in enumerate(stacks_data): + if not isinstance(stack_item, dict): + raise PresetValidationError( + f"Invalid {config_path}: stack at index {idx} must be a mapping, " + f"got {type(stack_item).__name__}" + ) + name = str(stack_item.get("name", "")).strip() + if not name: + raise PresetValidationError( + f"Invalid {config_path}: stack at index {idx} is missing a 'name'" + ) + if name in RESERVED_STACK_NAMES: + raise PresetValidationError( + f"Invalid {config_path}: stack name '{name}' is reserved and cannot be used " + f"as a stack definition (FR-1025)" + ) + if name in seen_names: + raise PresetValidationError( + f"Invalid {config_path}: stack name '{name}' is defined more than once" + ) + seen_names.add(name) + + entries_data = stack_item.get("entries", []) + if not isinstance(entries_data, list): + raise PresetValidationError( + f"Invalid {config_path}: stack '{name}' has an 'entries' value that must be a " + f"list, got {type(entries_data).__name__}" + ) + + entries: list[PresetStackEntry] = [] + seen_presets: set[str] = set() + for entry_idx, entry_item in enumerate(entries_data): + if not isinstance(entry_item, dict): + raise PresetValidationError( + f"Invalid {config_path}: entry {entry_idx} of stack '{name}' must be a " + f"mapping, got {type(entry_item).__name__}" + ) + preset = str(entry_item.get("preset", "")).strip() + if not preset: + raise PresetValidationError( + f"Invalid {config_path}: entry {entry_idx} of stack '{name}' is missing a " + f"'preset' ID" + ) + if preset in seen_presets: + raise PresetValidationError( + f"Invalid {config_path}: preset '{preset}' appears more than once in stack " + f"'{name}'" + ) + seen_presets.add(preset) + + raw_priority = entry_item.get("priority", 10) + if isinstance(raw_priority, bool): + raise PresetValidationError( + f"Invalid {config_path}: preset '{preset}' in stack '{name}' has an invalid " + f"priority, expected integer, got {raw_priority!r}" + ) + try: + priority = int(raw_priority) + except (TypeError, ValueError, OverflowError): + raise PresetValidationError( + f"Invalid {config_path}: preset '{preset}' in stack '{name}' has an invalid " + f"priority, expected integer, got {raw_priority!r}" + ) + + source = entry_item.get("source") + source = str(source).strip() if source else None + + entries.append(PresetStackEntry(preset=preset, priority=priority, source=source)) + + stacks.append(PresetStack(name=name, entries=entries)) + + return PresetStacksConfig(stacks=stacks) + + +def _stack_state_path(project_root: Path) -> Path: + return project_root / ".specify" / "presets" / STACK_STATE_FILENAME + + +def _load_stack_state(project_root: Path) -> dict[str, list[str]]: + """Load `{stack_name: [pack_id, ...]}` from `.stack-state.json` (absent file -> `{}`).""" + state_path = _stack_state_path(project_root) + if not state_path.exists(): + return {} + try: + with open(state_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError): + return {} + if not isinstance(data, dict): + return {} + return data + + +def _save_stack_state(project_root: Path, state: dict[str, list[str]]) -> None: + """Save `{stack_name: [pack_id, ...]}` to `.stack-state.json`.""" + state_path = _stack_state_path(project_root) + state_path.parent.mkdir(parents=True, exist_ok=True) + with open(state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + + +def _download_archive(url: str) -> Path: + """Download an archive from a stack entry's explicit `source:` URL. + + Mirrors `preset add`'s `--from` branch (`presets/_commands.py:79-306`) using + the same security primitives, without touching that existing, heavily + tested code path. + """ + from urllib.parse import urlparse + + from .._download_security import ( + archive_format_from_name, + archive_suffix, + detect_archive_format, + is_https_or_localhost_http, + read_response_limited, + ) + from .._github_http import resolve_github_release_asset_api_url + from ..authentication.http import github_provider_hosts, open_url + from . import PresetError + + try: + parsed = urlparse(url) + parsed.port + except ValueError: + raise PresetError(f"Invalid URL: {url}") + + if not is_https_or_localhost_http(url): + raise PresetError( + f"URL must use HTTPS with a hostname and be a valid URL with a host. " + f"HTTP is only allowed for localhost, 127.0.0.1, and ::1: {url}" + ) + + tmpdir = Path(tempfile.mkdtemp(prefix="specify-stack-")) + archive_path = tmpdir / "preset.archive" + try: + extra_headers = None + resolved_url = resolve_github_release_asset_api_url( + url, open_url, github_hosts=github_provider_hosts() + ) + if resolved_url: + url = resolved_url + extra_headers = {"Accept": "application/octet-stream"} + + with open_url(url, timeout=60, extra_headers=extra_headers) as response: + final_url = response.geturl() if hasattr(response, "geturl") else url + if not is_https_or_localhost_http(final_url): + raise PresetError(f"URL redirected to a disallowed URL: {final_url}") + archive_data = read_response_limited( + response, error_type=PresetError, label=f"preset {url}" + ) + content_type = ( + response.getheader("Content-Type") if hasattr(response, "getheader") else None + ) + + archive_path.write_bytes(archive_data) + archive_format = detect_archive_format( + archive_path, source_name=url, content_type=content_type, error_type=PresetError + ) + detected_path = archive_path.with_suffix(archive_suffix(archive_format)) + os.replace(archive_path, detected_path) + return detected_path + except PresetError: + shutil.rmtree(tmpdir, ignore_errors=True) + raise + except (urllib.error.URLError, OSError) as e: + shutil.rmtree(tmpdir, ignore_errors=True) + raise PresetError(f"Failed to download {url}: {e}") from e + except BaseException: + shutil.rmtree(tmpdir, ignore_errors=True) + raise + + +def _resolve_entry_source( + project_root: Path, entry: PresetStackEntry +) -> tuple[Path, bool, Optional[Path]]: + """Resolve a stack entry to an installable source. + + Resolution order mirrors `preset add`'s `--dev`/`--from`/plain-`preset_id` + branches (`presets/_commands.py:79-306`): an entry's own `source:` (local + directory or archive URL) takes precedence over catalog resolution, and + any catalog-resolved entry bypasses `install_allowed` (FR-2025) since + listing a preset in one's own stack is itself the trust decision. + + Returns: + `(path, is_directory, cleanup)` — `is_directory` selects + `install_from_directory` vs `install_from_zip` in `apply_stack`; + `cleanup`, if not None, is a file or directory to remove after install. + """ + from .. import _locate_bundled_preset + from . import PresetCatalog, PresetError + + if entry.source: + if entry.source.startswith(("http://", "https://")): + archive_path = _download_archive(entry.source) + return archive_path, False, archive_path.parent + dev_path = Path(entry.source).resolve() + if not dev_path.exists(): + raise PresetError(f"Source directory not found: {entry.source}") + return dev_path, True, None + + bundled_path = _locate_bundled_preset(entry.preset) + if bundled_path: + return bundled_path, True, None + + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(entry.preset) + if not pack_info: + raise PresetError( + f"Preset '{entry.preset}' not found: no bundled preset, no catalog " + f"entry, and no explicit 'source' in the stack" + ) + + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + + raise PresetError( + f"Preset '{entry.preset}' is bundled with spec-kit but could not be " + f"found in the installed package. Try reinstalling spec-kit: " + f"{REINSTALL_COMMAND}" + ) + + archive_path = catalog.download_pack(entry.preset, bypass_install_allowed=True) + return archive_path, False, archive_path + + +@dataclass +class StackEntryResult: + """The outcome of resolving and installing one stack entry.""" + + preset: str + success: bool + error: Optional[str] = None + + +@dataclass +class StackApplyResult: + """The outcome of applying a whole stack.""" + + stack_name: str + entries: list[StackEntryResult] = field(default_factory=list) + removed: list[str] = field(default_factory=list) + + @property + def success(self) -> bool: + return all(e.success for e in self.entries) + + +def apply_stack(project_root: Path, stack: PresetStack, speckit_version: str) -> StackApplyResult: + """Apply a named stack: install every current entry, then sync out dropped ones. + + Calls only the existing `PresetManager.install_from_directory`/ + `install_from_zip` (with `force=True`, which already removes-then-reinstalls + a present pack, per `presets/__init__.py:3567-3573`) and `PresetManager.remove` + — no new install/uninstall logic lives here. + """ + from . import PresetError, PresetManager + + manager = PresetManager(project_root) + entries: list[StackEntryResult] = [] + current_ids: list[str] = [] + + for entry in stack.entries: + cleanup: Optional[Path] = None + try: + source_path, is_directory, cleanup = _resolve_entry_source(project_root, entry) + if is_directory: + manager.install_from_directory( + source_path, speckit_version, priority=entry.priority, force=True + ) + else: + manager.install_from_zip( + source_path, speckit_version, priority=entry.priority, force=True + ) + entries.append(StackEntryResult(preset=entry.preset, success=True)) + current_ids.append(entry.preset) + except PresetError as e: + entries.append( + StackEntryResult( + preset=entry.preset, + success=False, + error=f"stack '{stack.name}', preset '{entry.preset}': {e}", + ) + ) + finally: + if cleanup is not None: + if cleanup.is_dir(): + shutil.rmtree(cleanup, ignore_errors=True) + else: + cleanup.unlink(missing_ok=True) + + state = _load_stack_state(project_root) + previous_ids = set(state.get(stack.name, [])) + other_stacks_ids: set[str] = set() + for other_name, other_ids in state.items(): + if other_name != stack.name: + other_stacks_ids.update(other_ids) + + removed: list[str] = [] + for pid in previous_ids - set(current_ids): + if pid not in other_stacks_ids: + manager.remove(pid) + removed.append(pid) + + state[stack.name] = current_ids + _save_stack_state(project_root, state) + + return StackApplyResult(stack_name=stack.name, entries=entries, removed=removed) diff --git a/tests/test_preset_stacks.py b/tests/test_preset_stacks.py new file mode 100644 index 0000000000..028e127921 --- /dev/null +++ b/tests/test_preset_stacks.py @@ -0,0 +1,696 @@ +""" +Unit tests for reusable preset stacks (`.specify/preset-stacks.yml`). + +Tests cover: +- Config file loading/validation +- Applying a stack (install/priority/source/failure semantics) +- Sync-on-reapply (uninstall of dropped entries, multi-stack overlap) +- `specify preset stack` CLI verbs (list/install/add/remove) +- `specify init --preset-stack` / implicit-default resolution +""" + +import os +import tempfile +import shutil +import zipfile +from pathlib import Path + +import pytest + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.presets import PresetCatalog, PresetManager, PresetValidationError +from specify_cli.presets.stacks import ( + PresetStack, + PresetStackEntry, + PresetStacksConfig, + apply_stack, + load_stacks_config, +) + +# ===== Fixtures ===== + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory with .specify/ initialized.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + templates_dir = specify_dir / "templates" + templates_dir.mkdir() + + core_spec = templates_dir / "spec-template.md" + core_spec.write_text("# Core Spec Template\n") + + core_plan = templates_dir / "plan-template.md" + core_plan.write_text("# Core Plan Template\n") + + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + + return proj_dir + + +def _write_stacks_config(project_dir: Path, data: dict) -> Path: + specify_dir = project_dir / ".specify" + specify_dir.mkdir(exist_ok=True) + config_path = specify_dir / "preset-stacks.yml" + config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return config_path + + +def _make_preset_dir(base_dir: Path, pack_id: str, version: str = "1.0.0") -> Path: + """Build a minimal, valid, installable preset directory (mirrors test_presets.py's pack_dir).""" + p_dir = base_dir / pack_id + p_dir.mkdir() + manifest = { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id.title(), + "version": version, + "description": f"Test preset {pack_id}", + "author": "Test Author", + "repository": f"https://github.com/test/{pack_id}", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "description": "Custom spec template", + "replaces": "spec-template", + } + ] + }, + "tags": ["testing"], + } + (p_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + templates_dir = p_dir / "templates" + templates_dir.mkdir() + (templates_dir / "spec-template.md").write_text(f"# {pack_id} template\n") + return p_dir + + +def _zip_preset_dir(pack_dir: Path, zip_path: Path) -> Path: + with zipfile.ZipFile(zip_path, "w") as zf: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(pack_dir)) + return zip_path + + +# ===== load_stacks_config ===== + + +class TestLoadStacksConfig: + def test_absent_config_returns_empty_config(self, project_dir): + config = load_stacks_config(project_dir) + assert config == PresetStacksConfig(stacks=[]) + + def test_valid_multi_stack_file_round_trips(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [ + { + "name": "default", + "entries": [ + {"preset": "alpha", "priority": 5}, + {"preset": "beta"}, + ], + }, + { + "name": "extra", + "entries": [ + {"preset": "gamma", "priority": 20, "source": "/local/gamma"}, + ], + }, + ] + }) + + config = load_stacks_config(project_dir) + + assert config == PresetStacksConfig(stacks=[ + PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5), + PresetStackEntry(preset="beta", priority=10), + ]), + PresetStack(name="extra", entries=[ + PresetStackEntry(preset="gamma", priority=20, source="/local/gamma"), + ]), + ]) + + def test_malformed_yaml_rejected_naming_file(self, project_dir): + config_path = project_dir / ".specify" / "preset-stacks.yml" + config_path.write_text("stacks: [this is: not valid yaml", encoding="utf-8") + + with pytest.raises(PresetValidationError, match=r"preset-stacks\.yml"): + load_stacks_config(project_dir) + + def test_entry_missing_preset_rejected_naming_stack(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"priority": 5}]}, + ] + }) + + with pytest.raises(PresetValidationError, match="default"): + load_stacks_config(project_dir) + + def test_stack_named_none_rejected(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [{"name": "none", "entries": [{"preset": "alpha"}]}] + }) + + with pytest.raises(PresetValidationError, match="none"): + load_stacks_config(project_dir) + + def test_duplicate_preset_in_same_stack_rejected(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [{ + "name": "default", + "entries": [ + {"preset": "alpha", "priority": 5}, + {"preset": "alpha", "priority": 10}, + ], + }] + }) + + with pytest.raises(PresetValidationError, match="alpha"): + load_stacks_config(project_dir) + + def test_omitted_priority_defaults_to_10(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [{"name": "default", "entries": [{"preset": "alpha"}]}] + }) + + config = load_stacks_config(project_dir) + + assert config.stacks[0].entries[0].priority == 10 + + +# ===== apply_stack ===== + + +class TestApplyStack: + def test_applies_entries_at_listed_priority(self, project_dir, temp_dir): + """AC1/FR-2030/FR-1010: matches individual `preset add --priority` calls.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + PresetStackEntry(preset="beta", priority=20, source=str(beta_dir)), + ]) + + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + manager = PresetManager(project_dir) + assert manager.registry.is_installed("alpha") + assert manager.registry.is_installed("beta") + assert manager.registry.get("alpha")["priority"] == 5 + assert manager.registry.get("beta")["priority"] == 20 + + def test_reapply_updates_priority_without_duplicating(self, project_dir, temp_dir): + """AC2: re-applying with a changed priority updates in place.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + ]) + apply_stack(project_dir, stack, "0.1.5") + + stack.entries[0].priority = 15 + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + manager = PresetManager(project_dir) + assert manager.registry.is_installed("alpha") + assert manager.registry.get("alpha")["priority"] == 15 + assert manager.list_installed().count( + next(p for p in manager.list_installed() if p["id"] == "alpha") + ) == 1 + + def test_discovery_only_catalog_preset_installs_anyway(self, project_dir, temp_dir, monkeypatch): + """AC3/FR-2025: a discovery-only catalog preset is installed via a stack.""" + gamma_dir = _make_preset_dir(temp_dir, "gamma") + zip_path = _zip_preset_dir(gamma_dir, temp_dir / "gamma.zip") + + monkeypatch.setattr( + PresetCatalog, "get_pack_info", + lambda self, pack_id: {"id": pack_id, "_install_allowed": False, "download_url": "https://example.invalid/gamma.zip"}, + ) + + def fake_download_pack(self, pack_id, target_dir=None, bypass_install_allowed=False): + assert bypass_install_allowed is True + return zip_path + + monkeypatch.setattr(PresetCatalog, "download_pack", fake_download_pack) + monkeypatch.setattr("specify_cli._locate_bundled_preset", lambda pack_id: None) + + stack = PresetStack(name="default", entries=[PresetStackEntry(preset="gamma", priority=10)]) + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert PresetManager(project_dir).registry.is_installed("gamma") + + def test_explicit_local_source_makes_no_network_call(self, project_dir, temp_dir, monkeypatch): + """FR-2032: an explicit local `source:` installs without any catalog/network lookup.""" + delta_dir = _make_preset_dir(temp_dir, "delta") + + def fail_if_called(*args, **kwargs): + raise AssertionError("catalog should not be consulted for a local source entry") + + monkeypatch.setattr(PresetCatalog, "get_pack_info", fail_if_called) + monkeypatch.setattr(PresetCatalog, "download_pack", fail_if_called) + + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="delta", priority=10, source=str(delta_dir)), + ]) + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert PresetManager(project_dir).registry.is_installed("delta") + + def test_explicit_source_url_installs_via_download(self, project_dir, temp_dir, monkeypatch): + """FR-2032: an explicit archive-URL `source:` resolves via the download path.""" + scratch_dir = temp_dir / "download-scratch" + scratch_dir.mkdir() + epsilon_dir = _make_preset_dir(scratch_dir, "epsilon") + zip_path = _zip_preset_dir(epsilon_dir, scratch_dir / "epsilon.zip") + + monkeypatch.setattr( + "specify_cli.presets.stacks._download_archive", + lambda url: zip_path, + ) + + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="epsilon", priority=10, source="https://example.invalid/epsilon.zip"), + ]) + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert PresetManager(project_dir).registry.is_installed("epsilon") + + def test_unresolvable_entry_fails_others_still_install(self, project_dir, temp_dir, monkeypatch): + """AC4/FR-2040: an entry with no catalog match and no source fails by name; rest still installs.""" + zeta_dir = _make_preset_dir(temp_dir, "zeta") + monkeypatch.setattr(PresetCatalog, "get_pack_info", lambda self, pack_id: None) + monkeypatch.setattr("specify_cli._locate_bundled_preset", lambda pack_id: None) + + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="missing-preset", priority=10), + PresetStackEntry(preset="zeta", priority=10, source=str(zeta_dir)), + ]) + result = apply_stack(project_dir, stack, "0.1.5") + + assert not result.success + by_preset = {e.preset: e for e in result.entries} + assert by_preset["missing-preset"].success is False + assert "default" in by_preset["missing-preset"].error + assert "missing-preset" in by_preset["missing-preset"].error + assert by_preset["zeta"].success is True + assert PresetManager(project_dir).registry.is_installed("zeta") + + def test_dropped_entry_is_uninstalled_on_reapply(self, project_dir, temp_dir): + """AC5/FR-2035: dropping an entry and re-applying uninstalls it.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + PresetStackEntry(preset="beta", priority=10, source=str(beta_dir)), + ]) + apply_stack(project_dir, stack, "0.1.5") + + stack.entries = [PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir))] + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert result.removed == ["beta"] + manager = PresetManager(project_dir) + assert manager.registry.is_installed("alpha") + assert not manager.registry.is_installed("beta") + + def test_dropped_entry_stays_installed_if_another_stack_still_lists_it(self, project_dir, temp_dir): + """AC5/FR-2035: a dropped entry survives if another applied stack's state still lists it.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + + stack_a = PresetStack(name="stack-a", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + PresetStackEntry(preset="beta", priority=10, source=str(beta_dir)), + ]) + apply_stack(project_dir, stack_a, "0.1.5") + + stack_b = PresetStack(name="stack-b", entries=[ + PresetStackEntry(preset="beta", priority=10, source=str(beta_dir)), + ]) + apply_stack(project_dir, stack_b, "0.1.5") + + stack_a.entries = [PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir))] + result = apply_stack(project_dir, stack_a, "0.1.5") + + assert result.success + assert result.removed == [] + assert PresetManager(project_dir).registry.is_installed("beta") + + def test_independently_installed_preset_is_never_touched(self, project_dir, temp_dir): + """AC5/FR-2035: a preset installed outside of any stack is left alone.""" + beta_dir = _make_preset_dir(temp_dir, "beta") + PresetManager(project_dir).install_from_directory(beta_dir, "0.1.5", priority=10) + + alpha_dir = _make_preset_dir(temp_dir, "alpha") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + ]) + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert result.removed == [] + assert PresetManager(project_dir).registry.is_installed("beta") + + +# ===== `specify init` integration (US2) ===== + + +_INIT_ARGS = [ + "--integration", + "generic", + "--integration-options", + "--commands-dir .agent/commands", + "--ignore-agent-tools", + "--offline", +] + + +def _init_here(project_dir: Path, extra_args: list[str]): + """Run `specify init --here --force` inside `project_dir` (merges into the + `.specify/preset-stacks.yml` a test may have pre-created there).""" + previous = os.getcwd() + os.chdir(project_dir) + try: + return CliRunner().invoke( + app, + ["init", "--here", "--force", *_INIT_ARGS, *extra_args], + catch_exceptions=True, + ) + finally: + os.chdir(previous) + + +class TestInitPresetStack: + def test_default_stack_installs_automatically(self, temp_dir): + """AC1: a defined `default` stack installs automatically when neither + `--preset` nor `--preset-stack` is given.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + gamma_dir = _make_preset_dir(temp_dir, "gamma") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "gamma", "priority": 5, "source": str(gamma_dir)}]}, + ] + }) + + result = _init_here(project_dir, []) + + assert result.exit_code == 0, result.stdout + assert PresetManager(project_dir).registry.is_installed("gamma") + + def test_no_default_stack_defined_is_noop(self, temp_dir): + """AC2: no `default` stack defined -> no stack applied, identical to today.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + delta_dir = _make_preset_dir(temp_dir, "delta") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "other", "entries": [{"preset": "delta", "priority": 5, "source": str(delta_dir)}]}, + ] + }) + + result = _init_here(project_dir, []) + + assert result.exit_code == 0, result.stdout + assert not PresetManager(project_dir).registry.is_installed("delta") + + def test_preset_flag_skips_default_stack(self, temp_dir): + """AC3: `--preset ` given -> `default` is skipped entirely.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + gamma_dir = _make_preset_dir(temp_dir, "gamma") + epsilon_dir = _make_preset_dir(temp_dir, "epsilon") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "gamma", "priority": 5, "source": str(gamma_dir)}]}, + ] + }) + + result = _init_here(project_dir, ["--preset", str(epsilon_dir)]) + + assert result.exit_code == 0, result.stdout + manager = PresetManager(project_dir) + assert manager.registry.is_installed("epsilon") + assert not manager.registry.is_installed("gamma") + + def test_preset_stack_flag_skips_default_stack(self, temp_dir): + """AC3: `--preset-stack ` given -> `default` is skipped entirely, + and the named stack is applied instead.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + gamma_dir = _make_preset_dir(temp_dir, "gamma") + delta_dir = _make_preset_dir(temp_dir, "delta") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "gamma", "priority": 5, "source": str(gamma_dir)}]}, + {"name": "other", "entries": [{"preset": "delta", "priority": 5, "source": str(delta_dir)}]}, + ] + }) + + result = _init_here(project_dir, ["--preset-stack", "other"]) + + assert result.exit_code == 0, result.stdout + manager = PresetManager(project_dir) + assert manager.registry.is_installed("delta") + assert not manager.registry.is_installed("gamma") + + def test_preset_stack_none_suppresses_default(self, temp_dir): + """AC4: `--preset-stack none` suppresses `default` even though it exists.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + gamma_dir = _make_preset_dir(temp_dir, "gamma") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "gamma", "priority": 5, "source": str(gamma_dir)}]}, + ] + }) + + result = _init_here(project_dir, ["--preset-stack", "none"]) + + assert result.exit_code == 0, result.stdout + assert not PresetManager(project_dir).registry.is_installed("gamma") + + def test_preset_and_preset_stack_together_rejected(self, temp_dir): + """AC5: both `--preset` and `--preset-stack` given -> rejected, neither installed.""" + project_name = "newproj" + previous = os.getcwd() + os.chdir(temp_dir) + try: + result = CliRunner().invoke( + app, + [ + "init", + project_name, + *_INIT_ARGS, + "--preset", + "whatever", + "--preset-stack", + "default", + ], + catch_exceptions=True, + ) + finally: + os.chdir(previous) + + assert result.exit_code != 0 + assert "--preset" in result.stdout + assert "--preset-stack" in result.stdout + assert not (temp_dir / project_name).exists() + + def test_unknown_preset_stack_name_rejected(self, temp_dir): + """FR-2022: an unknown `--preset-stack ` is rejected, naming the + stack and listing what's defined.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + gamma_dir = _make_preset_dir(temp_dir, "gamma") + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "gamma", "priority": 5, "source": str(gamma_dir)}]}, + ] + }) + + result = _init_here(project_dir, ["--preset-stack", "nope"]) + + assert result.exit_code != 0 + assert "nope" in result.stdout + assert "default" in result.stdout + + +# ===== `specify preset stack` CLI verbs (US3) ===== + + +def _run_stack_cli(project_dir: Path, args: list[str]): + previous = os.getcwd() + os.chdir(project_dir) + try: + return CliRunner().invoke(app, ["preset", "stack", *args], catch_exceptions=True) + finally: + os.chdir(previous) + + +class TestPresetStackCli: + def test_list_shows_stacks_with_priorities_and_default_marker(self, project_dir): + """AC1: `list` shows every stack's name, entries with priorities, and default status.""" + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "alpha", "priority": 5}]}, + {"name": "other", "entries": [{"preset": "beta", "priority": 20}]}, + ] + }) + + result = _run_stack_cli(project_dir, ["list"]) + + assert result.exit_code == 0, result.stdout + assert "default" in result.stdout + assert "other" in result.stdout + assert "alpha" in result.stdout + assert "beta" in result.stdout + assert "5" in result.stdout + assert "20" in result.stdout + + def test_list_with_no_stacks_defined_exits_zero(self, project_dir): + result = _run_stack_cli(project_dir, ["list"]) + assert result.exit_code == 0, result.stdout + + def test_install_by_name_only_touches_that_stacks_presets(self, project_dir, temp_dir): + """AC2: applying a non-default stack by name leaves other installed presets untouched.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + PresetManager(project_dir).install_from_directory(alpha_dir, "0.1.5", priority=10) + + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "other", "entries": [{"preset": "beta", "priority": 5, "source": str(beta_dir)}]}, + ] + }) + + result = _run_stack_cli(project_dir, ["install", "other"]) + + assert result.exit_code == 0, result.stdout + manager = PresetManager(project_dir) + assert manager.registry.is_installed("alpha") + assert manager.registry.is_installed("beta") + + def test_install_unknown_stack_name_rejected(self, project_dir): + """FR-2022: on-demand `install ` CLI path names the requested stack and what's defined.""" + _write_stacks_config(project_dir, { + "stacks": [{"name": "default", "entries": [{"preset": "alpha", "priority": 5}]}] + }) + + result = _run_stack_cli(project_dir, ["install", "nope"]) + + assert result.exit_code != 0 + assert "nope" in result.stdout + assert "default" in result.stdout + + def test_add_only_edits_config_and_never_installs(self, project_dir): + result = _run_stack_cli( + project_dir, ["add", "default", "--preset", "alpha", "--priority", "5"] + ) + + assert result.exit_code == 0, result.stdout + config = load_stacks_config(project_dir) + assert config.stacks == [ + PresetStack(name="default", entries=[PresetStackEntry(preset="alpha", priority=5)]) + ] + assert not PresetManager(project_dir).registry.is_installed("alpha") + + def test_add_updates_existing_entry_in_place(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [{"name": "default", "entries": [{"preset": "alpha", "priority": 5}]}] + }) + + result = _run_stack_cli( + project_dir, ["add", "default", "--preset", "alpha", "--priority", "20"] + ) + + assert result.exit_code == 0, result.stdout + config = load_stacks_config(project_dir) + assert config.stacks == [ + PresetStack(name="default", entries=[PresetStackEntry(preset="alpha", priority=20)]) + ] + + def test_remove_whole_stack(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [ + {"name": "default", "entries": [{"preset": "alpha", "priority": 5}]}, + {"name": "other", "entries": [{"preset": "beta", "priority": 10}]}, + ] + }) + + result = _run_stack_cli(project_dir, ["remove", "default"]) + + assert result.exit_code == 0, result.stdout + config = load_stacks_config(project_dir) + assert [s.name for s in config.stacks] == ["other"] + + def test_remove_single_entry_only(self, project_dir): + _write_stacks_config(project_dir, { + "stacks": [ + { + "name": "default", + "entries": [ + {"preset": "alpha", "priority": 5}, + {"preset": "beta", "priority": 10}, + ], + }, + ] + }) + + result = _run_stack_cli(project_dir, ["remove", "default", "--preset", "alpha"]) + + assert result.exit_code == 0, result.stdout + config = load_stacks_config(project_dir) + assert config.stacks == [ + PresetStack(name="default", entries=[PresetStackEntry(preset="beta", priority=10)]) + ] + + def test_remove_never_uninstalls(self, project_dir, temp_dir): + """`remove` only edits the config file; it never calls `PresetManager.remove`.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + ]) + apply_stack(project_dir, stack, "0.1.5") + assert PresetManager(project_dir).registry.is_installed("alpha") + _write_stacks_config(project_dir, { + "stacks": [{"name": "default", "entries": [{"preset": "alpha", "priority": 5, "source": str(alpha_dir)}]}], + }) + + result = _run_stack_cli(project_dir, ["remove", "default"]) + + assert result.exit_code == 0, result.stdout + assert PresetManager(project_dir).registry.is_installed("alpha") From ab56e127d4250b58cebaac305b23a667d8987345 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Thu, 13 Aug 2026 12:00:21 +0300 Subject: [PATCH 2/6] feat(presets): add specify preset stack list/add/remove/install Adds the specify preset stack list/add/remove CLI verbs, mirroring preset_catalog_add/remove's exact YAML-dict-edit pattern: list shows every stack defined in .specify/preset-stacks.yml; add/remove only edit that config file (never install or uninstall anything). (The install verb and its wiring into stacks.py were added in the prior commit.) --- src/specify_cli/presets/_commands.py | 215 +++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index b7e5ad06e5..a90a894d07 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -39,6 +39,13 @@ ) preset_app.add_typer(preset_catalog_app, name="catalog") +preset_stack_app = typer.Typer( + name="stack", + help="Manage and apply reusable preset stacks", + add_completion=False, +) +preset_app.add_typer(preset_stack_app, name="stack") + # ===== Preset Commands ===== @@ -844,6 +851,214 @@ def preset_catalog_remove( console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") +@preset_stack_app.command("install") +def preset_stack_install( + name: str = typer.Argument(help="Stack name to apply"), +): + """Apply a named stack from .specify/preset-stacks.yml.""" + from .. import _require_specify_project, get_speckit_version + from . import PresetValidationError + from .stacks import apply_stack, load_stacks_config + + project_root = _require_specify_project() + + try: + config = load_stacks_config(project_root) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + stack = next((s for s in config.stacks if s.name == name), None) + if stack is None: + known = ", ".join(s.name for s in config.stacks) or "(none defined)" + console.print(f"[red]Error:[/red] Stack '{_escape_markup(name)}' is not defined in .specify/preset-stacks.yml") + console.print(f"Defined stacks: {_escape_markup(known)}") + raise typer.Exit(1) + + result = apply_stack(project_root, stack, get_speckit_version()) + + for entry in result.entries: + if entry.success: + console.print(f"[green]✓[/green] Preset '{_escape_markup(entry.preset)}' installed") + else: + console.print(f"[red]✗[/red] Preset '{_escape_markup(entry.preset)}' failed: {_escape_markup(entry.error or '')}") + + for pid in result.removed: + console.print(f"[dim]- Removed preset '{_escape_markup(pid)}' (no longer in stack '{_escape_markup(name)}')[/dim]") + + if not result.success: + raise typer.Exit(1) + + console.print(f"\n[green]✓[/green] Stack '{_escape_markup(name)}' applied") + + +@preset_stack_app.command("list") +def preset_stack_list(): + """List every stack defined in .specify/preset-stacks.yml.""" + from .. import _require_specify_project + from . import PresetValidationError + from .stacks import load_stacks_config + + project_root = _require_specify_project() + + try: + config = load_stacks_config(project_root) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + if not config.stacks: + console.print("[dim]No stacks defined in .specify/preset-stacks.yml[/dim]") + return + + console.print("\n[bold cyan]Preset Stacks:[/bold cyan]\n") + for stack in config.stacks: + marker = " [green](default)[/green]" if stack.name == "default" else "" + console.print(f" [bold]{_escape_markup(stack.name)}[/bold]{marker}") + for entry in stack.entries: + source_suffix = f" (source: {_escape_markup(entry.source)})" if entry.source else "" + console.print( + f" - {_escape_markup(entry.preset)} (priority {entry.priority}){source_suffix}" + ) + console.print() + + +@preset_stack_app.command("add") +def preset_stack_add( + name: str = typer.Argument(help="Stack name to add or update"), + preset: str = typer.Option(..., "--preset", help="Preset ID to add to the stack"), + priority: int = typer.Option(10, "--priority", help="Install priority (lower = higher priority)"), + source: str = typer.Option(None, "--source", help="Explicit source: local directory path or archive URL"), +): + """Add or update one entry in a stack's definition. Never installs anything.""" + from .. import _display_project_path, _require_specify_project + from .stacks import RESERVED_STACK_NAMES + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + if name in RESERVED_STACK_NAMES: + console.print(f"[red]Error:[/red] Stack name '{_escape_markup(name)}' is reserved and cannot be used") + raise typer.Exit(1) + + config_path = specify_dir / "preset-stacks.yml" + + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_path))}: {_escape_markup(str(e))}") + raise typer.Exit(1) + else: + config = {} + + stacks = config.get("stacks", []) + if not isinstance(stacks, list): + console.print("[red]Error:[/red] Invalid preset-stacks.yml: 'stacks' must be a list.") + raise typer.Exit(1) + + safe_name = _escape_markup(name) + safe_preset = _escape_markup(preset) + + stack = next((s for s in stacks if isinstance(s, dict) and s.get("name") == name), None) + if stack is None: + stack = {"name": name, "entries": []} + stacks.append(stack) + + entries = stack.setdefault("entries", []) + if not isinstance(entries, list): + console.print(f"[red]Error:[/red] Invalid preset-stacks.yml: stack '{safe_name}' has entries that must be a list.") + raise typer.Exit(1) + + entry = next((e for e in entries if isinstance(e, dict) and e.get("preset") == preset), None) + entry_data = {"preset": preset, "priority": priority} + if source: + entry_data["source"] = source + + if entry is None: + entries.append(entry_data) + verb = "Added" + else: + entry.clear() + entry.update(entry_data) + verb = "Updated" + + config["stacks"] = stacks + config_path.write_text( + yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + console.print(f"[green]✓[/green] {verb} preset '{safe_preset}' in stack '{safe_name}' (priority {priority})") + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"\nConfig saved to {config_label}") + + +@preset_stack_app.command("remove") +def preset_stack_remove( + name: str = typer.Argument(help="Stack name to remove or edit"), + preset: str = typer.Option(None, "--preset", help="If given, remove only this preset entry from the stack"), +): + """Remove a stack definition, or one entry from it. Never uninstalls anything.""" + from .. import _require_specify_project + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + config_path = specify_dir / "preset-stacks.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No .specify/preset-stacks.yml found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_path))}: {_escape_markup(str(e))}") + raise typer.Exit(1) + + stacks = config.get("stacks", []) + if not isinstance(stacks, list): + console.print("[red]Error:[/red] Invalid preset-stacks.yml: 'stacks' must be a list.") + raise typer.Exit(1) + + safe_name = _escape_markup(name) + + stack = next((s for s in stacks if isinstance(s, dict) and s.get("name") == name), None) + if stack is None: + console.print(f"[red]Error:[/red] Stack '{safe_name}' not found.") + raise typer.Exit(1) + + if preset is None: + stacks = [s for s in stacks if s is not stack] + config["stacks"] = stacks + config_path.write_text( + yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + console.print(f"[green]✓[/green] Removed stack '{safe_name}'") + return + + entries = stack.get("entries", []) + if not isinstance(entries, list): + console.print(f"[red]Error:[/red] Invalid preset-stacks.yml: stack '{safe_name}' has entries that must be a list.") + raise typer.Exit(1) + + safe_preset = _escape_markup(preset) + original_count = len(entries) + entries = [e for e in entries if not (isinstance(e, dict) and e.get("preset") == preset)] + if len(entries) == original_count: + console.print(f"[red]Error:[/red] Preset '{safe_preset}' not found in stack '{safe_name}'.") + raise typer.Exit(1) + + stack["entries"] = entries + config["stacks"] = stacks + config_path.write_text( + yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + console.print(f"[green]✓[/green] Removed preset '{safe_preset}' from stack '{safe_name}'") + + def register(app: typer.Typer) -> None: """Attach the preset command group to the root Typer app.""" app.add_typer(preset_app, name="preset") From 06c694b0a8b33dd777e3243e3ca7b8cf49a793a8 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Fri, 14 Aug 2026 12:31:32 +0300 Subject: [PATCH 3/6] feat(init): add --preset-stack flag to specify init Applies a named stack (or the implicit "default" stack, if defined and no flag is given) automatically at init time via apply_stack(). --preset-stack none skips stack resolution entirely; --preset and --preset-stack are mutually exclusive. bundle/_run_init() invokes init's raw Typer callback with a fully-enumerated kwarg list, bypassing Click's option-default resolution, so the new preset_stack parameter has to be passed through there as well; without it the parameter keeps the raw typer.Option sentinel and bundle-driven bootstrap fails on a string comparison. --- src/specify_cli/commands/bundle/__init__.py | 1 + src/specify_cli/commands/init.py | 83 +++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index b816e6fd01..8608275800 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -122,6 +122,7 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N github_token=None, offline=offline, preset=None, + preset_stack=None, integration=integration, integration_options=None, extensions=None, diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 2bb8452025..487e9f5735 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -293,6 +293,11 @@ def init( "--preset", help="Install a preset during initialization (by preset ID)", ), + preset_stack: str = typer.Option( + None, + "--preset-stack", + help="Apply a named preset stack from .specify/preset-stacks.yml during initialization ('none' to suppress the implicit 'default' stack)", + ), integration: str = typer.Option( None, "--integration", @@ -381,6 +386,12 @@ def init( console.print(f"[yellow]Available integrations:[/yellow] {available}") raise typer.Exit(1) + if preset and preset_stack: + console.print( + "[red]Error:[/red] Cannot specify both --preset and --preset-stack" + ) + raise typer.Exit(1) + if project_name == ".": here = True project_name = None @@ -850,6 +861,78 @@ def init( preset_err, continuing="Continuing without the optional preset.", ) + else: + from ..presets import PresetValidationError + from ..presets.stacks import apply_stack, load_stacks_config + + stack_to_apply = None + if preset_stack != "none": + try: + stacks_config = load_stacks_config(project_path) + except PresetValidationError as stacks_err: + console.print( + f"[red]Error:[/red] {_escape_markup(str(stacks_err))}" + ) + raise typer.Exit(1) + + if preset_stack: + stack_to_apply = next( + ( + s + for s in stacks_config.stacks + if s.name == preset_stack + ), + None, + ) + if stack_to_apply is None: + known = ", ".join( + s.name for s in stacks_config.stacks + ) or "(none defined)" + console.print( + f"[red]Error:[/red] Stack '{_escape_markup(preset_stack)}' " + f"is not defined in .specify/preset-stacks.yml" + ) + console.print( + f"Defined stacks: {_escape_markup(known)}" + ) + raise typer.Exit(1) + else: + stack_to_apply = next( + ( + s + for s in stacks_config.stacks + if s.name == "default" + ), + None, + ) + + if stack_to_apply is not None: + try: + result = apply_stack( + project_path, stack_to_apply, get_speckit_version() + ) + for entry in result.entries: + if entry.success: + console.print( + f"[green]✓[/green] Preset '{_escape_markup(entry.preset)}' installed" + ) + else: + console.print( + f"[yellow]Warning:[/yellow] {_escape_markup(entry.error or '')}" + ) + for pid in result.removed: + console.print( + f"[dim]- Removed preset '{_escape_markup(pid)}' " + f"(no longer in stack '{_escape_markup(stack_to_apply.name)}')[/dim]" + ) + except Exception as stack_err: + _print_cli_warning( + "install", + "preset stack", + stack_to_apply.name, + stack_err, + continuing="Continuing without the full preset stack.", + ) # Install extensions specified via --extension if extensions: From 1053b46636f7359bc7f4615b890d29b3aa0aced8 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Thu, 13 Aug 2026 12:00:42 +0300 Subject: [PATCH 4/6] docs(presets): document preset stacks Adds a Preset Stacks section to README.md (config format, CLI verbs, --preset-stack) and ARCHITECTURE.md (apply_stack() flow diagram, module cross-references), and corrects ARCHITECTURE.md's Module Structure listing to reflect the real presets/ package layout. --- presets/ARCHITECTURE.md | 52 ++++++++++++++++++++++++++++++++++++----- presets/README.md | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index 2ef78add27..5055153cb0 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -144,6 +144,42 @@ flowchart TD Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. +## Preset Stacks + +`.specify/preset-stacks.yml` holds named, ordered lists of preset entries (`preset`, `priority`, +optional `source`). `load_stacks_config()` parses and validates the file (unique stack names, +`default`/`none` reserved, no duplicate `preset` within a stack); `apply_stack()` then drives the +same install path as `specify preset add` for each entry, in priority order. + +```mermaid +flowchart TD + A["apply_stack(project_root, stack)"] --> B["load stack-state.json\n(prior applied stack, if any)"] + B --> C{"entry has explicit source?"} + C -- Yes --> D["install_from_directory / install_from_archive"] + C -- No --> E["catalog.download_pack(bypass_install_allowed=True)"] + E --> D + D --> F["record entry result (success/error)"] + F --> G{"more entries?"} + G -- Yes --> C + G -- No --> H["diff this stack's preset IDs vs\nprior stack-state for this name"] + H --> I["uninstall presets dropped from the stack,\nunless still listed by another applied stack"] + I --> J["write updated stack-state.json"] +``` + +Per-entry failures are collected but never abort the run — `apply_stack()` returns a result with +one entry per attempted install plus the list of removed preset IDs; the caller (CLI command or +`specify init`) renders success/failure lines and exits non-zero only if at least one entry failed. + +`specify init`'s implicit-default behavior and `specify preset stack install ` share this +same `apply_stack()` call — resolving which stack to apply (named, implicit `default`, or none) is +the only logic that differs between the two entry points. + +- **Python**: `load_stacks_config()`, `apply_stack()`, `_resolve_entry_source()` in + `src/specify_cli/presets/stacks.py` +- **CLI**: `specify preset stack list/install/add/remove` in + `src/specify_cli/presets/_commands.py`; `--preset-stack` on `specify init` in + `src/specify_cli/commands/init.py` + ## Repository Layout ``` @@ -178,10 +214,14 @@ presets/ ``` src/specify_cli/ -├── agents.py # CommandRegistrar — shared infrastructure for writing -│ # command files to agent directories -├── presets.py # PresetManifest, PresetRegistry, PresetManager, -│ # PresetCatalog, PresetCatalogEntry, PresetResolver -└── __init__.py # CLI commands: specify preset list/add/remove/search/ - # resolve/info, specify preset catalog list/add/remove +├── agents.py # CommandRegistrar — shared infrastructure for writing +│ # command files to agent directories +└── presets/ + ├── __init__.py # PresetManifest, PresetRegistry, PresetManager, + │ # PresetCatalog, PresetCatalogEntry, PresetResolver + ├── _commands.py # CLI commands: specify preset list/add/remove/search/ + │ # resolve/info, specify preset catalog list/add/remove, + │ # specify preset stack list/install/add/remove + └── stacks.py # PresetStackEntry, PresetStack, PresetStacksConfig, + # load_stacks_config(), apply_stack() ``` diff --git a/presets/README.md b/presets/README.md index 539da08786..adf5560140 100644 --- a/presets/README.md +++ b/presets/README.md @@ -121,6 +121,56 @@ specify preset catalog add https://example.com/catalog.json --name my-org --inst specify preset catalog remove my-org ``` +## Preset Stacks + +A preset stack is a named, ordered list of `specify preset add` calls saved to +`.specify/preset-stacks.yml`, so a team can apply its whole preset lineup in one step instead of +running each `add` by hand: + +```yaml +stacks: + - name: team-baseline + entries: + - preset: healthcare-compliance + priority: 10 + - preset: enterprise-safe + priority: 5 + - name: default + entries: + - preset: healthcare-compliance + priority: 10 +``` + +A stack named `default` is applied automatically by `specify init` — no `--preset` or +`--preset-stack` flag needed. `default` and `none` are reserved stack names. + +```bash +# List every defined stack +specify preset stack list + +# Apply a stack on demand (also re-syncs: entries no longer in the stack are +# uninstalled, unless another applied stack still lists them) +specify preset stack install team-baseline + +# Add or update one entry in a stack's definition (never installs anything) +specify preset stack add team-baseline --preset enterprise-safe --priority 5 + +# Remove a whole stack, or just one entry, from the definition (never uninstalls anything) +specify preset stack remove team-baseline --preset enterprise-safe +specify preset stack remove team-baseline + +# Skip the implicit default stack at init time +specify init --preset-stack none + +# Apply a specific named stack at init time instead of the default +specify init --preset-stack team-baseline +``` + +`--preset` and `--preset-stack` are mutually exclusive on `specify init`. A stack entry can pin an +explicit `source` (local directory or archive URL); without one, the preset is resolved through the +normal catalog lookup — and, unlike a bare `specify preset add`, bypasses `install_allowed` for +discovery-only catalogs, since listing a preset in a stack is itself the trust decision. + ## Creating a Preset See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset. @@ -159,6 +209,7 @@ The token is attached automatically to requests targeting GitHub domains. Non-Gi |------|-------|-------------| | `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project | | `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects | +| `.specify/preset-stacks.yml` | Project | Named, reusable preset stacks (see [Preset Stacks](#preset-stacks)) | ## Future Considerations From 27eb3c60f881a4fcc3cba7feb8abb09fc69350ff Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Fri, 14 Aug 2026 12:30:00 +0300 Subject: [PATCH 5/6] fix(presets): make stack state track what was actually installed Addresses Copilot review feedback on the stack sync logic: - Stack membership now follows stack.entries, not a run's install outcomes. A transient failure previously dropped the entry from current_ids, so the diff treated a still-listed preset as removed and uninstalled a working installation. - Successful entries are recorded under the ID their manifest actually declares. PresetManager keys the registry off the manifest, so a source shipping a different ID left the requested ID in stack state and the real one orphaned on removal. - A run with any failing entry now defers uninstalls (deferred_removals) instead of guessing: a failed entry yields no manifest ID, so a previously tracked ID that differs from the requested one cannot be attributed to it, and removal is destructive. Also moves stack selection (select_stack) and result rendering (render_apply_result) into stacks.py, so `specify init` and `specify preset stack install` share them and the init.py diff shrinks from 83 to 40 lines. Co-Authored-By: Claude Opus 5 --- src/specify_cli/commands/init.py | 69 +++----------- src/specify_cli/presets/_commands.py | 12 +-- src/specify_cli/presets/stacks.py | 134 ++++++++++++++++++++++++--- tests/test_preset_stacks.py | 75 +++++++++++++++ 4 files changed, 213 insertions(+), 77 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 487e9f5735..e3e32e4631 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -862,69 +862,26 @@ def init( continuing="Continuing without the optional preset.", ) else: - from ..presets import PresetValidationError - from ..presets.stacks import apply_stack, load_stacks_config + from ..presets import PresetError + from ..presets.stacks import apply_stack, render_apply_result, select_stack - stack_to_apply = None - if preset_stack != "none": - try: - stacks_config = load_stacks_config(project_path) - except PresetValidationError as stacks_err: - console.print( - f"[red]Error:[/red] {_escape_markup(str(stacks_err))}" - ) - raise typer.Exit(1) - - if preset_stack: - stack_to_apply = next( - ( - s - for s in stacks_config.stacks - if s.name == preset_stack - ), - None, - ) - if stack_to_apply is None: - known = ", ".join( - s.name for s in stacks_config.stacks - ) or "(none defined)" - console.print( - f"[red]Error:[/red] Stack '{_escape_markup(preset_stack)}' " - f"is not defined in .specify/preset-stacks.yml" - ) - console.print( - f"Defined stacks: {_escape_markup(known)}" - ) - raise typer.Exit(1) - else: - stack_to_apply = next( - ( - s - for s in stacks_config.stacks - if s.name == "default" - ), - None, - ) + try: + stack_to_apply = select_stack(project_path, preset_stack) + except PresetError as stacks_err: + console.print(f"[red]Error:[/red] {_escape_markup(str(stacks_err))}") + raise typer.Exit(1) if stack_to_apply is not None: try: result = apply_stack( project_path, stack_to_apply, get_speckit_version() ) - for entry in result.entries: - if entry.success: - console.print( - f"[green]✓[/green] Preset '{_escape_markup(entry.preset)}' installed" - ) - else: - console.print( - f"[yellow]Warning:[/yellow] {_escape_markup(entry.error or '')}" - ) - for pid in result.removed: - console.print( - f"[dim]- Removed preset '{_escape_markup(pid)}' " - f"(no longer in stack '{_escape_markup(stack_to_apply.name)}')[/dim]" - ) + # Stack application is best-effort here, like --preset + # above: failures warn and init still succeeds. Use + # `specify preset stack install ` for a non-zero + # exit on failure. + for line in render_apply_result(result, failure_style="warning"): + console.print(line) except Exception as stack_err: _print_cli_warning( "install", diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index a90a894d07..89de0ec480 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -858,7 +858,7 @@ def preset_stack_install( """Apply a named stack from .specify/preset-stacks.yml.""" from .. import _require_specify_project, get_speckit_version from . import PresetValidationError - from .stacks import apply_stack, load_stacks_config + from .stacks import apply_stack, load_stacks_config, render_apply_result project_root = _require_specify_project() @@ -877,14 +877,8 @@ def preset_stack_install( result = apply_stack(project_root, stack, get_speckit_version()) - for entry in result.entries: - if entry.success: - console.print(f"[green]✓[/green] Preset '{_escape_markup(entry.preset)}' installed") - else: - console.print(f"[red]✗[/red] Preset '{_escape_markup(entry.preset)}' failed: {_escape_markup(entry.error or '')}") - - for pid in result.removed: - console.print(f"[dim]- Removed preset '{_escape_markup(pid)}' (no longer in stack '{_escape_markup(name)}')[/dim]") + for line in render_apply_result(result): + console.print(line) if not result.success: raise typer.Exit(1) diff --git a/src/specify_cli/presets/stacks.py b/src/specify_cli/presets/stacks.py index 1742dd7319..a7f371f9e1 100644 --- a/src/specify_cli/presets/stacks.py +++ b/src/specify_cli/presets/stacks.py @@ -19,7 +19,9 @@ STACK_STATE_FILENAME = ".stack-state.json" STACKS_CONFIG_FILENAME = "preset-stacks.yml" -RESERVED_STACK_NAMES = ("none",) +DEFAULT_STACK_NAME = "default" +NO_STACK_NAME = "none" +RESERVED_STACK_NAMES = (NO_STACK_NAME,) @dataclass @@ -150,6 +152,33 @@ def load_stacks_config(project_root: Path) -> PresetStacksConfig: return PresetStacksConfig(stacks=stacks) +def select_stack(project_root: Path, requested: Optional[str]) -> Optional[PresetStack]: + """Resolve which stack `specify init` should apply. + + `None` selects the `default` stack when one is defined, `'none'` selects + nothing, and any other name must exist in the config. + + Raises: + PresetValidationError: If the config file exists but is invalid. + PresetError: If an explicitly named stack is not defined. + """ + from . import PresetError + + if requested == NO_STACK_NAME: + return None + + config = load_stacks_config(project_root) + name = requested or DEFAULT_STACK_NAME + stack = next((s for s in config.stacks if s.name == name), None) + if stack is None and requested is not None: + known = ", ".join(s.name for s in config.stacks) or "(none defined)" + raise PresetError( + f"Stack '{name}' is not defined in .specify/{STACKS_CONFIG_FILENAME}. " + f"Defined stacks: {known}" + ) + return stack + + def _stack_state_path(project_root: Path) -> Path: return project_root / ".specify" / "presets" / STACK_STATE_FILENAME @@ -187,7 +216,6 @@ def _download_archive(url: str) -> Path: from urllib.parse import urlparse from .._download_security import ( - archive_format_from_name, archive_suffix, detect_archive_format, is_https_or_localhost_http, @@ -309,6 +337,10 @@ class StackEntryResult: preset: str success: bool error: Optional[str] = None + installed_id: Optional[str] = None + """The preset ID the manifest actually declared, set on success. Usually + identical to `preset`, but a source can ship a manifest with a different ID, + and it is that ID `PresetManager` installs and removes under.""" @dataclass @@ -318,12 +350,62 @@ class StackApplyResult: stack_name: str entries: list[StackEntryResult] = field(default_factory=list) removed: list[str] = field(default_factory=list) + deferred_removals: list[str] = field(default_factory=list) + """Presets that look dropped but were left installed because this run had a + failing entry, so the tracked IDs could not be attributed reliably.""" @property def success(self) -> bool: return all(e.success for e in self.entries) +def _dedupe(ids: list[str]) -> list[str]: + """Order-preserving de-duplication (two entries can resolve to one manifest ID).""" + seen: set[str] = set() + return [i for i in ids if not (i in seen or seen.add(i))] + + +def render_apply_result(result: StackApplyResult, failure_style: str = "error") -> list[str]: + """Render an apply result as Rich markup lines, shared by `init` and the CLI verb. + + `failure_style="warning"` is used by `specify init`, where a failing entry is + a warning rather than a command failure. + """ + from rich.markup import escape + + lines: list[str] = [] + for entry in result.entries: + if entry.success: + installed_as = ( + f" (as '{escape(entry.installed_id)}')" + if entry.installed_id and entry.installed_id != entry.preset + else "" + ) + lines.append(f"[green]✓[/green] Preset '{escape(entry.preset)}' installed{installed_as}") + elif failure_style == "warning": + lines.append(f"[yellow]Warning:[/yellow] {escape(entry.error or '')}") + else: + lines.append( + f"[red]✗[/red] Preset '{escape(entry.preset)}' failed: {escape(entry.error or '')}" + ) + + for pid in result.removed: + lines.append( + f"[dim]- Removed preset '{escape(pid)}' " + f"(no longer in stack '{escape(result.stack_name)}')[/dim]" + ) + + if result.deferred_removals: + deferred = ", ".join(escape(pid) for pid in result.deferred_removals) + lines.append( + f"[dim]- Kept preset(s) {deferred}: no longer listed in stack " + f"'{escape(result.stack_name)}', but removal is deferred until the stack " + f"applies cleanly[/dim]" + ) + + return lines + + def apply_stack(project_root: Path, stack: PresetStack, speckit_version: str) -> StackApplyResult: """Apply a named stack: install every current entry, then sync out dropped ones. @@ -331,28 +413,38 @@ def apply_stack(project_root: Path, stack: PresetStack, speckit_version: str) -> `install_from_zip` (with `force=True`, which already removes-then-reinstalls a present pack, per `presets/__init__.py:3567-3573`) and `PresetManager.remove` — no new install/uninstall logic lives here. + + Stack membership follows `stack.entries`, never this run's install outcomes, + so a transient failure cannot make a still-listed preset look dropped. """ from . import PresetError, PresetManager manager = PresetManager(project_root) entries: list[StackEntryResult] = [] - current_ids: list[str] = [] + member_ids: list[str] = [] + any_failed = False for entry in stack.entries: cleanup: Optional[Path] = None try: source_path, is_directory, cleanup = _resolve_entry_source(project_root, entry) if is_directory: - manager.install_from_directory( + manifest = manager.install_from_directory( source_path, speckit_version, priority=entry.priority, force=True ) else: - manager.install_from_zip( + manifest = manager.install_from_zip( source_path, speckit_version, priority=entry.priority, force=True ) - entries.append(StackEntryResult(preset=entry.preset, success=True)) - current_ids.append(entry.preset) + # Track the ID that was really installed: `PresetManager` keys the + # registry off the manifest, so tracking the requested ID instead + # would later remove the wrong preset and orphan the installed one. + entries.append( + StackEntryResult(preset=entry.preset, success=True, installed_id=manifest.id) + ) + member_ids.append(manifest.id) except PresetError as e: + any_failed = True entries.append( StackEntryResult( preset=entry.preset, @@ -360,6 +452,7 @@ def apply_stack(project_root: Path, stack: PresetStack, speckit_version: str) -> error=f"stack '{stack.name}', preset '{entry.preset}': {e}", ) ) + member_ids.append(entry.preset) finally: if cleanup is not None: if cleanup.is_dir(): @@ -368,19 +461,36 @@ def apply_stack(project_root: Path, stack: PresetStack, speckit_version: str) -> cleanup.unlink(missing_ok=True) state = _load_stack_state(project_root) - previous_ids = set(state.get(stack.name, [])) + previous_ids = state.get(stack.name, []) + members = set(member_ids) other_stacks_ids: set[str] = set() for other_name, other_ids in state.items(): if other_name != stack.name: other_stacks_ids.update(other_ids) + dropped = [ + pid for pid in previous_ids if pid not in members and pid not in other_stacks_ids + ] + removed: list[str] = [] - for pid in previous_ids - set(current_ids): - if pid not in other_stacks_ids: + deferred_removals: list[str] = [] + if any_failed: + # A failed entry never yielded a manifest ID, so a previously tracked ID + # that differs from the requested one cannot be attributed to it. + # Uninstalling is destructive: defer it to the next fully successful run. + deferred_removals = dropped + member_ids.extend(pid for pid in previous_ids if pid not in members) + else: + for pid in dropped: manager.remove(pid) removed.append(pid) - state[stack.name] = current_ids + state[stack.name] = _dedupe(member_ids) _save_stack_state(project_root, state) - return StackApplyResult(stack_name=stack.name, entries=entries, removed=removed) + return StackApplyResult( + stack_name=stack.name, + entries=entries, + removed=removed, + deferred_removals=deferred_removals, + ) diff --git a/tests/test_preset_stacks.py b/tests/test_preset_stacks.py index 028e127921..aa90cb3487 100644 --- a/tests/test_preset_stacks.py +++ b/tests/test_preset_stacks.py @@ -9,6 +9,7 @@ - `specify init --preset-stack` / implicit-default resolution """ +import json import os import tempfile import shutil @@ -109,6 +110,12 @@ def _make_preset_dir(base_dir: Path, pack_id: str, version: str = "1.0.0") -> Pa return p_dir +def _read_stack_state(project_dir: Path) -> dict: + return json.loads( + (project_dir / ".specify" / "presets" / ".stack-state.json").read_text(encoding="utf-8") + ) + + def _zip_preset_dir(pack_dir: Path, zip_path: Path) -> Path: with zipfile.ZipFile(zip_path, "w") as zf: for file_path in pack_dir.rglob("*"): @@ -369,6 +376,74 @@ def test_dropped_entry_stays_installed_if_another_stack_still_lists_it(self, pro assert result.removed == [] assert PresetManager(project_dir).registry.is_installed("beta") + def test_failed_entry_is_not_treated_as_dropped(self, project_dir, temp_dir): + """A transient failure must not make a still-listed preset look dropped.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + PresetStackEntry(preset="beta", priority=10, source=str(beta_dir)), + ]) + apply_stack(project_dir, stack, "0.1.5") + + # Same stack definition, but beta's source is momentarily unreachable. + stack.entries[1].source = str(temp_dir / "gone") + result = apply_stack(project_dir, stack, "0.1.5") + + assert not result.success + assert result.removed == [] + assert PresetManager(project_dir).registry.is_installed("beta") + assert set(_read_stack_state(project_dir)["default"]) == {"alpha", "beta"} + + def test_dropped_entry_removal_is_deferred_while_another_entry_fails( + self, project_dir, temp_dir + ): + """A run with a failing entry defers uninstalls instead of guessing.""" + alpha_dir = _make_preset_dir(temp_dir, "alpha") + beta_dir = _make_preset_dir(temp_dir, "beta") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir)), + PresetStackEntry(preset="beta", priority=10, source=str(beta_dir)), + ]) + apply_stack(project_dir, stack, "0.1.5") + + stack.entries = [PresetStackEntry(preset="alpha", priority=5, source=str(temp_dir / "gone"))] + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.removed == [] + assert result.deferred_removals == ["beta"] + assert PresetManager(project_dir).registry.is_installed("beta") + + # Once the stack applies cleanly, the deferred removal happens. + stack.entries = [PresetStackEntry(preset="alpha", priority=5, source=str(alpha_dir))] + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert result.removed == ["beta"] + assert not PresetManager(project_dir).registry.is_installed("beta") + + def test_manifest_id_differing_from_entry_is_tracked_and_removed( + self, project_dir, temp_dir + ): + """A source whose manifest declares another ID is tracked under that ID.""" + real_dir = _make_preset_dir(temp_dir, "real-id") + stack = PresetStack(name="default", entries=[ + PresetStackEntry(preset="requested-id", priority=10, source=str(real_dir)), + ]) + + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.success + assert result.entries[0].installed_id == "real-id" + assert _read_stack_state(project_dir)["default"] == ["real-id"] + assert PresetManager(project_dir).registry.is_installed("real-id") + + stack.entries = [] + result = apply_stack(project_dir, stack, "0.1.5") + + assert result.removed == ["real-id"] + assert not PresetManager(project_dir).registry.is_installed("real-id") + def test_independently_installed_preset_is_never_touched(self, project_dir, temp_dir): """AC5/FR-2035: a preset installed outside of any stack is left alone.""" beta_dir = _make_preset_dir(temp_dir, "beta") From e6bd4160240d22059a5586a97c0373b2fdb8b524 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Fri, 14 Aug 2026 12:30:09 +0300 Subject: [PATCH 6/6] docs(presets): correct stack docs to match the implementation Addresses Copilot review feedback: - Only `none` is reserved; `default` is an ordinary, definable stack name that init picks when no --preset-stack is given. - apply_stack() installs entries in listed order; `priority` is the resolver precedence recorded on the install, not an install order. - The non-zero exit claim only holds for `specify preset stack install`; `specify init` treats stack application as best-effort, like --preset. - Documents membership/ID tracking and deferred removals. Co-Authored-By: Claude Opus 5 --- presets/ARCHITECTURE.md | 35 +++++++++++++++++++++++++---------- presets/README.md | 12 +++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index 5055153cb0..89e6c9805e 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -148,8 +148,11 @@ Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). E `.specify/preset-stacks.yml` holds named, ordered lists of preset entries (`preset`, `priority`, optional `source`). `load_stacks_config()` parses and validates the file (unique stack names, -`default`/`none` reserved, no duplicate `preset` within a stack); `apply_stack()` then drives the -same install path as `specify preset add` for each entry, in priority order. +`none` reserved, no duplicate `preset` within a stack); `apply_stack()` then drives the same +install path as `specify preset add` for each entry, in the order the entries are listed. An +entry's `priority` is passed through to the install so the resolver knows its precedence — it does +not reorder installs. `default` is an ordinary, definable stack name; it is only special in that +`select_stack()` picks it when `specify init` runs without `--preset-stack`. ```mermaid flowchart TD @@ -158,24 +161,36 @@ flowchart TD C -- Yes --> D["install_from_directory / install_from_archive"] C -- No --> E["catalog.download_pack(bypass_install_allowed=True)"] E --> D - D --> F["record entry result (success/error)"] + D --> F["record entry result (success/error)\nplus the installed manifest ID"] F --> G{"more entries?"} G -- Yes --> C - G -- No --> H["diff this stack's preset IDs vs\nprior stack-state for this name"] - H --> I["uninstall presets dropped from the stack,\nunless still listed by another applied stack"] - I --> J["write updated stack-state.json"] + G -- No --> H["diff this stack's member IDs vs\nprior stack-state for this name"] + H --> K{"did any entry fail?"} + K -- Yes --> L["defer all uninstalls\n(keep prior IDs in state)"] + K -- No --> I["uninstall presets dropped from the stack,\nunless still listed by another applied stack"] + L --> J["write updated stack-state.json"] + I --> J ``` +Membership in `stack-state.json` follows `stack.entries`, not the outcome of a given run: a +successful entry is tracked under the ID its manifest actually declares (which is what +`PresetManager` installs and removes under), and a failed entry stays a member so a transient +failure never makes a still-listed preset look dropped. Because a failed entry never yields a +manifest ID, a run with any failure also defers uninstalls entirely (`deferred_removals`) rather +than risk removing a working preset; the next clean apply performs them. + Per-entry failures are collected but never abort the run — `apply_stack()` returns a result with -one entry per attempted install plus the list of removed preset IDs; the caller (CLI command or -`specify init`) renders success/failure lines and exits non-zero only if at least one entry failed. +one entry per attempted install, the removed preset IDs, and any deferred ones. `specify preset +stack install ` renders those lines and exits non-zero if any entry failed. `specify init` +renders the same lines but treats stack application as best-effort — failures print warnings and +init still exits zero, matching how `--preset` failures are handled there. `specify init`'s implicit-default behavior and `specify preset stack install ` share this same `apply_stack()` call — resolving which stack to apply (named, implicit `default`, or none) is the only logic that differs between the two entry points. -- **Python**: `load_stacks_config()`, `apply_stack()`, `_resolve_entry_source()` in - `src/specify_cli/presets/stacks.py` +- **Python**: `load_stacks_config()`, `select_stack()`, `apply_stack()`, `_resolve_entry_source()` + in `src/specify_cli/presets/stacks.py` - **CLI**: `specify preset stack list/install/add/remove` in `src/specify_cli/presets/_commands.py`; `--preset-stack` on `specify init` in `src/specify_cli/commands/init.py` diff --git a/presets/README.md b/presets/README.md index adf5560140..1bbe583ac7 100644 --- a/presets/README.md +++ b/presets/README.md @@ -141,8 +141,12 @@ stacks: priority: 10 ``` +Entries are installed in the order they are listed; `priority` is the resolution precedence the +preset is installed with (lower wins), not an install order. + A stack named `default` is applied automatically by `specify init` — no `--preset` or -`--preset-stack` flag needed. `default` and `none` are reserved stack names. +`--preset-stack` flag needed. `default` is otherwise an ordinary stack you define like any other; +only `none` is reserved, since `--preset-stack none` means "apply no stack". ```bash # List every defined stack @@ -171,6 +175,12 @@ explicit `source` (local directory or archive URL); without one, the preset is r normal catalog lookup — and, unlike a bare `specify preset add`, bypasses `install_allowed` for discovery-only catalogs, since listing a preset in a stack is itself the trust decision. +If an entry fails (unreachable source, unresolvable ID), the other entries still install and the +failure is reported per entry. `specify preset stack install` then exits non-zero; `specify init` +prints a warning and continues, the same way a failing `--preset` is handled. A failed run also +skips the uninstall half of the re-sync — presets dropped from the stack stay installed until the +stack applies cleanly, so a transient failure can never uninstall a working preset. + ## Creating a Preset See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset.