diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ccd670d20e..94376c0cf0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -765,6 +765,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*. @@ -776,9 +778,23 @@ 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. """ @@ -791,6 +807,8 @@ def _install_shared_infra( console=console, force=force, invoke_separator=invoke_separator, + refresh_managed=refresh_managed, + refresh_hint=refresh_hint, ) @@ -800,6 +818,8 @@ def _install_shared_infra_or_exit( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + refresh_managed: bool = False, + refresh_hint: str | None = None, ) -> bool: try: return _install_shared_infra( @@ -808,6 +828,8 @@ def _install_shared_infra_or_exit( tracker=tracker, force=force, invoke_separator=invoke_separator, + refresh_managed=refresh_managed, + refresh_hint=refresh_hint, ) except (ValueError, OSError) as exc: console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}") @@ -2578,7 +2600,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'), ): """Switch from the current integration to a different one.""" @@ -2749,14 +2772,27 @@ def integration_switch( target_integration, current, target, integration_options ) - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). + # 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_or_exit( project_root, selected_script, + force=refresh_shared_infra, + refresh_managed=True, invoke_separator=_invoke_separator_for_integration( target_integration, current, target, parsed_options ), + refresh_hint=( + "To overwrite customizations, re-run with " + "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." + ), ) if os.name != "nt": ensure_executable_scripts(project_root) diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 1e8be7b282..93e28fae43 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -242,13 +242,51 @@ def install_shared_infra( console: Any, force: bool = False, invoke_separator: str = ".", + refresh_managed: bool = False, + refresh_hint: str | None = None, ) -> bool: - """Install shared scripts and templates into *project_path*.""" + """Install shared scripts and templates into *project_path*. + + When ``refresh_managed`` is True, files whose on-disk hash still matches + the previously recorded manifest hash are overwritten with the bundled + version. Files whose hash diverges are treated as user customizations and + preserved with a warning. ``force=True`` overwrites everything regardless. + ``refresh_hint`` is shown after the customization warning to tell the user + which flag would overwrite their customizations. + """ + from .integrations.manifest import _sha256 + manifest = load_speckit_manifest(project_path, version=version, console=console) + prior_hashes = dict(manifest.files) + + def _is_managed(rel: str, dst: Path) -> bool: + expected = prior_hashes.get(rel) + if not expected or not dst.is_file() or dst.is_symlink(): + return False + try: + return _sha256(dst) == expected + except OSError: + return False + skipped_files: list[str] = [] + preserved_user_files: list[str] = [] planned_copies: list[tuple[Path, str, bytes, int]] = [] planned_templates: list[tuple[Path, str, str]] = [] + def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: + """Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" + if not dst.exists(): + return True, None + if force: + return True, None + if refresh_managed: + if _is_managed(rel, dst): + return True, None + if rel in prior_hashes: + return False, "preserved" + return False, "skip" + return False, "skip" + scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root) if scripts_src.is_dir(): dest_scripts = project_path / ".specify" / "scripts" @@ -265,12 +303,16 @@ def install_shared_infra( rel_path = src_path.relative_to(variant_src) dst_path = dest_variant / rel_path _ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False) - if dst_path.exists() and not force: - skipped_files.append(dst_path.relative_to(project_path).as_posix()) + rel = dst_path.relative_to(project_path).as_posix() + write, bucket = _decide_overwrite(rel, dst_path) + if not write: + if bucket == "preserved": + preserved_user_files.append(rel) + else: + skipped_files.append(rel) continue _ensure_safe_shared_directory(project_path, dst_path.parent) - rel = dst_path.relative_to(project_path).as_posix() planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777)) templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) @@ -283,13 +325,17 @@ def install_shared_infra( dst = dest_templates / src.name _ensure_safe_shared_destination(project_path, dst) - if dst.exists() and not force: - skipped_files.append(dst.relative_to(project_path).as_posix()) + rel = dst.relative_to(project_path).as_posix() + write, bucket = _decide_overwrite(rel, dst) + if not write: + if bucket == "preserved": + preserved_user_files.append(rel) + else: + skipped_files.append(rel) continue content = src.read_text(encoding="utf-8") content = IntegrationBase.resolve_command_refs(content, invoke_separator) - rel = dst.relative_to(project_path).as_posix() planned_templates.append((dst, rel, content)) for dst_path, rel, content, mode in planned_copies: @@ -307,11 +353,24 @@ def install_shared_infra( ) for path in skipped_files: console.print(f" {path}") + 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( - "To refresh shared infrastructure, run " - "[cyan]specify init --here --force[/cyan] or " - "[cyan]specify integration upgrade --force[/cyan]." + f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared " + "infrastructure file(s) (hash differs from previous install):" ) + for path in preserved_user_files: + console.print(f" {path}") + if refresh_hint: + console.print(refresh_hint) manifest.save() return True diff --git a/templates/constitution-template.md b/templates/constitution-template.md index a4670ff469..0dfcca066a 100644 --- a/templates/constitution-template.md +++ b/templates/constitution-template.md @@ -1,3 +1,5 @@ + + # [PROJECT_NAME] Constitution diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 750bbb6efa..5163f98db1 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -901,6 +901,152 @@ def test_switch_preserves_shared_infra(self, tmp_path): assert shared_script.exists() assert shared_script.read_text(encoding="utf-8") == shared_content + def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): + """Regression for #2293: stale managed shared scripts get refreshed on switch.""" + import hashlib + + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + bundled_bytes = shared_script.read_bytes() + + # Simulate a stale vendored script: write truncated content as bytes + # (write_text would translate \n→\r\n on Windows and break the hash) + # and update the speckit manifest hash so the stale copy is treated + # as "managed" (installed by spec-kit, not a user customization). + stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n" + shared_script.write_bytes(stale_bytes) + + manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"][".specify/scripts/bash/common.sh"] = ( + hashlib.sha256(stale_bytes).hexdigest() + ) + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Stale managed file should be replaced by the bundled version + assert shared_script.read_bytes() == bundled_bytes + + def test_switch_preserves_user_customized_shared_infra(self, tmp_path): + """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + + # User customization: append bytes but do NOT update manifest hash, + # so on-disk hash diverges from the recorded one. + original = shared_script.read_bytes() + custom_bytes = original + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert shared_script.read_bytes() == custom_bytes + assert "Preserved" in result.output + + def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): + """--refresh-shared-infra explicitly overwrites user customizations on switch.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + bundled_bytes = shared_script.read_bytes() + + # User customization (hash diverges from manifest) + custom_bytes = bundled_bytes + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + "--refresh-shared-infra", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Customization is overwritten with the bundled version + assert shared_script.read_bytes() == bundled_bytes + + def test_switch_skips_symlinked_parent_directory(self, tmp_path): + """Regression: if .specify/scripts/bash is a symlink, switch must not write through it. + + Copilot follow-up on #2375: leaf-only symlink check let writes escape + when an *ancestor* directory was symlinked outside the project root. + """ + import sys + if sys.platform.startswith("win"): + import pytest as _pytest + _pytest.skip("Symlink creation typically requires admin on Windows") + + project = _init_project(tmp_path, "claude") + bash_dir = project / ".specify" / "scripts" / "bash" + outside = tmp_path / "outside" + outside.mkdir() + for child in bash_dir.iterdir(): + child.rename(outside / child.name) + bash_dir.rmdir() + bash_dir.symlink_to(outside, target_is_directory=True) + sentinel = (outside / "common.sh").read_bytes() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Symlinked tree reported, not written through. + assert "symlink" in result.output.lower() + # Outside dir contents unchanged. + assert (outside / "common.sh").read_bytes() == sentinel + + def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path): + """--force (uninstall semantics) must NOT overwrite shared-infra customizations. + + Regression: ensures the decoupling of --force and --refresh-shared-infra. + """ + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + bundled_bytes = shared_script.read_bytes() + + custom_bytes = bundled_bytes + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # --force alone preserves the customization + assert shared_script.read_bytes() == custom_bytes + def test_switch_from_nothing(self, tmp_path): """Switch when no integration is installed should just install the target.""" project = tmp_path / "bare"