Skip to content

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

Open
ogil109 wants to merge 1 commit into
github:mainfrom
ogil109:fix/extension-add-executable-scripts
Open

fix(extensions): make shipped scripts executable after extension add#3723
ogil109 wants to merge 1 commit into
github:mainfrom
ogil109:fix/extension-add-executable-scripts

Conversation

@ogil109

@ogil109 ogil109 commented Jul 24, 2026

Copy link
Copy Markdown

Description

specify extension add (and extension update, and bundle installs) leave shipped POSIX
scripts 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.
Archives are unpacked with zipfile.extractall and directory installs are copied; neither
restores a stripped Unix mode.

Fix at the shared sink, not per-command. 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). Restoring perms there covers
every route — present and future — by construction, rather than patching each call site.

Route Entry method Reaches the sink
extension add --from <zip> install_from_zipinstall_from_directory
extension add <dir> --dev install_from_directory
extension update install_from_zip
bundle install install_from_zip / install_from_directory

Reuses ensure_executable_scripts() as-is. That helper already exists and already
makes .specify scripts executable (init, migrate, and integration-install all call
it); the extension install routes were simply the ones that never did. The sink calls it
plainly, re-establishing the same idempotent "scripts are executable" invariant those
other flows restore.

Design note: the sink calls the helper plainly rather than scoping it to just the installed extension. A scan-scope argument would only spare re-walking a handful of already-correct files — negligible beside the copy/extract the install just performed — while widening a simple, four-caller interface for one caller and pushing a "which roots to scan" decision up to callers the helper should own. The whole-project call is the deliberate choice: simplest interface, sensible default, correct for every caller.

No dead code introduced. Existing callers were audited: init's end-of-init call still
covers core .specify/scripts and is untouched; integration-install / migrate do no
manager install and cover only core scripts. Nothing was removed.

No-op on Windows (helper returns early); best-effort per file; does not change which
files are executable or their mode.

Fixes #3722.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytesttests/test_extensions.py and
    tests/test_presets.py green (765 passed), including the new tests.
  • Tested with a sample project — installed a script-shipping extension from a published
    archive via the real CLI: before → -rw-r--r-- + Permission denied; after →
    -rwxr-xr-x, runs.

New TestExtensionManager::test_install_restores_execute_bit_on_shipped_scripts asserts a
mode-0644 script comes out executable via both install_from_directory and
install_from_zip(force=True) (the latter also exercising the remove-then-reinstall shape
of extension update). Plus the end-to-end extension add --dev test. Both fail without
the change; skipped on Windows.

AI Disclosure

  • I did use AI assistance (describe below)

Fix and tests written with Claude Code (Claude Opus). Found verifying a community extension
of mine, root-caused, and — since it became a cross-cutting change across install routes —
worked the design as a small spec (route inventory + sink-vs-per-route decision + dead-code
audit) before landing it. Verified end-to-end against a CLI built from current main.

@ogil109
ogil109 requested a review from mnriem as a code owner July 24, 2026 16:04
@mnriem
mnriem requested a review from Copilot July 24, 2026 16:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/_commands.py Outdated
Comment on lines +632 to +633
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On it.

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

@ogil109
ogil109 force-pushed the fix/extension-add-executable-scripts branch from c555e4d to 54e4b12 Compare July 24, 2026 22:10
ogil109 added a commit to ogil109/spec-kit that referenced this pull request Jul 24, 2026
… (per review)

Addresses review feedback on github#3723. The first commit restored execute bits only
in `extension_add`, but `extension update` and bundle installs place files through
the same manager methods and would still ship non-executable scripts.

Move the `ensure_executable_scripts()` call out of `extension_add` and into
`ExtensionManager.install_from_directory` — the single sink every install route
funnels through (`install_from_zip` delegates to it; add / update / bundle all
reach it). This covers every route, present and future, by construction rather
than by patching each call site.

Audited the existing callers before adding it: `init`'s end-of-init call also
covers extensions installed during init (now a harmless idempotent re-scan) and
is still needed for core `.specify/scripts`; integration-install and migrate do
no manager install and cover only core scripts. So nothing becomes dead and
nothing is removed.

Adds a manager-level regression test asserting a mode-0644 script ships
executable via both `install_from_directory` and `install_from_zip(force=True)`
(the latter also exercising the remove-then-reinstall shape of `extension
update`). The end-to-end `extension add --dev` test from the first commit stays.
@ogil109
ogil109 force-pushed the fix/extension-add-executable-scripts branch from 54e4b12 to fc3d6b5 Compare July 24, 2026 22:13
@ogil109

ogil109 commented Jul 24, 2026

Copy link
Copy Markdown
Author

Thanks for the review — both points led to a better change. Summary of where it landed.

What it does. Guarantees that a shipped POSIX script inside an installed extension
(.specify/extensions/<id>/scripts/...) is executable after the install, for every
install route — extension add (archive and --dev), extension update, and bundle
installs. Previously it only worked incidentally, after a later specify init, so a bare
extension add into an existing project left the script non-executable and a documented
direct invocation failed with Permission denied.

How it works. All extension file placement funnels through one method,
ExtensionManager.install_from_directory (install_from_zip delegates to it). Restoring
permissions there covers every route by construction, instead of patching each command. It
reuses the existing ensure_executable_scripts() — the same helper init, migrate, and
integration-install use — called plainly, re-establishing the same idempotent "scripts are
executable" invariant those flows restore.

Why this shape.

  • Sink, not per-command: a future install route inherits the guarantee automatically; no
    chance of a fourth entry point silently regressing it.
  • Reuse, not reimplement: no duplicated chmod/shebang logic — one definition of "which
    scripts are executable", unchanged.
  • Simple interface, deliberately: the sink calls the helper plainly rather than scoping
    it to the installed extension. A scan-scope knob would only spare re-walking already-correct
    files — negligible beside the copy/extract just done — while enlarging a simple, four-caller
    interface for one caller. The plain whole-project call is the sensible default and the
    smaller surface.
  • No dead code: the existing callers were audited before adding the sink call — init's
    end-of-init call still covers core .specify/scripts and is untouched; integration and
    migrate do no manager install. Nothing was removed.

Scope guarantees: no-op on Windows, best-effort per file (a permission failure never
fails the install), and it 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 the end-to-end
extension add --dev test. Both fail without the change; skipped on Windows. Full suite
green (test_extensions.py + test_presets.py, 765 passed).

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 github#3722.
@ogil109
ogil109 force-pushed the fix/extension-add-executable-scripts branch from 049f0ea to 67680d4 Compare July 24, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

specify extension add leaves shipped POSIX scripts non-executable

3 participants