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 preserv
(
project_path: Path,
script_type: str,
*,
version: str,
core_pack: Path | None,
repo_root: Path,
console: Any,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
)
| 351 | |
| 352 | |
| 353 | def install_shared_infra( |
| 354 | project_path: Path, |
| 355 | script_type: str, |
| 356 | *, |
| 357 | version: str, |
| 358 | core_pack: Path | None, |
| 359 | repo_root: Path, |
| 360 | console: Any, |
| 361 | force: bool = False, |
| 362 | invoke_separator: str = ".", |
| 363 | refresh_managed: bool = False, |
| 364 | refresh_hint: str | None = None, |
| 365 | ) -> bool: |
| 366 | """Install shared scripts and templates into *project_path*. |
| 367 | |
| 368 | When ``refresh_managed`` is True, files whose on-disk hash still matches |
| 369 | the previously recorded manifest hash are overwritten with the bundled |
| 370 | version. Files whose hash diverges are treated as user customizations and |
| 371 | preserved with a warning. ``force=True`` overwrites every regular file |
| 372 | (symlinks and symlinked-parent destinations are always preserved with a |
| 373 | warning — the safe-destination check refuses to follow them so writes |
| 374 | cannot escape the project root). ``refresh_hint`` is shown after the |
| 375 | customization warning to tell the user which flag would overwrite their |
| 376 | customizations. |
| 377 | """ |
| 378 | from .integrations.manifest import _sha256, _validate_rel_path |
| 379 | |
| 380 | manifest = load_speckit_manifest(project_path, version=version, console=console) |
| 381 | prior_hashes = dict(manifest.files) |
| 382 | |
| 383 | def _is_managed(rel: str, dst: Path) -> bool: |
| 384 | expected = prior_hashes.get(rel) |
| 385 | if not expected or not dst.is_file() or dst.is_symlink(): |
| 386 | return False |
| 387 | if manifest.is_recovered(rel): |
| 388 | return False |
| 389 | try: |
| 390 | return _sha256(dst) == expected |
| 391 | except OSError: |
| 392 | return False |
| 393 | |
| 394 | skipped_files: list[str] = [] |
| 395 | preserved_user_files: list[str] = [] |
| 396 | symlinked_files: list[str] = [] |
| 397 | planned_copies: list[tuple[Path, str, bytes, int]] = [] |
| 398 | planned_templates: list[tuple[Path, str, str]] = [] |
| 399 | # Track every shared path the current bundle produces so we can detect |
| 400 | # manifest entries the core no longer ships (stale-script cleanup, #3076). |
| 401 | seen_rels: set[str] = set() |
| 402 | scripts_scanned = False |
| 403 | variant_dir = "bash" if script_type == "sh" else "powershell" |
| 404 | |
| 405 | def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: |
| 406 | """Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" |
| 407 | if not dst.exists(): |
| 408 | return True, None |
| 409 | if force: |
| 410 | return True, None |