mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2076 lines
85 KiB
Python
2076 lines
85 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"
|
|
# init must not leave any legacy agent-context keys in init-options.json
|
|
assert "context_file" not in opts
|
|
|
|
# agent-context is fully opt-in: init must not install it or write its config
|
|
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
|
assert not ext_cfg_path.exists(), "init must not create the agent-context extension config"
|
|
|
|
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
|
|
|
# init must not create or manage the agent context file
|
|
assert not (project / ".github" / "copilot-instructions.md").exists()
|
|
|
|
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_removes_stale_managed_script(self, tmp_path):
|
|
"""A managed script the core no longer ships (e.g. the legacy
|
|
update-agent-context.sh, superseded by the agent-context extension) is
|
|
removed, and the manifest stops tracking it (#3076)."""
|
|
from specify_cli import _install_shared_infra
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "stale-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
|
|
# Legacy orphan the current bundle no longer ships, recorded in the
|
|
# manifest as a managed file (hash matches on disk) — a pre-refactor install.
|
|
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
|
(scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("speckit", project, version="test")
|
|
manifest.record_existing(stale_rel)
|
|
manifest.save()
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# The orphan is gone and the manifest no longer tracks it.
|
|
assert not (scripts_dir / "update-agent-context.sh").exists()
|
|
refreshed = IntegrationManifest.load("speckit", project)
|
|
assert stale_rel not in refreshed.files
|
|
# Scripts the core DOES ship are installed and tracked.
|
|
assert (scripts_dir / "common.sh").exists()
|
|
assert ".specify/scripts/bash/common.sh" in refreshed.files
|
|
|
|
def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
|
|
"""A user-modified stale script is preserved (hash diverges from the
|
|
managed baseline), never silently deleted (#3076)."""
|
|
from specify_cli import _install_shared_infra
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "stale-modified"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
|
|
stale = scripts_dir / "update-agent-context.sh"
|
|
stale.write_text("# original managed\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("speckit", project, version="test")
|
|
manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
|
|
manifest.save()
|
|
|
|
# User customizes it after install → on-disk hash now diverges.
|
|
stale.write_text("# user customization\n", encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# Preserved: it is no longer a managed (hash-matching) copy.
|
|
assert stale.exists()
|
|
assert stale.read_text(encoding="utf-8") == "# user customization\n"
|
|
|
|
def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
|
|
"""A stale manifest entry whose file is already gone from disk is pruned
|
|
so the manifest stays consistent, not left tracked forever (#3076 review)."""
|
|
from specify_cli import _install_shared_infra
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "orphan-entry"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
|
|
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
|
stale = scripts_dir / "update-agent-context.sh"
|
|
stale.write_text("# legacy orphan\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("speckit", project, version="test")
|
|
manifest.record_existing(stale_rel)
|
|
manifest.save()
|
|
# File removed out of band, but the manifest still tracks it.
|
|
stale.unlink()
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
refreshed = IntegrationManifest.load("speckit", project)
|
|
assert stale_rel not in refreshed.files
|
|
|
|
def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
|
|
"""If the bundle's script source dir exists but is empty, stale-cleanup
|
|
must NOT run (no source files seen → can't tell what's obsolete): a
|
|
previously-tracked script is preserved, never mass-deleted (#3076 review)."""
|
|
from specify_cli import _install_shared_infra, shared_infra
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
# Point the script source at an empty ``bash/`` directory.
|
|
empty_src = tmp_path / "empty-bundle" / "scripts"
|
|
(empty_src / "bash").mkdir(parents=True)
|
|
monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)
|
|
|
|
project = tmp_path / "empty-source"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
tracked_rel = ".specify/scripts/bash/common.sh"
|
|
(scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("speckit", project, version="test")
|
|
manifest.record_existing(tracked_rel)
|
|
manifest.save()
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# Empty source → scripts_scanned stays False → nothing deleted.
|
|
assert (scripts_dir / "common.sh").exists()
|
|
refreshed = IntegrationManifest.load("speckit", project)
|
|
assert tracked_rel in refreshed.files
|
|
|
|
def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
|
|
"""A corrupted/hand-edited manifest key with a ``..`` segment is skipped
|
|
before any filesystem access — its traversal target is never deleted
|
|
(#3076 review, containment guard)."""
|
|
import hashlib
|
|
import json
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "unsafe-key"
|
|
project.mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
manifest_dir = project / ".specify" / "integrations"
|
|
manifest_dir.mkdir(parents=True)
|
|
|
|
# A file the traversal key would resolve to (outside scripts/bash/).
|
|
victim = project / ".specify" / "scripts" / "keep-me.sh"
|
|
victim_bytes = b"# do not touch\n"
|
|
victim.write_bytes(victim_bytes)
|
|
|
|
# Hand-crafted manifest: a key under the script prefix but with a ``..``
|
|
# segment, with the *matching* hash so that — absent the containment guard
|
|
# — stale-cleanup would consider it managed and unlink the target.
|
|
traversal_key = ".specify/scripts/bash/../keep-me.sh"
|
|
(manifest_dir / "speckit.manifest.json").write_text(
|
|
json.dumps({
|
|
"integration": "speckit",
|
|
"version": "test",
|
|
"files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# The unsafe key was skipped; its target file is untouched.
|
|
assert victim.exists()
|
|
assert victim.read_bytes() == victim_bytes
|
|
|
|
def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""A key that passes the lexical guard but escapes containment — e.g. a
|
|
Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
|
|
the project root when joined — is skipped via ``_validate_rel_path``, never
|
|
unlinked, and never turned into an install-time hard failure (#3076 review
|
|
round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
|
|
managed key, since real drive-relative paths only escape on Windows."""
|
|
from specify_cli import _install_shared_infra
|
|
from specify_cli.integrations import manifest as manifest_mod
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "escaping-key"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
|
|
# A managed stale orphan that would normally be removed.
|
|
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
|
stale = scripts_dir / "update-agent-context.sh"
|
|
stale.write_text("# legacy orphan\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("speckit", project, version="test")
|
|
manifest.record_existing(stale_rel)
|
|
manifest.save()
|
|
|
|
# Force the containment check to reject this key, as it would for a
|
|
# drive-relative escape on Windows. The cleanup must skip it gracefully.
|
|
real_validate = manifest_mod._validate_rel_path
|
|
|
|
def fake_validate(rel, root):
|
|
if str(rel).endswith("update-agent-context.sh"):
|
|
raise ValueError("simulated drive-relative escape")
|
|
return real_validate(rel, root)
|
|
|
|
monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)
|
|
|
|
# Must not raise (no install-time hard failure from a corrupted key).
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# The escaping key was skipped, so its file is left untouched...
|
|
assert stale.exists()
|
|
assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
|
|
# ...yet the install otherwise completed: real scripts are installed.
|
|
assert (scripts_dir / "common.sh").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",
|
|
}
|
|
|
|
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
|