mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* refactor: move extension command handlers to extensions/_commands.py (PR-7/8) Convert the flat extensions.py module into an extensions/ package and extract all extension_app and catalog_app command handlers plus their private helpers (_resolve_installed_extension, _resolve_catalog_extension, _print_extension_info) out of __init__.py into the new extensions/_commands.py, mirroring the domain-dir layout used for presets/_commands.py (PR-6) and integrations/_commands.py (PR-5). - extensions.py -> extensions/__init__.py (pure rename, 99%); intra-module relative imports bumped from `.x` to `..x` since they reference root siblings. - Root helpers (_require_specify_project, _locate_bundled_extension, load_init_options, _display_project_path) are reached through thin shims that re-fetch from the parent package at call time, so test monkeypatching of specify_cli.<helper> keeps working unchanged. - __init__.py drops ~1444 lines (3511 -> 2067); CLI surface preserved via register(app). No behavior change. Full suite failure set is identical before/after (82 pre-existing env failures, 0 new). * fix(extensions): preserve per-command path in update backup for skills agents Skills agents (extension == "/SKILL.md") name every command file SKILL.md, each in its own per-command subdir (e.g. speckit-plan/SKILL.md). The update backup keyed the backup path on cmd_file.name alone, so all of an agent's skill files collided onto a single backup path — each shutil.copy2 overwrote the previous one, and rollback restored one skill's content over all the others, corrupting or losing the rest. Mirror the real on-disk layout by using cmd_file.relative_to(commands_dir), keeping each backup path unique. This also makes backed_up_command_files values unique so restore copies the correct content back to each command. Add a regression test asserting two distinct skill files survive a backup -> failed-update -> rollback cycle with their own content. * style(extensions): use yaml.safe_dump when writing catalog config The catalog add/remove handlers wrote the integration catalog config with yaml.dump. Switch to yaml.safe_dump to align with the SafeDumper used by the presets commands and to refuse emitting !!python/object tags if a non-basic value ever reaches the config dict. Output is unchanged for the current basic-type payload (str/int/bool/dict/ list) — this is a defensive/consistency change, not a behavioral fix. * fix(extensions): correct _print_cli_warning import path in skill registration register_enabled_extensions_for_agent imported _print_cli_warning from `.` (the extensions package), but the helper lives in the parent specify_cli package. The wrong level raised ImportError inside the error handlers, aborting extension/skill registration on the first failure instead of warning and continuing. Use `..` to match the other parent-package imports. * fix(extensions): escape untrusted values in Rich markup output User-provided arguments and extension/catalog metadata (names, descriptions, versions, IDs, paths) were interpolated into Rich markup strings without escaping. Values containing markup sequences (e.g. [red]...) would be parsed as markup, allowing output injection that could corrupt or mislead CLI messages. Wrap all such interpolations with rich.markup.escape across the extension/catalog command handlers: list, search, info (_print_extension_info), add (including --dev paths), remove, enable, disable, set-priority, update, and the ambiguous-match resolvers (error strings and Table rows). Reuse the already-computed safe_extension where available. Escaping is a no-op for benign strings, so normal output is unchanged. * Prevent Rich markup injection in extension CLI output User-controlled catalog URLs and extension IDs are rendered through Rich-enabled console paths, so every remaining output-only interpolation now escapes markup while leaving stored values and filesystem behavior unchanged. Regression tests cover catalog add, install hints, remove hints, and state command messages with bracketed markup-like values. * Prevent markup injection from exception text Rich markup remains enabled for styled CLI messages, so exception text and config path labels must be escaped before rendering. YAML parser errors, URL validation failures, download errors, and extension validation errors can include user-controlled catalog or manifest values. Constraint: Preserve existing exception handling and user-facing error paths Rejected: Disable Rich markup for these messages | existing output intentionally uses markup for labels and styling Confidence: high Scope-risk: narrow Directive: Escape user-controlled exception text before interpolating into Rich-rendered strings Tested: .venv/bin/python -m pytest tests/test_extensions.py -q Co-authored-by: OmX <omx@oh-my-codex.dev> * Prevent path and manifest review regressions Catalog path labels are rendered through Rich markup and downloaded update manifests are trusted long enough to validate extension IDs. Escape displayed project paths before rendering, and reject non-mapping extension.yml payloads before ID validation so bad archives fail with a clear rollback reason. --------- Co-authored-by: OmX <omx@oh-my-codex.dev>
1883 lines
76 KiB
Python
1883 lines
76 KiB
Python
"""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())
|
|
return output.strip()
|
|
|
|
|
|
class TestCliDiagnosticFormatting:
|
|
def test_cli_error_detail_flattens_newlines(self):
|
|
import specify_cli
|
|
|
|
assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"
|
|
|
|
def test_cli_error_detail_handles_empty_message(self):
|
|
import specify_cli
|
|
|
|
assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"
|
|
|
|
def test_cli_phase_label_includes_target(self):
|
|
import specify_cli
|
|
|
|
assert (
|
|
specify_cli._cli_phase_label("rollback", "integration", "codex")
|
|
== "rollback integration 'codex'"
|
|
)
|
|
|
|
|
|
class TestInitIntegrationFlag:
|
|
def test_unknown_integration_rejected(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(tmp_path / "test-project"), "--integration", "nonexistent",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "Unknown integration" in result.output
|
|
|
|
def test_integration_copilot_creates_files(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
runner = CliRunner()
|
|
project = tmp_path / "int-test"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "copilot", "--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
|
|
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "copilot"
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts["integration"] == "copilot"
|
|
# context_file lives in the agent-context extension config, not init-options.json
|
|
assert "context_file" not in opts
|
|
|
|
import yaml as _yaml
|
|
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
|
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
|
|
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
|
|
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
|
|
|
|
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
|
|
|
# Context section should be upserted into the copilot instructions file
|
|
ctx_file = project / ".github" / "copilot-instructions.md"
|
|
assert ctx_file.exists()
|
|
ctx_content = ctx_file.read_text(encoding="utf-8")
|
|
assert "<!-- SPECKIT START -->" in ctx_content
|
|
assert "<!-- SPECKIT END -->" in ctx_content
|
|
|
|
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
|
assert shared_manifest.exists()
|
|
|
|
def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
import specify_cli
|
|
|
|
def fail_select(*_args, **_kwargs):
|
|
raise AssertionError("non-interactive init should not open the integration picker")
|
|
|
|
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "noninteractive"
|
|
result = runner.invoke(app, [
|
|
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
|
|
|
def test_integration_copilot_auto_promotes(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "promote-test"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "copilot", "--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
|
|
def test_init_optional_preset_failure_reports_target_and_continues(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
from specify_cli.presets import PresetManager
|
|
|
|
def fail_install(self, path, version):
|
|
raise OSError("preset install exploded\nwith context")
|
|
|
|
monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)
|
|
|
|
project = tmp_path / "init-preset-warning"
|
|
result = CliRunner().invoke(
|
|
app,
|
|
[
|
|
"init",
|
|
str(project),
|
|
"--integration",
|
|
"copilot",
|
|
"--script",
|
|
"sh",
|
|
"--preset",
|
|
"lean",
|
|
],
|
|
catch_exceptions=False,
|
|
)
|
|
normalized = _normalize_cli_output(result.output)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Failed to install preset 'lean'" in normalized
|
|
assert "preset install exploded with context" in normalized
|
|
assert "Continuing without the optional preset" in normalized
|
|
assert "Project ready" in normalized
|
|
|
|
def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "claude-here-existing"
|
|
project.mkdir()
|
|
commands_dir = project / ".claude" / "skills"
|
|
commands_dir.mkdir(parents=True)
|
|
skill_dir = commands_dir / "speckit-specify"
|
|
skill_dir.mkdir(parents=True)
|
|
command_file = skill_dir / "SKILL.md"
|
|
command_file.write_text("# preexisting command\n", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert command_file.exists()
|
|
# init replaces skills (not additive); verify the file has valid skill content
|
|
assert command_file.exists()
|
|
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
|
|
"""Pre-existing shared files are not overwritten without --force."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "skip-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
# Pre-create a shared script with custom content
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
# Pre-create a shared template with custom content
|
|
templates_dir = project / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True)
|
|
custom_template = "# user-modified spec-template\n"
|
|
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# User's files should be preserved (not overwritten)
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
|
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
|
|
|
|
# Other shared files should still be installed
|
|
assert (scripts_dir / "setup-plan.sh").exists()
|
|
assert (templates_dir / "plan-template.md").exists()
|
|
|
|
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
|
|
"""Pre-existing shared files ARE overwritten when force=True."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "force-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
# Pre-create a shared script with custom content
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
# Pre-create a shared template with custom content
|
|
templates_dir = project / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True)
|
|
custom_template = "# user-modified spec-template\n"
|
|
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
# Files should be overwritten with bundled versions
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
|
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
|
|
|
|
# Other shared files should also be installed
|
|
assert (scripts_dir / "setup-plan.sh").exists()
|
|
assert (templates_dir / "plan-template.md").exists()
|
|
|
|
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
|
"""Console warning is displayed when files are skipped."""
|
|
from specify_cli import _install_shared_infra
|
|
from tests.conftest import strip_ansi
|
|
|
|
project = tmp_path / "warn-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
captured = capsys.readouterr()
|
|
plain = strip_ansi(captured.out)
|
|
assert "already exist and were not updated" in plain
|
|
assert "specify init --here --force" in plain
|
|
# Rich may wrap long lines; normalize whitespace for the second command
|
|
normalized = " ".join(plain.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_buckets_symlinked_script_destination(self, tmp_path, capsys):
|
|
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
|
|
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")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "symlinked shared infrastructure" in captured.out
|
|
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
|
|
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
|
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
|
|
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
|
|
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")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "symlinked shared infrastructure" in captured.out
|
|
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 installs must not follow a symlinked .specify directory."""
|
|
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"):
|
|
_install_shared_infra(project, "sh", force=True)
|
|
# Nothing should have been written under the symlinked .specify target.
|
|
assert list(outside.iterdir()) == []
|
|
|
|
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_buckets_unsafe_destinations_and_continues(self, tmp_path):
|
|
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
|
|
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")
|
|
|
|
install_shared_infra(
|
|
project,
|
|
"sh",
|
|
version="test",
|
|
core_pack=core_pack,
|
|
repo_root=tmp_path / "unused",
|
|
console=_NoopConsole(),
|
|
force=True,
|
|
)
|
|
|
|
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
|
|
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
|
assert existing.read_text(encoding="utf-8") == "# new a\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
|
|
|
|
project = tmp_path / "no-warn-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "already exist and were not updated" not in captured.out
|
|
|
|
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
|
|
"""E2E: specify init --here --force overwrites shared infra files."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "e2e-force"
|
|
project.mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--force",
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0
|
|
# --force should overwrite the custom file
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
|
|
|
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
|
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "e2e-no-force"
|
|
project.mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here",
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
], input="y\n", catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0
|
|
# Without --force, custom file should be preserved
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
|
# Warning about skipped files should appear
|
|
assert "not updated" in result.output
|
|
|
|
|
|
class TestForceExistingDirectory:
|
|
"""Tests for --force merging into an existing named directory."""
|
|
|
|
def test_force_merges_into_existing_dir(self, tmp_path):
|
|
"""specify init <dir> --force succeeds when the directory already exists."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
target = tmp_path / "existing-proj"
|
|
target.mkdir()
|
|
# Place a pre-existing file to verify it survives the merge
|
|
marker = target / "user-file.txt"
|
|
marker.write_text("keep me", encoding="utf-8")
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(target), "--integration", "copilot", "--force",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
|
|
|
# Pre-existing file should survive
|
|
assert marker.read_text(encoding="utf-8") == "keep me"
|
|
|
|
# Spec Kit files should be installed
|
|
assert (target / ".specify" / "init-options.json").exists()
|
|
assert (target / ".specify" / "templates" / "spec-template.md").exists()
|
|
|
|
def test_without_force_errors_on_existing_dir(self, tmp_path):
|
|
"""specify init <dir> without --force errors when directory exists."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
target = tmp_path / "existing-proj"
|
|
target.mkdir()
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(target), "--integration", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1
|
|
assert "already exists" in _normalize_cli_output(result.output)
|
|
|
|
|
|
class TestGitExtensionOptIn:
|
|
"""Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
|
|
|
|
def test_git_extension_not_auto_installed(self, tmp_path):
|
|
"""Git extension is NOT installed automatically during init."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "git-opt-in"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
# Git extension directory should NOT be present after init
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert not ext_dir.exists(), "git extension should not be auto-installed"
|
|
|
|
def test_no_git_flag_is_rejected(self, tmp_path):
|
|
"""--no-git flag has been removed; passing it should fail."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "no-git-rejected"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
|
"--no-git", "--ignore-agent-tools",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
|
|
assert "No such option" in result.output or "no such option" in result.output.lower()
|
|
|
|
def test_git_extension_commands_not_registered_by_default(self, tmp_path):
|
|
"""Git extension commands are NOT registered with the agent during default init."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "git-cmds-absent"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "claude", "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
# Git extension skill commands should NOT be present
|
|
claude_skills = project / ".claude" / "skills"
|
|
assert claude_skills.exists(), "Claude skills directory was not created"
|
|
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
|
assert len(git_skills) == 0, "git extension commands should not be registered by default"
|
|
|
|
|
|
class TestSharedInfraCommandRefs:
|
|
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in shared infra."""
|
|
|
|
@staticmethod
|
|
def _combined_script_content(project, script_type):
|
|
script_dir = "bash" if script_type == "sh" else "powershell"
|
|
suffix = "sh" if script_type == "sh" else "ps1"
|
|
names = [
|
|
f"check-prerequisites.{suffix}",
|
|
f"common.{suffix}",
|
|
f"setup-tasks.{suffix}",
|
|
]
|
|
return "\n".join(
|
|
(project / ".specify" / "scripts" / script_dir / name).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for name in names
|
|
)
|
|
|
|
def test_dot_separator_in_page_templates(self, tmp_path):
|
|
"""Markdown agents get /speckit.<name> in page templates."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "dot-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, "sh", invoke_separator=".")
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
assert plan.exists()
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
|
assert "/speckit.plan" in content
|
|
|
|
checklist = project / ".specify" / "templates" / "checklist-template.md"
|
|
content = checklist.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit.checklist" in content
|
|
|
|
def test_hyphen_separator_in_page_templates(self, tmp_path):
|
|
"""Skills agents get /speckit-<name> in page templates."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "hyphen-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, "sh", invoke_separator="-")
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
assert plan.exists()
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
|
assert "/speckit-plan" in content
|
|
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
|
|
|
|
tasks = project / ".specify" / "templates" / "tasks-template.md"
|
|
content = tasks.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit-tasks" in content
|
|
|
|
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
|
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
|
|
"""Markdown agents get /speckit.<name> in shared script hints."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / f"dot-script-{script_type}"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, script_type, invoke_separator=".")
|
|
|
|
content = self._combined_script_content(project, script_type)
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit.specify" in content
|
|
assert "/speckit.plan" in content
|
|
assert "/speckit.tasks" in content
|
|
assert "/speckit-specify" not in content
|
|
assert "/speckit-plan" not in content
|
|
assert "/speckit-tasks" not in content
|
|
|
|
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
|
def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type):
|
|
"""Skills agents get /speckit-<name> in shared script hints."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / f"hyphen-script-{script_type}"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, script_type, invoke_separator="-")
|
|
|
|
content = self._combined_script_content(project, script_type)
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit-specify" in content
|
|
assert "/speckit-plan" in content
|
|
assert "/speckit-tasks" in content
|
|
assert "/speckit.specify" not in content
|
|
assert "/speckit.plan" not in content
|
|
assert "/speckit.tasks" not in content
|
|
|
|
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-claude"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "claude",
|
|
"--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
script_content = self._combined_script_content(project, "sh")
|
|
assert "/speckit-specify" in script_content
|
|
assert "/speckit.specify" not in script_content
|
|
|
|
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-copilot"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
script_content = self._combined_script_content(project, "sh")
|
|
assert "/speckit.specify" in script_content
|
|
assert "/speckit-specify" not in script_content
|
|
|
|
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-copilot-skills"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "copilot",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
|
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
script_content = self._combined_script_content(project, "sh")
|
|
assert "/speckit-specify" in script_content
|
|
assert "/speckit.specify" not in script_content
|
|
|
|
|
|
class TestIntegrationCatalogDiscoveryCLI:
|
|
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
|
|
|
All tests patch `IntegrationCatalog._get_merged_integrations` so no network
|
|
or on-disk cache is touched. Adds #2344 coverage without affecting any
|
|
existing integration install/switch/uninstall/upgrade behavior.
|
|
"""
|
|
|
|
FAKE_INTEGRATIONS = [
|
|
{
|
|
"id": "acme-coder",
|
|
"name": "Acme Coder",
|
|
"version": "2.0.0",
|
|
"description": "Community integration for Acme Coder",
|
|
"author": "acme-org",
|
|
"tags": ["cli", "acme"],
|
|
"_catalog_name": "community",
|
|
"_install_allowed": False,
|
|
},
|
|
{
|
|
"id": "stellar-agent",
|
|
"name": "Stellar Agent",
|
|
"version": "1.3.0",
|
|
"description": "First-party Stellar agent integration",
|
|
"author": "stellar-labs",
|
|
"tags": ["ide"],
|
|
"_catalog_name": "default",
|
|
"_install_allowed": True,
|
|
},
|
|
]
|
|
|
|
def _make_project(self, tmp_path):
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
return project
|
|
|
|
def _patch_catalog(self, monkeypatch, integrations=None):
|
|
"""Return a stubbed `_get_merged_integrations` that yields *integrations*."""
|
|
from specify_cli.integrations.catalog import IntegrationCatalog
|
|
|
|
data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)
|
|
|
|
def fake_merged(self, force_refresh=False):
|
|
return data
|
|
|
|
monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)
|
|
|
|
def _invoke(self, argv, cwd):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
old = os.getcwd()
|
|
try:
|
|
os.chdir(cwd)
|
|
return runner.invoke(app, argv, catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old)
|
|
|
|
def test_integration_install_failure_reports_phase_target_and_rollback(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from specify_cli.integrations import INTEGRATION_REGISTRY
|
|
from specify_cli.integrations.base import IntegrationBase
|
|
|
|
class BrokenIntegration(IntegrationBase):
|
|
key = "broken-test"
|
|
config = {
|
|
"name": "Broken Test",
|
|
"folder": ".broken/",
|
|
"commands_subdir": "commands",
|
|
"install_url": None,
|
|
"requires_cli": False,
|
|
}
|
|
registrar_config = {
|
|
"dir": ".broken/commands",
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
}
|
|
context_file = "BROKEN.md"
|
|
|
|
def setup(self, project_root, manifest, **kwargs):
|
|
raise OSError("setup exploded\nwith context")
|
|
|
|
def teardown(self, project_root, manifest, force=False):
|
|
raise OSError("rollback exploded")
|
|
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())
|
|
|
|
result = self._invoke(["integration", "install", "broken-test"], project)
|
|
normalized = _normalize_cli_output(result.output)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Failed to rollback integration 'broken-test'" in normalized
|
|
assert "rollback exploded" in normalized
|
|
assert "Failed to install integration 'broken-test'" in normalized
|
|
assert "setup exploded with context" in normalized
|
|
|
|
def test_integration_upgrade_failure_reports_phase_and_target(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from specify_cli.integrations import INTEGRATION_REGISTRY
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
|
|
class UpgradeBrokenIntegration(CopilotIntegration):
|
|
key = "upgrade-broken"
|
|
config = dict(CopilotIntegration.config)
|
|
config["name"] = "Upgrade Broken"
|
|
|
|
def setup(self, project_root, manifest, **kwargs):
|
|
raise OSError("upgrade exploded\nwith context")
|
|
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setitem(
|
|
INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
|
|
)
|
|
|
|
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
|
|
(project / ".specify" / "integration.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"integration": "upgrade-broken",
|
|
"integrations": ["upgrade-broken"],
|
|
"integration_settings": {"upgrade-broken": {"script": "sh"}},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(
|
|
project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
|
|
).write_text(
|
|
json.dumps(
|
|
{
|
|
"integration": "upgrade-broken",
|
|
"version": "0.0.0",
|
|
"installed_at": "2026-05-16T00:00:00+00:00",
|
|
"files": {},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
|
|
normalized = _normalize_cli_output(result.output)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Failed to upgrade integration 'upgrade-broken'" in normalized
|
|
assert "upgrade exploded with context" in normalized
|
|
assert "previous integration files may still be in place" in normalized
|
|
|
|
def test_integration_switch_cleanup_warning_reports_phase_and_targets(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
project = self._make_project(tmp_path)
|
|
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
|
|
(project / ".specify" / "integration.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"integration": "copilot",
|
|
"integrations": ["copilot"],
|
|
"integration_settings": {"copilot": {"script": "sh"}},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"integration": "copilot",
|
|
"version": "0.0.0",
|
|
"installed_at": "2026-05-16T00:00:00+00:00",
|
|
"files": {},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def fail_cleanup(self, integration_key):
|
|
raise OSError("cleanup exploded")
|
|
|
|
monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)
|
|
|
|
result = self._invoke(["integration", "switch", "claude"], project)
|
|
normalized = _normalize_cli_output(result.output)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
|
|
assert "cleanup exploded" in normalized
|
|
assert "Switched to integration" in normalized
|
|
|
|
# -- Project guard -----------------------------------------------------
|
|
|
|
def test_search_requires_specify_project(self, tmp_path):
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
result = self._invoke(["integration", "search"], project)
|
|
assert result.exit_code == 1
|
|
assert "Not a spec-kit project" in result.output
|
|
|
|
def test_catalog_list_requires_specify_project(self, tmp_path):
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
result = self._invoke(["integration", "catalog", "list"], project)
|
|
assert result.exit_code == 1
|
|
assert "Not a spec-kit project" in result.output
|
|
|
|
def test_primary_integration_commands_require_specify_project(self, tmp_path):
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
commands = [
|
|
["integration", "list"],
|
|
["integration", "install", "codex"],
|
|
["integration", "use", "codex"],
|
|
["integration", "uninstall"],
|
|
["integration", "switch", "codex"],
|
|
["integration", "upgrade"],
|
|
]
|
|
|
|
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_integration_commands_require_specify_directory(self, tmp_path):
|
|
project = tmp_path / "bad"
|
|
project.mkdir()
|
|
(project / ".specify").write_text("not a directory")
|
|
|
|
commands = [
|
|
["integration", "list"],
|
|
["integration", "use", "codex"],
|
|
]
|
|
|
|
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", "list"],
|
|
["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
|
|
|
|
def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path):
|
|
project = self._make_project(tmp_path)
|
|
cfg_path = project / ".specify" / "extension-catalogs.yml"
|
|
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
|
|
|
|
result = self._invoke([
|
|
"extension", "catalog", "add",
|
|
"https://example.com/extension-catalog.yml",
|
|
"--name", "demo-extensions",
|
|
], project)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
output = _normalize_cli_output(result.output)
|
|
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
|
|
assert "expected a YAML mapping at the root" in output
|
|
assert "AttributeError" not in output
|
|
|
|
def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path):
|
|
project = self._make_project(tmp_path)
|
|
cfg_path = project / ".specify" / "extension-catalogs.yml"
|
|
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
|
|
|
|
result = self._invoke(["extension", "catalog", "remove", "demo"], project)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
output = _normalize_cli_output(result.output)
|
|
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
|
|
assert "expected a YAML mapping at the root" in output
|
|
assert "AttributeError" not in output
|
|
|
|
def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path):
|
|
project = self._make_project(tmp_path)
|
|
catalog_name = "[red]demo[/red]"
|
|
|
|
result = self._invoke([
|
|
"extension", "catalog", "add",
|
|
"https://example.com/extension-catalog.yml",
|
|
"--name", catalog_name,
|
|
], project)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
output = _normalize_cli_output(result.output)
|
|
assert f"Added catalog '{catalog_name}'" in output
|
|
|
|
def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path):
|
|
project = self._make_project(tmp_path)
|
|
catalog_name = "[red]demo[/red]"
|
|
cfg_path = project / ".specify" / "extension-catalogs.yml"
|
|
cfg_path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": catalog_name,
|
|
"url": "https://example.com/extension-catalog.yml",
|
|
"priority": 10,
|
|
"install_allowed": False,
|
|
"description": "",
|
|
}
|
|
]
|
|
},
|
|
sort_keys=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = self._invoke(["extension", "catalog", "remove", catalog_name], project)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
output = _normalize_cli_output(result.output)
|
|
assert f"Removed catalog '{catalog_name}'" in output
|
|
|
|
# -- search ------------------------------------------------------------
|
|
|
|
def test_search_lists_all(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Found 2 integration(s)" in result.output
|
|
assert "acme-coder" in result.output
|
|
assert "stellar-agent" in result.output
|
|
assert "specify integration install stellar-agent" not in normalized_output
|
|
assert "Only built-in integration IDs can be installed" in normalized_output
|
|
|
|
def test_search_validates_integration_json_before_catalog_lookup(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
(project / ".specify" / "integration.json").write_text(
|
|
"{bad json\n", encoding="utf-8"
|
|
)
|
|
|
|
from specify_cli.integrations.catalog import IntegrationCatalog
|
|
|
|
def fail_search(self, **kwargs):
|
|
raise AssertionError("catalog search should not be called")
|
|
|
|
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
|
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1
|
|
assert "contains invalid JSON" in normalized_output
|
|
assert "integration.json" in normalized_output
|
|
|
|
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""A non-UTF8 ``integration.json`` must surface a clear error and
|
|
avoid falling through to the catalog lookup, mirroring the malformed-JSON
|
|
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
|
|
project = self._make_project(tmp_path)
|
|
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
|
|
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
|
|
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
|
|
|
from specify_cli.integrations.catalog import IntegrationCatalog
|
|
|
|
def fail_search(self, **kwargs):
|
|
raise AssertionError("catalog search should not be called")
|
|
|
|
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
|
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1
|
|
assert "not valid UTF-8" in normalized_output
|
|
assert "integration.json" in normalized_output
|
|
|
|
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(["integration", "search", "--tag", "acme"], project)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Found 1 integration(s)" in result.output
|
|
assert "acme-coder" in result.output
|
|
assert "stellar-agent" not in result.output
|
|
|
|
def test_search_filters_by_author(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(
|
|
["integration", "search", "--author", "stellar-labs"], project
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Found 1 integration(s)" in result.output
|
|
assert "stellar-agent" in result.output
|
|
|
|
def test_search_no_match_hint(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(
|
|
["integration", "search", "--tag", "nope"], project
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "No integrations found" in result.output
|
|
assert "specify integration search" in result.output
|
|
|
|
def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(["integration", "search", "acme"], project)
|
|
assert result.exit_code == 0, result.output
|
|
# acme-coder is flagged _install_allowed=False, so we should warn
|
|
assert "Not directly installable" in result.output
|
|
|
|
# -- info --------------------------------------------------------------
|
|
|
|
def test_info_found(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(
|
|
["integration", "info", "stellar-agent"], project
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Stellar Agent" in result.output
|
|
assert "stellar-agent" in result.output
|
|
assert "v1.3.0" in result.output
|
|
|
|
def test_info_not_found(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
self._patch_catalog(monkeypatch)
|
|
result = self._invoke(
|
|
["integration", "info", "does-not-exist"], project
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "not found" in result.output
|
|
|
|
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
# Empty catalog, but copilot is a registered built-in.
|
|
self._patch_catalog(monkeypatch, integrations=[])
|
|
result = self._invoke(["integration", "info", "copilot"], project)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Built-in integration" in result.output
|
|
|
|
# -- validation vs network guidance ------------------------------------
|
|
|
|
def test_search_local_config_error_shows_local_config_tip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""`integration search` must point at .specify/integration-catalogs.yml
|
|
for local-config errors (not the generic 'temporarily unavailable')."""
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
# Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
|
|
cfg = project / ".specify" / "integration-catalogs.yml"
|
|
invalid_yaml = "catalogs:\n - [bad\n"
|
|
cfg.write_text(invalid_yaml, encoding="utf-8")
|
|
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1, result.output
|
|
assert "configuration file path shown above" in normalized_output
|
|
assert ".specify/integration-catalogs.yml" in normalized_output
|
|
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
|
assert "temporarily unavailable" not in normalized_output
|
|
|
|
def test_search_invalid_env_catalog_url_shows_env_tip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv(
|
|
"SPECKIT_INTEGRATION_CATALOG_URL",
|
|
"http://insecure.example.com/catalog.json",
|
|
)
|
|
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1, result.output
|
|
assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
|
|
assert "unset it to use the configured catalog files" in normalized_output
|
|
assert ".specify/integration-catalogs.yml" in normalized_output
|
|
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
|
assert "temporarily unavailable" not in normalized_output
|
|
|
|
def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ")
|
|
|
|
from specify_cli.integrations.catalog import (
|
|
IntegrationCatalog,
|
|
IntegrationCatalogError,
|
|
)
|
|
|
|
def fail_search(self, **kwargs):
|
|
raise IntegrationCatalogError("catalog offline")
|
|
|
|
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
|
|
|
result = self._invoke(["integration", "search"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1, result.output
|
|
assert "temporarily unavailable" in normalized_output
|
|
assert (
|
|
"SPECKIT_INTEGRATION_CATALOG_URL environment variable"
|
|
not in normalized_output
|
|
)
|
|
|
|
def test_info_unknown_with_local_config_error_shows_local_config_tip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""`integration info <unknown>` falls back to the catalog-error branch
|
|
and must show local-config guidance, not 'Try again when online'."""
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
cfg = project / ".specify" / "integration-catalogs.yml"
|
|
invalid_yaml = "catalogs:\n - [bad\n"
|
|
cfg.write_text(invalid_yaml, encoding="utf-8")
|
|
|
|
result = self._invoke(
|
|
["integration", "info", "definitely-not-real"], project
|
|
)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1, result.output
|
|
assert "configuration file path shown above" in normalized_output
|
|
assert ".specify/integration-catalogs.yml" in normalized_output
|
|
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
|
assert "Try again when online" not in normalized_output
|
|
|
|
def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv(
|
|
"SPECKIT_INTEGRATION_CATALOG_URL",
|
|
"http://insecure.example.com/catalog.json",
|
|
)
|
|
|
|
result = self._invoke(
|
|
["integration", "info", "definitely-not-real"], project
|
|
)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 1, result.output
|
|
assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
|
|
assert "unset it to use the configured catalog files" in normalized_output
|
|
assert "Try again when online" not in normalized_output
|
|
|
|
# -- catalog list / add / remove ---------------------------------------
|
|
|
|
def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
result = self._invoke(["integration", "catalog", "list"], project)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Integration Catalog Sources" in result.output
|
|
assert "No project-level catalog sources configured" in result.output
|
|
assert "Active catalog sources" in result.output
|
|
assert "non-removable" in result.output
|
|
assert "default" in result.output
|
|
assert "community" in result.output
|
|
# Built-in defaults are active, but not removable project entries.
|
|
assert "[0]" not in result.output
|
|
assert "[1]" not in result.output
|
|
|
|
def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
|
|
add_result = self._invoke(
|
|
[
|
|
"integration",
|
|
"catalog",
|
|
"add",
|
|
"https://new.example.com/catalog.json",
|
|
"--name",
|
|
"mine",
|
|
],
|
|
project,
|
|
)
|
|
assert add_result.exit_code == 0, add_result.output
|
|
assert "Catalog source added" in add_result.output
|
|
|
|
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
|
assert cfg_path.exists()
|
|
|
|
list_result = self._invoke(["integration", "catalog", "list"], project)
|
|
assert list_result.exit_code == 0, list_result.output
|
|
assert "Project catalog sources" in list_result.output
|
|
assert "[0]" in list_result.output
|
|
assert "mine" in list_result.output
|
|
assert "default" not in list_result.output
|
|
assert "community" not in list_result.output
|
|
|
|
remove_result = self._invoke(
|
|
["integration", "catalog", "remove", "0"], project
|
|
)
|
|
assert remove_result.exit_code == 0, remove_result.output
|
|
assert "'mine' removed" in remove_result.output
|
|
|
|
def test_catalog_list_normalizes_blank_project_catalog_names(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
|
cfg_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"url": "https://null-name.example.com/catalog.json",
|
|
"name": None,
|
|
},
|
|
{
|
|
"url": "https://blank-name.example.com/catalog.json",
|
|
"name": " ",
|
|
},
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = self._invoke(["integration", "catalog", "list"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "[0] catalog-1" in normalized_output
|
|
assert "[1] catalog-2" in normalized_output
|
|
assert "None" not in normalized_output
|
|
|
|
def test_catalog_list_env_override_supersedes_project_config(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.setenv(
|
|
"SPECKIT_INTEGRATION_CATALOG_URL",
|
|
"https://env.example.com/catalog.json",
|
|
)
|
|
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
|
cfg_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"url": "https://project.example.com/catalog.json",
|
|
"name": "project",
|
|
"priority": 1,
|
|
}
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = self._invoke(["integration", "catalog", "list"], project)
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 0, result.output
|
|
assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
|
|
assert "supersedes configured catalog files" in normalized_output
|
|
assert "non-removable" in normalized_output
|
|
assert "https://env.example.com/catalog.json" in normalized_output
|
|
assert "https://project.example.com/catalog.json" not in normalized_output
|
|
assert "[0]" not in normalized_output
|
|
|
|
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Surrounding whitespace in the URL must not appear in the success
|
|
message or be persisted to the YAML config."""
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
|
|
padded_url = " https://padded.example.com/catalog.json "
|
|
clean_url = "https://padded.example.com/catalog.json"
|
|
|
|
add_result = self._invoke(
|
|
[
|
|
"integration",
|
|
"catalog",
|
|
"add",
|
|
padded_url,
|
|
"--name",
|
|
"padded",
|
|
],
|
|
project,
|
|
)
|
|
assert add_result.exit_code == 0, add_result.output
|
|
assert clean_url in add_result.output
|
|
assert padded_url not in add_result.output
|
|
|
|
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
|
import yaml as _yaml
|
|
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
|
urls = [c["url"] for c in data["catalogs"]]
|
|
assert clean_url in urls
|
|
assert padded_url not in urls
|
|
|
|
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
result = self._invoke(
|
|
[
|
|
"integration",
|
|
"catalog",
|
|
"add",
|
|
"http://insecure.example.com/catalog.json",
|
|
],
|
|
project,
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "HTTPS" in result.output
|
|
|
|
def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
url = "https://dup.example.com/catalog.json"
|
|
first = self._invoke(
|
|
["integration", "catalog", "add", url], project
|
|
)
|
|
assert first.exit_code == 0, first.output
|
|
second = self._invoke(
|
|
["integration", "catalog", "add", url], project
|
|
)
|
|
assert second.exit_code == 1
|
|
assert "already configured" in second.output
|
|
|
|
def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
# Need a config file for remove to attempt an index lookup
|
|
self._invoke(
|
|
[
|
|
"integration",
|
|
"catalog",
|
|
"add",
|
|
"https://only.example.com/catalog.json",
|
|
],
|
|
project,
|
|
)
|
|
result = self._invoke(
|
|
["integration", "catalog", "remove", "9"], project
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "out of range" in result.output
|
|
|
|
def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
|
|
project = self._make_project(tmp_path)
|
|
result = self._invoke(
|
|
["integration", "catalog", "remove", "0"], project
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "No catalog config" in result.output
|
|
|
|
def test_catalog_remove_final_entry_restores_defaults(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""End-to-end: add → remove-last-entry → list should not error.
|
|
|
|
Regression for the flow where a user adds a catalog, removes it, then
|
|
runs any follow-up integration command. Without the fix the config
|
|
file would be left as `catalogs: []` and every subsequent
|
|
`integration` call would fail with "contains no 'catalogs' entries".
|
|
"""
|
|
project = self._make_project(tmp_path)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
|
|
|
add = self._invoke(
|
|
[
|
|
"integration",
|
|
"catalog",
|
|
"add",
|
|
"https://only.example.com/catalog.json",
|
|
"--name",
|
|
"only",
|
|
],
|
|
project,
|
|
)
|
|
assert add.exit_code == 0, add.output
|
|
|
|
remove = self._invoke(
|
|
["integration", "catalog", "remove", "0"], project
|
|
)
|
|
assert remove.exit_code == 0, remove.output
|
|
assert "'only' removed" in remove.output
|
|
|
|
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
|
assert not cfg_path.exists(), (
|
|
"config file should be deleted when the final catalog is removed"
|
|
)
|
|
|
|
# Follow-up command must succeed and show the built-in defaults,
|
|
# not error out on "contains no 'catalogs' entries".
|
|
listing = self._invoke(["integration", "catalog", "list"], project)
|
|
assert listing.exit_code == 0, listing.output
|
|
assert "default" in listing.output
|
|
assert "community" in listing.output
|