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
38 changes: 38 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,44 @@ Installs a preset from the catalog, a URL, or a local directory. Preset commands

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

## Update a Preset

```bash
specify preset update <preset_id> [--from <url>] [--dev <path>] [--priority <N>]
```

Replaces an already-installed preset by running the normal `preset remove`
operation first and then the normal `preset add` operation. `--from`, `--dev`,
and `--priority` are forwarded to `preset add`; `--from` and `--dev` cannot be
combined. Without an explicit source, add resolves the preset through its usual
bundled and catalog lookup.

Update is deliberately destructive. Arguments are checked before anything is
removed: `--from` and `--dev` cannot be combined, `--priority` must be 1 or
higher, and the preset must already be installed. Beyond those checks the
replacement source itself is not inspected in advance. If removal succeeds but
replacement installation fails, the previous preset has already been removed.
The command reports a copy-pastable `specify preset add` retry command,
including the replacement source and priority. On Windows, the reported command
is explicitly formatted for PowerShell. There is no source pre-flight,
version comparison, manifest diff, staging, rollback, automatic repair, or
recovery transaction. A missing or invalid replacement source can therefore
leave the preset removed. With
`--from` or `--dev`, the replacement manifest's `preset.id` is not checked
against the requested ID before removal; a source declaring a different ID may
therefore install a different preset after the requested one has been removed.

A successful update follows normal remove and add behavior: it re-enables the
preset, recreates `installed_at`, removes local modifications tracked by the
preset, and treats an explicit `--from` or `--dev` as an intentional source
change. If constitution synchronization is enabled, both normal reconciliation
passes run. If add fails after removal, a generated constitution may remain
reconciled against the stack without the removed preset. The generated-file
guard still protects a hand-edited `.specify/memory/constitution.md`. No
update-specific constitution optimization is applied, so the normal remove and
add passes may rewrite the generated constitution even when the final resolved
content is unchanged.

## Remove a Preset

```bash
Expand Down
138 changes: 132 additions & 6 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import os
import re
import shlex
from pathlib import Path

import typer
Expand Down Expand Up @@ -47,6 +48,42 @@
preset_app.add_typer(preset_catalog_app, name="catalog")


#: Lowest priority a user may request. Lower numbers win resolution, so the
#: stack is anchored at 1 rather than 0 to leave no unreachable slot above the
#: highest-precedence preset.
MINIMUM_PRESET_PRIORITY = 1


def _render_powershell_argv(argv: list[str]) -> str:
"""Render argv as a copy-pastable PowerShell command.

PowerShell single-quoted strings are literal except that an embedded single
quote is escaped by doubling it. The call operator is required because the
executable name is quoted too.
"""
def quote_arg(arg: str) -> str:
return "'" + arg.replace("'", "''") + "'"

return "& " + " ".join(quote_arg(arg) for arg in argv)


def _validate_priority(priority: int) -> None:
"""Reject a non-positive priority before any destructive work begins.

Shared by add, set-priority, and update so the three commands cannot drift
apart on the accepted range or the message they print. update in particular
must call this *before* removing the installed preset: validating only
inside add would leave the preset removed and print a retry command
carrying the same rejected priority.
"""
if priority < MINIMUM_PRESET_PRIORITY:
console.print(
"[red]Error:[/red] Priority must be a positive integer "
f"({MINIMUM_PRESET_PRIORITY} or higher)"
)
raise typer.Exit(1)


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

Expand Down Expand Up @@ -240,9 +277,7 @@ def preset_add(

project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
_validate_priority(priority)

manager = PresetManager(project_root)
speckit_version = get_speckit_version()
Expand Down Expand Up @@ -454,6 +489,99 @@ def preset_remove(
raise typer.Exit(1)


@preset_app.command("update")
def preset_update(
preset_id: str = typer.Argument(..., help="Installed preset ID to replace"),
from_url: str = typer.Option(
None,
"--from",
help="Install the replacement from a .zip, .tar.gz, or .tgz URL",
),
dev: str = typer.Option(
None,
"--dev",
help="Install the replacement from a local directory (development mode)",
),
priority: int = typer.Option(
10,
"--priority",
help="Resolution priority for the replacement (default 10)",
),
):
"""Replace an installed preset using the normal remove and add flows."""
from .. import _require_specify_project
from . import PresetManager

if from_url is not None and dev is not None:
console.print("[red]Error:[/red] --from and --dev are mutually exclusive")
raise typer.Exit(1)
if from_url == "":
console.print("[red]Error:[/red] --from must not be empty")
raise typer.Exit(1)
if dev == "":
console.print("[red]Error:[/red] --dev must not be empty")
raise typer.Exit(1)

# Validate priority before removal. add rejects the same range, but only
# after remove has already run, which would leave the preset removed and
# the printed retry command carrying the rejected priority.
_validate_priority(priority)

project_root = _require_specify_project()
manager = PresetManager(project_root)
if not manager.registry.is_installed(preset_id):
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
raise typer.Exit(1)

# Keep update deliberately destructive: remove performs its complete normal
# reconciliation before add resolves and installs the replacement.
preset_remove(preset_id)

retry_args = ["specify", "preset", "add"]
retry_options = []
if from_url is not None:
retry_options.extend(["--from", from_url])
if dev is not None:
retry_options.extend(["--dev", dev])
retry_options.extend(["--priority", str(priority)])
if preset_id.startswith("-"):
retry_args.extend([*retry_options, "--", preset_id])
else:
retry_args.extend([preset_id, *retry_options])

def report_add_failure() -> None:
if os.name == "nt":
retry_label = "Retry in PowerShell: "
rendered_args = _render_powershell_argv(retry_args)
else:
retry_label = "Retry with: "
rendered_args = shlex.join(retry_args)
console.print(
"[red]Error:[/red] Preset update failed; the previous preset was removed."
)
console.print(
f"{retry_label}[cyan]"
f"{_escape_markup(rendered_args)}"
"[/cyan]",
soft_wrap=True,
)

try:
preset_add(
preset_id=preset_id,
from_url=from_url,
dev=dev,
priority=priority,
)
except typer.Exit as error:
report_add_failure()
raise typer.Exit(error.exit_code or 1)
except Exception as error:
console.print(f"[red]Error:[/red] {_escape_markup(str(error))}")
report_add_failure()
raise typer.Exit(1)


@preset_app.command("search")
def preset_search(
query: str = typer.Argument(None, help="Search query"),
Expand Down Expand Up @@ -698,9 +826,7 @@ def preset_set_priority(

project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
_validate_priority(priority)

manager = PresetManager(project_root)

Expand Down
Loading