mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 07:04:44 +08:00
Support controlled multi-install for safe AI agent integrations (#2389)
* support controlled multi-install integrations * fix: harden multi-install integration state * refactor: isolate integration runtime helpers * fix: address copilot review feedback * fix: address follow-up copilot feedback * fix: tighten integration switch semantics * fix: address final copilot review feedback * fix: harden integration manifest read errors * fix: refuse symlinked shared infra paths * test: filter expected self-test preset warning * test: address copilot review nits * refactor: centralize safe shared infra writes * fix: use no-follow writes for shared infra * fix: keep default integration atomic on template refresh * fix: harden shared infra error paths * fix: preflight shared infra and future state schemas * fix: support nested shared scripts during preflight * test: tolerate wrapped schema error output * fix: use safe default mode for shared text writes * fix: use posix paths in shared skip output * fix: share project guard for integration use * fix: centralize spec-kit project guards * fix: use posix project paths in cli output * fix: harden shared manifest and upgrade refresh
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -41,6 +42,17 @@ def _run_in_project(project, args):
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
def _write_invalid_manifest(project, key):
|
||||
manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
manifest.write_bytes(b"\xff\xfe\x00")
|
||||
return manifest
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
row = next(line for line in output.splitlines() if line.startswith(f"│ {key}"))
|
||||
return [cell.strip() for cell in row.split("│")[1:-1]]
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -80,6 +92,39 @@ class TestIntegrationList:
|
||||
assert "claude" in result.output
|
||||
assert "gemini" in result.output
|
||||
|
||||
def test_list_shows_multi_install_safe_status(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "Multi-install" in result.output
|
||||
assert "Safe" in result.output
|
||||
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
|
||||
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
|
||||
|
||||
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
data = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
data["integration_state_schema"] = 99
|
||||
int_json.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "schema 99" in normalized
|
||||
assert "only supports schema 1" in normalized
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,7 +161,9 @@ class TestIntegrationInstall:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
assert "uninstall" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -127,8 +174,112 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "already installed" in result.output
|
||||
assert "uninstall" in result.output
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed successfully" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["default_integration"] == "claude"
|
||||
assert data["integration_state_schema"] == 1
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
|
||||
assert data["integration_settings"]["codex"]["invoke_separator"] == "-"
|
||||
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
||||
assert before
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
||||
assert before <= after
|
||||
|
||||
def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
int_json.write_text(json.dumps({
|
||||
"integration": "claude",
|
||||
"version": "0.0.0",
|
||||
}), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["default_integration"] == "claude"
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
|
||||
def test_install_multi_unsafe_requires_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
assert data["installed_integrations"] == ["copilot", "claude"]
|
||||
|
||||
def test_install_into_bare_project(self, tmp_path):
|
||||
"""Install into a project with .specify/ but no integration."""
|
||||
@@ -246,6 +397,7 @@ class TestIntegrationUninstall:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "preserved" in result.output
|
||||
assert ".claude/skills/speckit-plan/SKILL.md" in result.output
|
||||
|
||||
# Modified file kept
|
||||
assert plan_file.exists()
|
||||
@@ -260,7 +412,68 @@ class TestIntegrationUninstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "not the currently installed" in result.output
|
||||
assert "not installed" in result.output
|
||||
|
||||
def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_uninstall_non_default_preserves_default(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "uninstall", "codex",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["installed_integrations"] == ["claude"]
|
||||
|
||||
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
||||
"""Shared scripts and templates are not removed by integration uninstall."""
|
||||
@@ -281,6 +494,135 @@ class TestIntegrationUninstall:
|
||||
assert (project / ".specify" / "templates").is_dir()
|
||||
|
||||
|
||||
class TestIntegrationUse:
|
||||
def test_use_installed_integration_sets_default(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "codex"
|
||||
assert data["default_integration"] == "codex"
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "codex"
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
def test_use_requires_installed_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "use", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
|
||||
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "gemini",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||
assert use_gemini.exit_code == 0, use_gemini.output
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
|
||||
assert use_claude.exit_code == 0, use_claude.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.write_text("custom template with /speckit-plan\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "gemini",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||
assert use_gemini.exit_code == 0, use_gemini.output
|
||||
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
||||
|
||||
force_use = runner.invoke(app, [
|
||||
"integration", "use", "gemini",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
assert force_use.exit_code == 0, force_use.output
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
updated = template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in updated
|
||||
assert "custom template" not in updated
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
|
||||
outside = tmp_path / "outside-template.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.unlink()
|
||||
os.symlink(outside, template)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "use", "codex",
|
||||
"--force",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to refresh shared templates" in result.output
|
||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
|
||||
# ── switch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -306,6 +648,22 @@ class TestIntegrationSwitch:
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown integration" in result.output
|
||||
|
||||
def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "codex",
|
||||
"--script", "sh",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Could not read integration manifest" in result.output
|
||||
|
||||
def test_switch_same_noop(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
@@ -315,7 +673,48 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
assert "already the default integration" in result.output
|
||||
|
||||
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.write_text("# custom shared template\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "claude",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "managed shared templates refreshed" in result.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "codex",
|
||||
"--integration-options", "--bogus",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "--integration-options cannot be used" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["default_integration"] == "claude"
|
||||
|
||||
def test_switch_between_integrations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -522,6 +921,107 @@ class TestIntegrationSwitch:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
|
||||
def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "generic",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "codex"
|
||||
assert data["installed_integrations"] == ["codex"]
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "codex"
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestIntegrationUpgrade:
|
||||
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
|
||||
|
||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
before_manifest = manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
def fail_refresh(*args, **kwargs):
|
||||
raise ValueError("refuse refresh")
|
||||
|
||||
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to refresh shared templates" in result.output
|
||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
||||
|
||||
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "gemini"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user