From f70bdf94fa1be2fbc8984d821e7b583a4188d00d Mon Sep 17 00:00:00 2001 From: rayhem Date: Sat, 6 Jun 2026 12:24:36 -0400 Subject: [PATCH] fix: ensure installed files are owner-writable regardless of source permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shutil.copy2 and copytree propagate permission bits from the source, leaving destination files at 0o444 and dirs at 0o555 when copying from any read-only source (Nix store, read-only mounts, etc.). Subsequent writes to .specify/ then fail with PermissionError. Replace copy2 with copyfile (content-only, no permission bits) at all four install-path call sites. Add ensure_writable_tree() to fix directory permissions after copytree calls — copytree always stamps dest dirs via copystat() regardless of copy_function. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/_utils.py | 22 ++++++++++++++++++++-- src/specify_cli/commands/init.py | 2 +- src/specify_cli/extensions.py | 4 +++- src/specify_cli/presets.py | 4 +++- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index beae253593..d009f25866 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -183,13 +183,13 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: else: log("Skipped merge (preserved existing settings)", "yellow") else: - shutil.copy2(sub_item, dest_file) + shutil.copyfile(sub_item, dest_file) log("Copied (no existing settings.json):", "blue") except Exception as e: log(f"Warning: Could not merge settings: {e}", "yellow") if not dest_file.exists(): - shutil.copy2(sub_item, dest_file) + shutil.copyfile(sub_item, dest_file) def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None: @@ -272,6 +272,24 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, return merged +def ensure_writable_tree(path: Path) -> None: + """Add owner write+execute to every directory under path. + + shutil.copytree always calls copystat() on directories, propagating + read-only bits from any read-only source. Call this after copytree to + guarantee destination directories accept writes regardless of source + permissions. + """ + if os.name == "nt": + return + for dirpath, _, _ in os.walk(path): + dp = Path(dirpath) + try: + dp.chmod(dp.stat().st_mode | 0o300) + except OSError: + pass + + def _display_project_path(project_root: Path, path: str | Path) -> str: """Return a stable POSIX-style display path for paths under a project.""" path_obj = Path(path) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 68f5bed31f..4a606ab970 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -430,7 +430,7 @@ def init( import shutil as _shutil dest_wf = project_path / ".specify" / "workflows" / "speckit" dest_wf.mkdir(parents=True, exist_ok=True) - _shutil.copy2( + _shutil.copyfile( bundled_wf / "workflow.yml", dest_wf / "workflow.yml", ) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index bddf637cbc..a9ce528709 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -27,6 +27,7 @@ from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase from ._init_options import is_ai_skills_enabled +from ._utils import ensure_writable_tree _FALLBACK_CORE_COMMAND_NAMES = frozenset({ "analyze", @@ -1280,7 +1281,8 @@ def install_from_directory( shutil.rmtree(dest_dir) ignore_fn = self._load_extensionignore(source_dir) - shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn, copy_function=shutil.copyfile) + ensure_writable_tree(dest_dir) # Register commands with AI agents registered_commands = {} diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 47b4240d85..4c93651c79 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -30,6 +30,7 @@ from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .integrations.base import IntegrationBase from ._init_options import is_ai_skills_enabled +from ._utils import ensure_writable_tree def _substitute_core_template( @@ -1534,7 +1535,8 @@ def install_from_directory( if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + shutil.copytree(source_dir, dest_dir, copy_function=shutil.copyfile) + ensure_writable_tree(dest_dir) # Pre-register the preset so that composition resolution can see it # in the priority stack when resolving composed command content.