fix(extensions): make shipped scripts executable after install (#3723)

Extension archives are unpacked with zipfile.extractall and directory installs
are copied; neither restores a stripped Unix mode. A bundled *.sh therefore
lands non-executable, so a documented `.specify/extensions/<id>/scripts/bash/foo.sh`
invocation fails with "Permission denied" — e.g. a CI step that runs an
extension's gate. It only worked incidentally, after a later `specify init`.

Restore permissions at the shared sink. Every extension install route funnels
through ExtensionManager.install_from_directory (install_from_zip delegates to
it; extension add, extension update, and bundle installs all reach it), so
calling the existing ensure_executable_scripts() there covers every route —
present and future — by construction rather than by patching each command.

The helper already makes .specify scripts executable (init, migrate, and
integration-install all call it); it is called plainly, re-establishing the same
idempotent "scripts are executable" invariant those flows restore. Deliberately
the whole-project call rather than a scoped one: a scan-scope argument would only
spare re-walking already-correct files — negligible beside the copy/extract just
performed — while widening a simple, widely-used interface for a single caller.
Existing callers were audited: init's end-of-init call still covers core
.specify/scripts and is untouched; integration-install and migrate do no manager
install. Nothing is removed. No-op on Windows; best-effort per file; does not
change which files are executable or their mode.

Tests: a manager-level regression test asserts a mode-0644 script comes out
executable via both install_from_directory and install_from_zip(force=True) (the
latter also covering the remove-then-reinstall shape of extension update), plus
an end-to-end `extension add --dev` test. Both fail without the change; skipped
on Windows.

Fixes #3722.
This commit is contained in:
Oscar
2026-07-27 14:40:43 +02:00
committed by GitHub
parent 446ee329b1
commit 2fb94e0f9c
2 changed files with 108 additions and 0 deletions

View File

@@ -2021,6 +2021,24 @@ class ExtensionManager:
except OSError:
pass # Best-effort; install already committed to the registry.
# Restore execute bits on shipped POSIX scripts. copytree here (and the
# zipfile.extractall in install_from_zip, which delegates to this method) does
# not restore a stripped Unix mode, so a bundled *.sh would land non-executable
# and a documented `.specify/extensions/<id>/scripts/...` invocation would fail
# with "Permission denied". This is the single sink every install route funnels
# through (extension add / update / bundle), so fixing it here covers them all.
# No-op on Windows (helper returns early).
#
# Deliberately the whole-project call, not a scoped one. The helper's contract
# is "make .specify scripts executable" — the same idempotent invariant that
# init and migrate restore — so re-establishing it after an install is exactly
# its job. A scoped variant would only spare re-walking already-correct files,
# a handful of stats that are negligible beside the copy/extract this method just
# did, and it would cost a per-caller scan-scope argument on an otherwise simple,
# widely-used interface. The simpler call wins.
from .. import ensure_executable_scripts
ensure_executable_scripts(self.project_root)
return manifest
def install_from_zip(