Skip to content

Commit eeac3b2

Browse files
committed
fix: address PR review feedback
- Replace empty except with cache cleanup in _fetch_single_catalog - Log teardown failure warning instead of silent pass in upgrade - Validate catalog_data and integrations are dicts before use - Catch OSError/UnicodeError in IntegrationDescriptor._load - Add isinstance checks for integration/requires/provides/commands - Enforce semver (X.Y.Z) instead of PEP 440 for descriptor versions - Fix docstring and CONTRIBUTING.md to match actual block-on-modified behavior - Restore old manifest on upgrade failure for transactional safety
1 parent 38091b5 commit eeac3b2

3 files changed

Lines changed: 45 additions & 10 deletions

File tree

integrations/CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ The `specify integration upgrade` command supports diff-aware upgrades:
123123

124124
1. **Hash comparison** — the manifest records SHA-256 hashes of all installed files
125125
2. **Modified file detection** — files changed since installation are flagged
126-
3. **Safe default** — modified files are preserved unless `--force` is used
127-
4. **Clean reinstall** — unmodified files are replaced with the latest version
126+
3. **Safe default** — the upgrade blocks if any installed files were modified since installation
127+
4. **Forced reinstall** — passing `--force` overwrites modified files with the latest version
128128

129129
```bash
130130
# Upgrade current integration (blocks if files are modified)

src/specify_cli/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2229,7 +2229,7 @@ def integration_upgrade(
22292229
"""Upgrade an integration by reinstalling with diff-aware file handling.
22302230
22312231
Compares manifest hashes to detect locally modified files and
2232-
preserves them unless --force is used.
2232+
blocks the upgrade unless --force is used.
22332233
"""
22342234
from .integrations import get_integration
22352235
from .integrations.manifest import IntegrationManifest
@@ -2315,8 +2315,16 @@ def integration_upgrade(
23152315
except Exception as exc:
23162316
try:
23172317
integration.teardown(project_root, new_manifest, force=True)
2318+
except Exception as teardown_exc:
2319+
console.print(
2320+
f"[yellow]Warning:[/yellow] Teardown during rollback also failed: {teardown_exc}"
2321+
)
2322+
# Attempt to restore the old manifest so the project is not left broken
2323+
try:
2324+
old_manifest.save()
2325+
_write_integration_json(project_root, key, selected_script)
23182326
except Exception:
2319-
pass
2327+
pass # Best-effort restoration; original error is more important
23202328
console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}")
23212329
raise typer.Exit(1)
23222330

src/specify_cli/integrations/catalog.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from typing import Any, Dict, List, Optional
2020

2121
import yaml
22-
from packaging import version as pkg_version
2322

2423

2524
# ---------------------------------------------------------------------------
@@ -244,19 +243,29 @@ def _fetch_single_catalog(
244243
if age < self.CACHE_DURATION:
245244
return json.loads(cache_file.read_text())
246245
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
247-
pass
246+
# Cache is invalid or stale metadata; delete and refetch from source.
247+
cache_file.unlink(missing_ok=True)
248+
cache_meta.unlink(missing_ok=True)
248249

249250
try:
250251
with urllib.request.urlopen(entry.url, timeout=10) as resp:
251252
catalog_data = json.loads(resp.read())
252253

254+
if not isinstance(catalog_data, dict):
255+
raise IntegrationCatalogError(
256+
f"Invalid catalog format from {entry.url}: expected a JSON object"
257+
)
253258
if (
254259
"schema_version" not in catalog_data
255260
or "integrations" not in catalog_data
256261
):
257262
raise IntegrationCatalogError(
258263
f"Invalid catalog format from {entry.url}"
259264
)
265+
if not isinstance(catalog_data.get("integrations"), dict):
266+
raise IntegrationCatalogError(
267+
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
268+
)
260269

261270
self.cache_dir.mkdir(parents=True, exist_ok=True)
262271
cache_file.write_text(json.dumps(catalog_data, indent=2))
@@ -413,6 +422,10 @@ def _load(path: Path) -> dict:
413422
raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}")
414423
except FileNotFoundError:
415424
raise IntegrationDescriptorError(f"Descriptor not found: {path}")
425+
except (OSError, UnicodeError) as exc:
426+
raise IntegrationDescriptorError(
427+
f"Unable to read descriptor {path}: {exc}"
428+
)
416429

417430
# -- Validation -------------------------------------------------------
418431

@@ -430,6 +443,10 @@ def _validate(self) -> None:
430443
)
431444

432445
integ = self.data["integration"]
446+
if not isinstance(integ, dict):
447+
raise IntegrationDescriptorError(
448+
"'integration' must be a mapping"
449+
)
433450
for field in ("id", "name", "version", "description"):
434451
if field not in integ:
435452
raise IntegrationDescriptorError(
@@ -442,20 +459,26 @@ def _validate(self) -> None:
442459
"must be lowercase alphanumeric with hyphens only"
443460
)
444461

445-
try:
446-
pkg_version.Version(integ["version"])
447-
except pkg_version.InvalidVersion:
462+
if not re.match(r"^\d+\.\d+\.\d+", integ["version"]):
448463
raise IntegrationDescriptorError(
449-
f"Invalid version: {integ['version']}"
464+
f"Invalid version '{integ['version']}': must use semantic versioning (e.g., 1.0.0)"
450465
)
451466

452467
requires = self.data["requires"]
468+
if not isinstance(requires, dict):
469+
raise IntegrationDescriptorError(
470+
"'requires' must be a mapping"
471+
)
453472
if "speckit_version" not in requires:
454473
raise IntegrationDescriptorError(
455474
"Missing requires.speckit_version"
456475
)
457476

458477
provides = self.data["provides"]
478+
if not isinstance(provides, dict):
479+
raise IntegrationDescriptorError(
480+
"'provides' must be a mapping"
481+
)
459482
commands = provides.get("commands", [])
460483
scripts = provides.get("scripts", [])
461484
if "commands" in provides and not isinstance(commands, list):
@@ -471,6 +494,10 @@ def _validate(self) -> None:
471494
"Integration must provide at least one command or script"
472495
)
473496
for cmd in commands:
497+
if not isinstance(cmd, dict):
498+
raise IntegrationDescriptorError(
499+
"Each command entry must be a mapping"
500+
)
474501
if "name" not in cmd or "file" not in cmd:
475502
raise IntegrationDescriptorError(
476503
"Command entry missing 'name' or 'file'"

0 commit comments

Comments
 (0)