mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +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:
@@ -1,13 +1,21 @@
|
||||
"""Tests for --integration flag on specify init (CLI-level)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
class _NoopConsole:
|
||||
def print(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_cli_output(output: str) -> str:
|
||||
output = strip_ansi(output)
|
||||
output = " ".join(output.split())
|
||||
@@ -254,6 +262,310 @@ class TestInitIntegrationFlag:
|
||||
normalized = " ".join(captured.out.split())
|
||||
assert "specify integration upgrade --force" in normalized
|
||||
|
||||
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
|
||||
"""Invalid shared manifests warn before falling back to a new manifest."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bad-shared-manifest-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
manifest_path = integrations_dir / "speckit.manifest.json"
|
||||
manifest_path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not read shared infrastructure manifest" in captured.out
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys):
|
||||
"""Non-UTF-8 shared manifests warn before falling back to a new manifest."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bad-shared-manifest-encoding-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
manifest_path = integrations_dir / "speckit.manifest.json"
|
||||
manifest_path.write_bytes(b"\xff\xfe\x00")
|
||||
|
||||
_install_shared_infra(project, "sh")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not read shared infrastructure manifest" in captured.out
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
|
||||
"""Shared script refreshes must not follow destination symlinks."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-script-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-script.sh"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
os.symlink(outside, scripts_dir / "common.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
|
||||
"""Shared template installs must not follow destination symlinks."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-template-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-template.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
|
||||
"""Template-only refreshes must not follow destination symlinks."""
|
||||
from specify_cli import _refresh_shared_templates
|
||||
|
||||
project = tmp_path / "symlink-refresh-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-refresh.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_refresh_shared_templates(project, invoke_separator=".", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
||||
"""Shared infra directory creation must not follow a symlinked .specify."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-dir-test"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside-specify"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, project / ".specify")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert not (outside / "scripts").exists()
|
||||
assert not (outside / "templates").exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
|
||||
"""Shared infra manifest saves must not follow destination symlinks."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-shared-manifest-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
|
||||
outside = tmp_path / "outside-manifest.json"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, integrations_dir / "speckit.manifest.json")
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked integration manifest"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
|
||||
"""Template refresh validates all destinations before writing any file."""
|
||||
from specify_cli.shared_infra import refresh_shared_templates
|
||||
|
||||
project = tmp_path / "preflight-refresh-test"
|
||||
project.mkdir()
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
|
||||
(templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")
|
||||
|
||||
existing = templates_dir / "a-template.md"
|
||||
existing.write_text("# old a\n", encoding="utf-8")
|
||||
outside = tmp_path / "outside-z.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, templates_dir / "z-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
refresh_shared_templates(
|
||||
project,
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
invoke_separator=".",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
|
||||
"""Full shared infra installs validate destinations before writing any file."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "preflight-install-test"
|
||||
project.mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
scripts_src = core_pack / "scripts" / "bash"
|
||||
scripts_src.mkdir(parents=True)
|
||||
(scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
|
||||
(scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")
|
||||
|
||||
existing = scripts_dir / "a.sh"
|
||||
existing.write_text("# old a\n", encoding="utf-8")
|
||||
outside = tmp_path / "outside-z.sh"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, scripts_dir / "z.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
|
||||
"""Nested script source files create safe destination parents at write time."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "nested-script-install-test"
|
||||
project.mkdir()
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
nested_src = core_pack / "scripts" / "bash" / "nested"
|
||||
nested_src.mkdir(parents=True)
|
||||
(nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8")
|
||||
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh"
|
||||
assert nested_dest.read_text(encoding="utf-8") == "# nested\n"
|
||||
|
||||
def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path):
|
||||
"""Skipped shared infra paths are reported consistently across platforms."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "posix-skip-warning-test"
|
||||
project.mkdir()
|
||||
nested_dest = project / ".specify" / "scripts" / "bash" / "nested"
|
||||
nested_dest.mkdir(parents=True)
|
||||
(nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8")
|
||||
|
||||
templates_dest = project / ".specify" / "templates"
|
||||
templates_dest.mkdir(parents=True)
|
||||
(templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8")
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
nested_src = core_pack / "scripts" / "bash" / "nested"
|
||||
nested_src.mkdir(parents=True)
|
||||
(nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8")
|
||||
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8")
|
||||
|
||||
buffer = io.StringIO()
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=Console(file=buffer, force_terminal=False, width=120),
|
||||
force=False,
|
||||
)
|
||||
|
||||
output = buffer.getvalue()
|
||||
assert ".specify/scripts/bash/nested/deep.sh" in output
|
||||
assert ".specify/templates/plan-template.md" in output
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows")
|
||||
def test_shared_template_writes_are_not_world_writable(self, tmp_path):
|
||||
"""Shared template writes use a safe default mode instead of chmod 666."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "template-mode-test"
|
||||
project.mkdir()
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
written = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert written.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
|
||||
"""No skip warning when force=True (all files overwritten)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
@@ -712,6 +1024,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
commands = [
|
||||
["integration", "list"],
|
||||
["integration", "install", "codex"],
|
||||
["integration", "use", "codex"],
|
||||
["integration", "uninstall"],
|
||||
["integration", "switch", "codex"],
|
||||
["integration", "upgrade"],
|
||||
@@ -730,10 +1043,92 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
project.mkdir()
|
||||
(project / ".specify").write_text("not a directory")
|
||||
|
||||
result = self._invoke(["integration", "list"], project)
|
||||
commands = [
|
||||
["integration", "list"],
|
||||
["integration", "use", "codex"],
|
||||
]
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Not a spec-kit project" in result.output
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad-feature-commands"
|
||||
project.mkdir()
|
||||
(project / ".specify").write_text("not a directory")
|
||||
|
||||
commands = [
|
||||
["preset", "list"],
|
||||
["preset", "add", "demo"],
|
||||
["preset", "remove", "demo"],
|
||||
["preset", "search"],
|
||||
["preset", "resolve", "spec-template"],
|
||||
["preset", "info", "demo"],
|
||||
["preset", "set-priority", "demo", "5"],
|
||||
["preset", "enable", "demo"],
|
||||
["preset", "disable", "demo"],
|
||||
["preset", "catalog", "list"],
|
||||
["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
|
||||
["preset", "catalog", "remove", "demo"],
|
||||
["extension", "list"],
|
||||
["extension", "add", "demo"],
|
||||
["extension", "remove", "demo"],
|
||||
["extension", "search"],
|
||||
["extension", "info", "demo"],
|
||||
["extension", "update", "demo"],
|
||||
["extension", "enable", "demo"],
|
||||
["extension", "disable", "demo"],
|
||||
["extension", "set-priority", "demo", "5"],
|
||||
["extension", "catalog", "list"],
|
||||
["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
|
||||
["extension", "catalog", "remove", "demo"],
|
||||
["workflow", "run", "demo"],
|
||||
["workflow", "resume", "demo"],
|
||||
["workflow", "status"],
|
||||
["workflow", "list"],
|
||||
["workflow", "add", "demo"],
|
||||
["workflow", "remove", "demo"],
|
||||
["workflow", "search"],
|
||||
["workflow", "info", "demo"],
|
||||
["workflow", "catalog", "add", "https://example.com/catalog.yml"],
|
||||
["workflow", "catalog", "remove", "0"],
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
failure_context = (
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
|
||||
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
|
||||
project = self._make_project(tmp_path)
|
||||
|
||||
preset_add = self._invoke([
|
||||
"preset", "catalog", "add",
|
||||
"https://example.com/preset-catalog.yml",
|
||||
"--name", "demo-presets",
|
||||
], project)
|
||||
assert preset_add.exit_code == 0, preset_add.output
|
||||
assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output
|
||||
|
||||
preset_list = self._invoke(["preset", "catalog", "list"], project)
|
||||
assert preset_list.exit_code == 0, preset_list.output
|
||||
assert "Config: .specify/preset-catalogs.yml" in preset_list.output
|
||||
|
||||
extension_add = self._invoke([
|
||||
"extension", "catalog", "add",
|
||||
"https://example.com/extension-catalog.yml",
|
||||
"--name", "demo-extensions",
|
||||
], project)
|
||||
assert extension_add.exit_code == 0, extension_add.output
|
||||
assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output
|
||||
|
||||
extension_list = self._invoke(["extension", "catalog", "list"], project)
|
||||
assert extension_list.exit_code == 0, extension_list.output
|
||||
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
|
||||
|
||||
# -- search ------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user