Skip to content

Commit 8ea2367

Browse files
Jakub Baranowskiclaude
andcommitted
fix(extensions): install bundled extension updates from the local package
Bundled extensions (agent-context, git, assess) have no download URL, so `specify extension update` could offer a version bump it then failed to install: step 5 unconditionally called catalog.download_extension(), which errors out for catalog entries without a URL (#4345). Resolve the update source for bundled extensions from the copy shipped with the running spec-kit release instead: - `_bundled_update_source()` locates the local bundled copy and parses its manifest version. - `_archive_extension_directory()` packages that copy as a ZIP so the update flows through the identical hardened archive pipeline (bounded extraction, manifest preflight, ID/version checks, backup/rollback) rather than growing a second install path. Symlinks are never followed into the archive. - When the local copy lags the catalog (or is missing), the update is blocked with an explicit "upgrade spec-kit, then rerun" message instead of installing an intermediate version or crashing; when the local copy is newer than the catalog, it installs the local version. Tests pin the install-from-local-copy route, every blocked-update branch, the newer-local-copy case, archive content/symlink behavior, and execute-bit restoration through the archive install route (POSIX-only; install_from_directory's trailing ensure_executable_scripts() re-establishes modes that ZIP extraction drops). Part 1 of the series requested in review on #4351; refs #4345. Assisted-by: Claude Code (model: claude-fable-5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ea5b856 commit 8ea2367

4 files changed

Lines changed: 446 additions & 6 deletions

File tree

docs/reference/extensions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ specify extension update [<name>]
7575

7676
Updates a specific extension, or all installed extensions if no name is given.
7777

78+
Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first.
79+
7880
## Enable / Disable an Extension
7981

8082
```bash

src/specify_cli/extensions/_commands.py

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
import stat
1616
import tempfile
1717
from pathlib import Path
18-
from typing import Optional
18+
from typing import Optional, TYPE_CHECKING
1919
from uuid import uuid4
2020

21+
if TYPE_CHECKING:
22+
from packaging.version import Version
23+
2124
import typer
2225
import yaml
2326
from rich.markup import escape as _escape_markup
@@ -106,6 +109,58 @@ def _command_safe_id(raw_id: object, placeholder: str = "<extension-id>") -> str
106109
return placeholder
107110

108111

112+
def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]:
113+
"""Locate the local bundled copy of *ext_id* and its parsed version.
114+
115+
Bundled extensions have no download URL, so an update can only come
116+
from the copy shipped with the running spec-kit release — which may
117+
lag the version the catalog on main advertises. Returns
118+
``(path, Version)`` when a valid local copy exists, ``(None, None)``
119+
otherwise.
120+
"""
121+
from . import ExtensionManifest, ValidationError
122+
from packaging import version as pkg_version
123+
124+
bundled_dir = _locate_bundled_extension(ext_id)
125+
if bundled_dir is None:
126+
return None, None
127+
try:
128+
manifest = ExtensionManifest(bundled_dir / "extension.yml")
129+
return bundled_dir, pkg_version.Version(manifest.version)
130+
except (ValidationError, pkg_version.InvalidVersion, OSError):
131+
return None, None
132+
133+
134+
def _archive_extension_directory(source_dir: Path) -> Path:
135+
"""Package an extension directory as a ZIP archive for the update flow.
136+
137+
The update pipeline validates and installs archives (bounded
138+
extraction, manifest preflight, ID/version checks, backup/rollback),
139+
so a locally bundled extension is fed through that identical hardened
140+
path rather than growing a second install code path. The caller
141+
deletes the archive after the update, the same as a downloaded one.
142+
"""
143+
import zipfile
144+
145+
fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip")
146+
try:
147+
with os.fdopen(fd, "wb") as archive_file:
148+
with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf:
149+
for path in sorted(source_dir.rglob("*")):
150+
# Never follow symlinks: is_file() follows the target
151+
# and ZipFile.write() reads its bytes, which would turn
152+
# an out-of-tree target into a regular archive member
153+
# before the hardened extractor ever sees it.
154+
if path.is_symlink():
155+
continue
156+
if path.is_file():
157+
zf.write(path, path.relative_to(source_dir).as_posix())
158+
except BaseException:
159+
Path(tmp_name).unlink(missing_ok=True)
160+
raise
161+
return Path(tmp_name)
162+
163+
109164
def _refresh_events_and_warn(project_root: Path) -> None:
110165
"""Refresh native event config and surface failures (R3).
111166
@@ -1622,6 +1677,7 @@ def extension_update(
16221677
console.print("🔄 Checking for updates...\n")
16231678

16241679
updates_available = []
1680+
blocked_updates = []
16251681

16261682
for ext_id in extensions_to_update:
16271683
safe_ext_id = _escape_markup(str(ext_id))
@@ -1658,20 +1714,55 @@ def extension_update(
16581714
continue
16591715

16601716
if catalog_version > installed_version:
1717+
download_url = ext_info.get("download_url")
1718+
bundled_dir = None
1719+
available_version = catalog_version
1720+
if ext_info.get("bundled") and not download_url:
1721+
# Bundled extensions cannot be downloaded; the update has
1722+
# to come from the copy shipped with the running spec-kit
1723+
# release, which may lag the catalog on main (#4345).
1724+
bundled_dir, bundled_version = _bundled_update_source(ext_id)
1725+
# Block whenever the local copy lags the catalog, not
1726+
# just when it lags the installation: installing an
1727+
# intermediate version would leave the project behind
1728+
# the catalog while reporting success, contrary to the
1729+
# documented "upgrade spec-kit first" behavior.
1730+
if bundled_dir is None or bundled_version < catalog_version:
1731+
local_desc = (
1732+
f"only ships v{bundled_version}"
1733+
if bundled_dir is not None
1734+
else "does not ship a local copy"
1735+
)
1736+
console.print(
1737+
f"⚠ {safe_ext_id}: v{catalog_version} is available, but this "
1738+
f"spec-kit release {local_desc} — upgrade spec-kit, then rerun "
1739+
f"'specify extension update'"
1740+
)
1741+
blocked_updates.append(ext_id)
1742+
continue
1743+
available_version = bundled_version
16611744
updates_available.append(
16621745
{
16631746
"id": ext_id,
16641747
"name": ext_info.get("name", ext_id), # Display name for status messages
16651748
"installed": str(installed_version),
1666-
"available": str(catalog_version),
1667-
"download_url": ext_info.get("download_url"),
1749+
"available": str(available_version),
1750+
"download_url": download_url,
1751+
"bundled_dir": bundled_dir,
16681752
}
16691753
)
16701754
else:
16711755
console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})")
16721756

16731757
if not updates_available:
1674-
console.print("\n[green]All extensions are up to date![/green]")
1758+
if blocked_updates:
1759+
console.print(
1760+
"\n[yellow]Update(s) exist but require a newer spec-kit "
1761+
"release — upgrade spec-kit, then rerun "
1762+
"'specify extension update'.[/yellow]"
1763+
)
1764+
else:
1765+
console.print("\n[green]All extensions are up to date![/green]")
16751766
raise typer.Exit(0)
16761767

16771768
# Show available updates
@@ -1968,8 +2059,15 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
19682059
if ext_hooks:
19692060
backup_hooks[hook_name] = ext_hooks
19702061

1971-
# 5. Download new version
1972-
archive_path = catalog.download_extension(extension_id)
2062+
# 5. Acquire the new version. Bundled extensions install from
2063+
# the copy shipped with the running spec-kit release (they
2064+
# have no download URL); everything else downloads. Both are
2065+
# packaged as archives so the identical validation,
2066+
# backup/rollback, and install pipeline below applies.
2067+
if update.get("bundled_dir") is not None:
2068+
archive_path = _archive_extension_directory(update["bundled_dir"])
2069+
else:
2070+
archive_path = catalog.download_extension(extension_id)
19732071
try:
19742072
# 6. Validate the archive and extension ID before modifying
19752073
# the existing installation. The shared extractor applies
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Tests for the bundled-extension local update route (#4345).
2+
3+
Bundled extensions have no download URL, so `specify extension update`
4+
installs them from the copy shipped with the running spec-kit release,
5+
packaged by `_archive_extension_directory` into the same hardened
6+
archive pipeline that downloaded updates use. These tests pin that
7+
packaging step and its round trip through the archive installer.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import os
13+
14+
import pytest
15+
import yaml
16+
from pathlib import Path
17+
18+
from specify_cli.extensions import ExtensionManager
19+
20+
21+
def _create_extension_source(
22+
base_dir: Path, name: str = "test-ext", version: str = "1.0.0"
23+
) -> Path:
24+
"""Create a minimal installable extension source directory."""
25+
ext_dir = base_dir / name
26+
ext_dir.mkdir(parents=True, exist_ok=True)
27+
28+
manifest = {
29+
"schema_version": "1.0",
30+
"extension": {
31+
"id": "test-ext",
32+
"name": "Test Extension",
33+
"version": version,
34+
"description": "A test extension",
35+
},
36+
"requires": {"speckit_version": ">=0.1.0"},
37+
"provides": {
38+
"commands": [
39+
{
40+
"name": "speckit.test-ext.hello",
41+
"file": "commands/hello.md",
42+
"description": "Test command",
43+
}
44+
]
45+
},
46+
}
47+
48+
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False))
49+
commands_dir = ext_dir / "commands"
50+
commands_dir.mkdir(exist_ok=True)
51+
(commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n")
52+
scripts_dir = ext_dir / "scripts"
53+
scripts_dir.mkdir(exist_ok=True)
54+
(scripts_dir / "run.sh").write_text("#!/bin/sh\necho hello\n")
55+
(ext_dir / "test-ext-config.yml").write_text("setting: default\n")
56+
return ext_dir
57+
58+
59+
def _make_project(tmp_path: Path) -> Path:
60+
project_dir = tmp_path / "project"
61+
project_dir.mkdir()
62+
(project_dir / ".specify").mkdir()
63+
(project_dir / ".claude" / "skills").mkdir(parents=True)
64+
return project_dir
65+
66+
67+
class TestArchiveExtensionDirectory:
68+
def test_archive_contains_regular_files_only(self, tmp_path):
69+
import zipfile
70+
71+
from specify_cli.extensions._commands import _archive_extension_directory
72+
73+
ext_dir = _create_extension_source(tmp_path)
74+
archive_path = _archive_extension_directory(ext_dir)
75+
try:
76+
with zipfile.ZipFile(archive_path) as zf:
77+
names = set(zf.namelist())
78+
assert "extension.yml" in names
79+
assert "commands/hello.md" in names
80+
finally:
81+
archive_path.unlink()
82+
83+
def test_archive_never_follows_symlinks(self, tmp_path):
84+
"""A symlink in the source must not pull out-of-tree bytes into the
85+
archive before the hardened extractor sees it."""
86+
import zipfile
87+
88+
from specify_cli.extensions._commands import _archive_extension_directory
89+
90+
ext_dir = _create_extension_source(tmp_path)
91+
outside = tmp_path / "outside.txt"
92+
outside.write_text("external bytes\n")
93+
try:
94+
(ext_dir / "scripts" / "link.txt").symlink_to(outside)
95+
except OSError:
96+
pytest.skip("symlink creation requires privileges on this platform")
97+
98+
archive_path = _archive_extension_directory(ext_dir)
99+
try:
100+
with zipfile.ZipFile(archive_path) as zf:
101+
names = set(zf.namelist())
102+
assert "scripts/link.txt" not in names
103+
finally:
104+
archive_path.unlink()
105+
106+
@pytest.mark.skipif(
107+
os.name == "nt", reason="POSIX execute bits do not exist on Windows"
108+
)
109+
def test_archive_route_restores_script_execute_bits(self, tmp_path):
110+
"""safe_extract_archive writes members without their recorded ZIP
111+
modes, so the archive install route depends on install_from_directory's
112+
trailing ensure_executable_scripts() call to keep documented
113+
`.specify/extensions/<id>/scripts/*.sh` invocations executable. Pin
114+
that round trip so removing the restoration would fail here instead
115+
of surfacing as `Permission denied` after a bundled update."""
116+
from specify_cli.extensions._commands import _archive_extension_directory
117+
118+
project_dir = _make_project(tmp_path)
119+
source = _create_extension_source(tmp_path)
120+
(source / "scripts" / "run.sh").chmod(0o755)
121+
122+
archive_path = _archive_extension_directory(source)
123+
try:
124+
ExtensionManager(project_dir).install_from_zip(archive_path, "0.1.0")
125+
finally:
126+
archive_path.unlink()
127+
128+
installed_script = (
129+
project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "run.sh"
130+
)
131+
assert installed_script.is_file()
132+
assert installed_script.stat().st_mode & 0o100, (
133+
"execute bit lost through the archive install route"
134+
)

0 commit comments

Comments
 (0)