feat: Integration catalog — discovery, versioning, and community distribution (#2130)

* Initial plan

* feat: add integration catalog system with catalog files, IntegrationCatalog class, list --catalog flag, upgrade command, integration.yml descriptor, and tests

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bbcd44e8-c69c-4735-adc1-bdf1ce109184

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* 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

* refactor: address second round of PR review feedback

- Remove dead cache_file/cache_metadata_file attributes from IntegrationCatalog
- Deduplicate non-default catalog warning (show once per process)
- Anchor version regex to reject partial matches like 1.0.0beta
- Fix 'Preserved modified' message to 'Skipped' for accuracy
- Make upgrade transactional: install new files first, then remove stale
  old-only files, so a failed setup leaves old integration intact
- Update CONTRIBUTING.md: speckit_version validates presence only

* Potential fix for pull request finding 'Empty except'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: address third round of PR review feedback

- Fix CONTRIBUTING.md JSON examples to show full catalog structure with
  schema_version and integrations wrapper
- Wrap cache writes in try/except OSError for read-only project dirs
- Validate _load_catalog_config YAML root is a dict
- Skip non-dict integ_data entries in merged catalog
- Normalize tags to list-of-strings before filtering/searching
- Add path traversal containment check for stale file deletion
- Clarify docstring: lower numeric priority = higher precedence

* fix: address fourth round of PR review feedback

- Remove unused _write_catalog helper from test file
- Fix comment: tests use monkeypatched urlopen, not file:// URLs
- Wrap cache unlink calls in OSError handler
- Add explicit encoding='utf-8' to all cache read_text/write_text calls
- Restore packaging.version.Version for descriptor version validation
  to align with extension/preset validators
- Add missing goose entry to integrations/catalog.json

* fix: remove unused Path import, add comment to empty except

* fix: validate descriptor root is dict, add shared infra to upgrade

- Add isinstance(self.data, dict) check at start of _validate() so
  non-mapping YAML roots raise IntegrationDescriptorError
- Run _install_shared_infra() and ensure_executable_scripts() in
  upgrade command to match install/switch behavior

* fix: address sixth round of PR review feedback

- Validate integration.id/name/version/description are strings
- Catch TypeError in pkg_version.Version() for non-string versions
- Swap validation order: check catalogs type before emptiness
- Isolate TestActiveCatalogs from user ~/.specify/ via monkeypatch

* fix: address seventh round of PR review feedback

- Update docs: version field uses PEP 440, not semver
- Harden search() against non-string author/name/description fields
- Validate requires.speckit_version is a non-empty string
- Validate command name/file are non-empty strings, file is safe relative path
- Handle stale symlinks in upgrade cleanup
- Document catalog configuration stack in README.md

* fix: validate script entries, remove destructive teardown from upgrade rollback

- Validate provides.scripts entries are non-empty strings with safe relative paths
- Remove teardown from upgrade rollback since setup overwrites in-place —
  teardown would delete files that were working before the upgrade

* fix: use consistent resolved root for stale-file cleanup paths

* fix: validate redirect URL and reject drive-qualified paths

- Validate final URL after redirects with _validate_catalog_url()
- Reject paths with Path.drive or Path.anchor for Windows safety
- Update FakeResponse mocks with geturl() method

* fix: fix docstring backticks, assert file modification in upgrade tests

* docs: clarify directory naming convention for hyphenated integration keys

* fix: correct key type hint, isolate all catalog tests from env

- Fix key parameter type to str | None (defaults to None)
- Add HOME/USERPROFILE monkeypatch and clear SPECKIT_INTEGRATION_CATALOG_URL
  in all TestCatalogFetch tests for full environment isolation

* fix: neutralize catalog table title, handle non-dict cache metadata

* fix: validate requires.tools entries in descriptor

* fix: show discovery-only status, clear metadata files in clear_cache

* fix: catch OSError/UnicodeError in cache read path

* refactor: reuse IntegrationManifest.uninstall for stale-file cleanup

* fix: normalize null tools to empty list in descriptor accessor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
Copilot
2026-04-16 08:59:32 -05:00
committed by GitHub
parent e0fd355dad
commit 282dd3da56
7 changed files with 1979 additions and 1 deletions

View File

@@ -0,0 +1,656 @@
"""Tests for the integration catalog system (catalog.py)."""
import json
import os
import pytest
import yaml
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogEntry,
IntegrationCatalogError,
IntegrationDescriptor,
IntegrationDescriptorError,
)
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
class TestIntegrationCatalogEntry:
def test_create_entry(self):
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
description="Test catalog",
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
assert entry.description == "Test catalog"
def test_default_description(self):
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=False,
)
assert entry.description == ""
# ---------------------------------------------------------------------------
# IntegrationCatalog — URL validation
# ---------------------------------------------------------------------------
class TestCatalogURLValidation:
def test_https_allowed(self):
IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json")
def test_http_rejected(self):
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json")
def test_http_localhost_allowed(self):
IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json")
IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json")
def test_missing_host_rejected(self):
with pytest.raises(IntegrationCatalogError, match="valid URL"):
IntegrationCatalog._validate_catalog_url("https:///no-host")
# ---------------------------------------------------------------------------
# IntegrationCatalog — active catalogs
# ---------------------------------------------------------------------------
class TestActiveCatalogs:
def test_defaults_when_no_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 2
assert active[0].name == "default"
assert active[1].name == "community"
def test_env_var_override(self, tmp_path, monkeypatch):
(tmp_path / ".specify").mkdir()
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"https://custom.example.com/catalog.json",
)
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "custom"
def test_project_config_overrides_defaults(self, tmp_path):
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({
"catalogs": [
{"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True},
]
}))
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "mine"
def test_empty_config_raises(self, tmp_path):
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": []}))
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
cat.get_active_catalogs()
# ---------------------------------------------------------------------------
# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses)
# ---------------------------------------------------------------------------
class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
def _patch_urlopen(self, monkeypatch, catalog_data):
"""Patch urllib.request.urlopen to return *catalog_data*."""
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
def read(self):
return self._data
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(url, timeout=10):
return FakeResponse(catalog_data, url)
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"acme-coder": {
"id": "acme-coder",
"name": "Acme Coder",
"version": "2.0.0",
"description": "Community integration for Acme Coder",
"author": "acme-org",
"tags": ["cli"],
},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search()
assert len(results) >= 1
ids = [r["id"] for r in results]
assert "acme-coder" in ids
def test_search_by_tag(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]},
"b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search(tag="cli")
assert all("cli" in r.get("tags", []) for r in results)
def test_search_by_query(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []},
"gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search(query="claude")
assert len(results) == 1
assert results[0]["id"] == "claude"
def test_get_integration_info(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"},
},
}
self._patch_urlopen(monkeypatch, catalog)
info = cat.get_integration_info("claude")
assert info is not None
assert info["name"] == "Claude Code"
assert cat.get_integration_info("nonexistent") is None
def test_invalid_catalog_format(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations"
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_clear_cache(self, tmp_path):
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
cat.cache_dir.mkdir(parents=True, exist_ok=True)
(cat.cache_dir / "catalog-abc123.json").write_text("{}")
cat.clear_cache()
assert not list(cat.cache_dir.glob("catalog-*.json"))
# ---------------------------------------------------------------------------
# IntegrationDescriptor (integration.yml)
# ---------------------------------------------------------------------------
VALID_DESCRIPTOR = {
"schema_version": "1.0",
"integration": {
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Integration for My Agent",
"author": "my-org",
},
"requires": {
"speckit_version": ">=0.6.0",
},
"provides": {
"commands": [
{"name": "speckit.specify", "file": "templates/speckit.specify.md"},
],
"scripts": ["update-context.sh"],
},
}
class TestIntegrationDescriptor:
def _write(self, tmp_path, data):
p = tmp_path / "integration.yml"
p.write_text(yaml.dump(data))
return p
def test_valid_descriptor(self, tmp_path):
p = self._write(tmp_path, VALID_DESCRIPTOR)
desc = IntegrationDescriptor(p)
assert desc.id == "my-agent"
assert desc.name == "My Agent"
assert desc.version == "1.0.0"
assert desc.description == "Integration for My Agent"
assert desc.requires_speckit_version == ">=0.6.0"
assert len(desc.commands) == 1
assert desc.scripts == ["update-context.sh"]
def test_missing_schema_version(self, tmp_path):
data = {**VALID_DESCRIPTOR}
del data["schema_version"]
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"):
IntegrationDescriptor(p)
def test_unsupported_schema_version(self, tmp_path):
data = {**VALID_DESCRIPTOR, "schema_version": "99.0"}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"):
IntegrationDescriptor(p)
def test_missing_integration_id(self, tmp_path):
data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"):
IntegrationDescriptor(p)
def test_invalid_id_format(self, tmp_path):
integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"}
data = {**VALID_DESCRIPTOR, "integration": integ}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"):
IntegrationDescriptor(p)
def test_invalid_version(self, tmp_path):
integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"}
data = {**VALID_DESCRIPTOR, "integration": integ}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Invalid version"):
IntegrationDescriptor(p)
def test_missing_speckit_version(self, tmp_path):
data = {**VALID_DESCRIPTOR, "requires": {}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"):
IntegrationDescriptor(p)
def test_no_commands_or_scripts(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="at least one command or script"):
IntegrationDescriptor(p)
def test_command_missing_name(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"):
IntegrationDescriptor(p)
def test_commands_not_a_list(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
IntegrationDescriptor(p)
def test_scripts_not_a_list(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
IntegrationDescriptor(p)
def test_file_not_found(self, tmp_path):
with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"):
IntegrationDescriptor(tmp_path / "nonexistent.yml")
def test_invalid_yaml(self, tmp_path):
p = tmp_path / "integration.yml"
p.write_text(": : :")
with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"):
IntegrationDescriptor(p)
def test_get_hash(self, tmp_path):
p = self._write(tmp_path, VALID_DESCRIPTOR)
desc = IntegrationDescriptor(p)
h = desc.get_hash()
assert h.startswith("sha256:")
def test_tools_accessor(self, tmp_path):
data = {**VALID_DESCRIPTOR, "requires": {
"speckit_version": ">=0.6.0",
"tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}],
}}
p = self._write(tmp_path, data)
desc = IntegrationDescriptor(p)
assert len(desc.tools) == 1
assert desc.tools[0]["name"] == "my-agent"
# ---------------------------------------------------------------------------
# CLI: integration list --catalog
# ---------------------------------------------------------------------------
class TestIntegrationListCatalog:
"""Test ``specify integration list --catalog``."""
def _init_project(self, tmp_path):
"""Create a minimal spec-kit project."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0, result.output
return project
def test_list_catalog_flag(self, tmp_path, monkeypatch):
"""--catalog should show catalog entries."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"test-agent": {
"id": "test-agent",
"name": "Test Agent",
"version": "1.0.0",
"description": "A test agent",
"tags": ["cli"],
},
},
}
import urllib.request
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
def read(self):
return self._data
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list", "--catalog"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "test-agent" in result.output
assert "Test Agent" in result.output
def test_list_without_catalog_still_works(self, tmp_path):
"""Default list (no --catalog) works as before."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path)
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "copilot" in result.output
assert "installed" in result.output
# ---------------------------------------------------------------------------
# CLI: integration upgrade
# ---------------------------------------------------------------------------
class TestIntegrationUpgrade:
"""Test ``specify integration upgrade``."""
def _init_project(self, tmp_path, integration="copilot"):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0, result.output
return project
def test_upgrade_requires_speckit_project(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_upgrade_no_integration_installed(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "No integration is currently installed" in result.output
def test_upgrade_succeeds(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0
assert "upgraded successfully" in result.output
def test_upgrade_blocks_on_modified_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Modify a tracked file so the manifest hash won't match
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
assert manifest_path.exists(), "Manifest should exist after init"
manifest_data = json.loads(manifest_path.read_text())
tracked_files = manifest_data.get("files", {})
assert tracked_files, "Manifest should track at least one file"
first_rel = next(iter(tracked_files))
target_file = project / first_rel
assert target_file.exists(), f"Tracked file {first_rel} should exist"
target_file.write_text("MODIFIED CONTENT\n")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "modified" in result.output.lower()
def test_upgrade_force_overwrites_modified(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Modify a tracked file
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
manifest_data = json.loads(manifest_path.read_text())
tracked_files = manifest_data.get("files", {})
assert tracked_files, "Manifest should track at least one file"
first_rel = next(iter(tracked_files))
target_file = project / first_rel
assert target_file.exists(), f"Tracked file {first_rel} should exist"
target_file.write_text("MODIFIED CONTENT\n")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0
assert "upgraded successfully" in result.output
def test_upgrade_wrong_integration_key(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "claude"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "not the currently installed integration" in result.output
def test_upgrade_no_manifest(self, tmp_path):
"""Upgrade with missing manifest suggests fresh install."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Remove manifest
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
if manifest_path.exists():
manifest_path.unlink()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "Nothing to upgrade" in result.output