Skip to content
Open
282 changes: 242 additions & 40 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,8 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared infrastructure files into *project_path*.

Expand All @@ -735,17 +737,115 @@ def _install_shared_infra(
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).

When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Overwrite policy:

* ``force=True`` — overwrite every existing file (still skips symlinks
to avoid following links outside the project root).
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
still matches the previously recorded manifest hash (i.e. unmodified
files installed by spec-kit). Files with diverging hashes are
treated as user customizations and preserved with a warning.
* Default — only add missing files; existing ones are skipped.

*refresh_hint* — caller-supplied rich-text fragment shown after the
"Preserved customized files" warning to tell the user which flag/command
they should re-run with to overwrite their customizations. Each caller
passes the flag that's actually valid in its CLI surface (e.g.
``--refresh-shared-infra`` for ``integration switch``,
``--force`` for ``init``/``integration upgrade``). When ``None``, no
remediation hint is printed for customizations.

Returns ``True`` on success.
"""
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest

core = _locate_core_pack()

# Load prior speckit manifest (if any) so we can both:
# 1. Detect files that are still unmodified relative to the previous
# install (refresh_managed mode).
# 2. Preserve hash entries for files we don't rewrite this run, so
# future refreshes can still tell managed-vs-customized apart.
prior_hashes: dict[str, str] = {}
try:
prior = IntegrationManifest.load("speckit", project_path)
prior_hashes = dict(prior.files)
except (FileNotFoundError, ValueError):
prior_hashes = {}

manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version())
# Seed with prior hashes; record_existing() will overwrite entries for
# files we actually touch this run. Untouched files keep their previous
# hash so we don't lose tracking. (`files` is a read-only property — write
# the backing dict directly.)
manifest._files = dict(prior_hashes)

def _is_managed(rel_posix: str, abs_path: Path) -> bool:
"""True when the file's current hash matches the recorded one.

Symlinks are never treated as managed — they may point outside the
project root, and following them to overwrite is unsafe.
"""
if abs_path.is_symlink():
return False
expected = prior_hashes.get(rel_posix)
if not expected:
return False
try:
from .integrations.manifest import _sha256
return _sha256(abs_path) == expected
except OSError:
return False
Comment thread
mnriem marked this conversation as resolved.

preserved_user_files: list[str] = []
symlinked_files: list[str] = []

# Resolve the project root once for safe-path comparisons. We refuse to
# write to any destination whose resolved absolute path is not contained
# under this root, or whose parent chain contains any symlink — either
# condition could cause shutil.copy2 / write_text to escape the project.
try:
safe_root = project_path.resolve(strict=False)
except OSError:
safe_root = project_path

def _is_safe_dest(dst: Path) -> bool:
"""Return True iff dst is inside project root with no symlinks in its path.

Checks two things:
1. dst.resolve() is contained under safe_root (catches a symlinked
parent like ``.specify/scripts -> /etc``).
2. No component on the path from project_path to dst.parent is itself
a symlink (defence in depth — covers cases where resolve() can't
be performed because the parent doesn't exist yet but a higher
ancestor is symlinked).
"""
try:
resolved = dst.resolve(strict=False)
except OSError:
return False
try:
resolved.relative_to(safe_root)
except ValueError:
return False
# Walk every existing ancestor of dst that lives under project_path
# and reject if any is a symlink.
cursor = dst.parent
while True:
try:
cursor.relative_to(project_path)
except ValueError:
break
if cursor.is_symlink():
return False
if cursor == project_path:
break
parent = cursor.parent
if parent == cursor:
break
cursor = parent
return True

# Scripts
if core and (core / "scripts").is_dir():
Expand All @@ -756,25 +856,65 @@ def _install_shared_infra(

skipped_files: list[str] = []

def _safe_mkdir(dst_dir: Path, rel_label: str) -> bool:
"""Create dst_dir only if it stays within the project root.

Returns False (and records dst_dir under symlinked_files) if dst_dir,
or any of its ancestors below project_path, is a symlink — in which
case mkdir(parents=True) would create or write outside the project.
"""
if dst_dir.is_symlink() or not _is_safe_dest(dst_dir):
try:
rel = dst_dir.relative_to(project_path).as_posix()
except ValueError:
rel = rel_label
symlinked_files.append(rel)
return False
dst_dir.mkdir(parents=True, exist_ok=True)
return True

if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
dest_scripts.mkdir(parents=True, exist_ok=True)
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
dest_variant.mkdir(parents=True, exist_ok=True)
for src_path in variant_src.rglob("*"):
if src_path.is_file():
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
if dst_path.exists() and not force:
skipped_files.append(str(dst_path.relative_to(project_path)))
else:
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
rel = dst_path.relative_to(project_path).as_posix()
manifest.record_existing(rel)
if _safe_mkdir(dest_scripts, ".specify/scripts"):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
if _safe_mkdir(dest_variant, f".specify/scripts/{variant_dir}"):
for src_path in variant_src.rglob("*"):
if src_path.is_file():
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel_posix = dst_path.relative_to(project_path).as_posix()
# Refuse to write through a symlink at the leaf or
# via a symlinked ancestor (e.g. .specify/scripts
# -> /etc). Both cases could let shutil.copy2
# escape the project root. Symlinks need manual
# intervention — no flag will resolve them — so
# track them separately.
if dst_path.is_symlink() or not _is_safe_dest(dst_path):
symlinked_files.append(rel_posix)
continue
should_write = (
not dst_path.exists()
or force
or (refresh_managed and _is_managed(rel_posix, dst_path))
)
if not should_write:
if refresh_managed and dst_path.exists() and rel_posix in prior_hashes:
preserved_user_files.append(rel_posix)
else:
skipped_files.append(str(dst_path.relative_to(project_path)))
# Re-record existing managed bundled files so
# future refresh_managed runs can still match
# them by hash.
if refresh_managed and dst_path.exists():
manifest.record_existing(rel_posix)
else:
if not _safe_mkdir(dst_path.parent, str(dst_path.parent.relative_to(project_path))):
continue
shutil.copy2(src_path, dst_path)
manifest.record_existing(rel_posix)

# Page templates (not command templates, not vscode-settings.json)
if core and (core / "templates").is_dir():
Expand All @@ -785,31 +925,75 @@ def _install_shared_infra(

if templates_src.is_dir():
dest_templates = project_path / ".specify" / "templates"
dest_templates.mkdir(parents=True, exist_ok=True)
for f in templates_src.iterdir():
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
dst = dest_templates / f.name
if dst.exists() and not force:
skipped_files.append(str(dst.relative_to(project_path)))
else:
content = f.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(
content, invoke_separator
if _safe_mkdir(dest_templates, ".specify/templates"):
for f in templates_src.iterdir():
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
dst = dest_templates / f.name
rel_posix = dst.relative_to(project_path).as_posix()
# Same defence as the scripts loop: refuse symlinks at
# the leaf or anywhere in the parent chain.
if dst.is_symlink() or not _is_safe_dest(dst):
symlinked_files.append(rel_posix)
continue
should_write = (
not dst.exists()
or force
or (refresh_managed and _is_managed(rel_posix, dst))
)
dst.write_text(content, encoding="utf-8")
rel = dst.relative_to(project_path).as_posix()
manifest.record_existing(rel)
if not should_write:
if refresh_managed and dst.exists() and rel_posix in prior_hashes:
preserved_user_files.append(rel_posix)
else:
skipped_files.append(str(dst.relative_to(project_path)))
if refresh_managed and dst.exists():
manifest.record_existing(rel_posix)
else:
content = f.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(
content, invoke_separator
)
dst.write_text(content, encoding="utf-8")
manifest.record_existing(rel_posix)

if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
)
for f in skipped_files:
console.print(f" {f}")
# Prefer the caller-supplied hint (e.g. --refresh-shared-infra for
# `integration switch`); fall back to the generic init/upgrade
# remediation when no hint was provided.
if refresh_hint:
console.print(refresh_hint)
else:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)

if preserved_user_files:
console.print(
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
"infrastructure file(s) (hash differs from previous install):"
)
for f in preserved_user_files:
console.print(f" {f}")
if refresh_hint:
console.print(refresh_hint)

if symlinked_files:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
"infrastructure file(s) — symlinks are never overwritten because they "
"may resolve outside the project root:"
)
for f in symlinked_files:
console.print(f" {f}")
console.print(
"To restore the bundled version, remove or replace the symlink manually, "
"then re-run the command."
)
Comment thread
Quratulain-bilal marked this conversation as resolved.

manifest.save()
Expand Down Expand Up @@ -2291,7 +2475,8 @@ def integration_uninstall(
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
Comment thread
mnriem marked this conversation as resolved.
):
"""Switch from the current integration to a different one."""
Expand Down Expand Up @@ -2381,9 +2566,26 @@ def integration_switch(
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)

# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
# supported-agent list) would silently break the new integration.
#
# Use refresh_managed=True so only files that match their previously
# recorded hash are overwritten — user customizations are detected via
# hash divergence and preserved with a warning. Pass
# --refresh-shared-infra to overwrite customizations as well. See #2293.
_install_shared_infra(
project_root,
selected_script,
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=target_integration.effective_invoke_separator(parsed_options),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
),
)
Comment thread
Quratulain-bilal marked this conversation as resolved.
if os.name != "nt":
ensure_executable_scripts(project_root)

Expand Down
Loading