mirror of
https://github.com/github/spec-kit.git
synced 2026-07-05 13:34:06 +08:00
* refactor(integrations): co-locate integration commands in integrations/ domain dir
- Remove commands/ stubs (handlers will live in domain dirs)
- Move all integration CLI handlers out of __init__.py into integrations/
- Split into focused modules under integrations/:
_helpers.py (340 lines) — domain helpers
_install_commands.py (306 lines) — install / uninstall
_migrate_commands.py (487 lines) — switch / upgrade
_query_commands.py (442 lines) — list / use / search / info / catalog
_commands.py (34 lines) — app objects + register()
- __init__.py reduced by ~1400 lines; integration block replaced with register() call
- Fix patch paths in tests to new module locations
* fix(integrations): restore original integration list output in refactor
Preserve the CLI Required column, post-table default/installed summary,
and no-installed guidance that were dropped during the no-behavior-change
refactor of integration list into _query_commands.py.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(integrations): restore _clear/_update_init_options public imports
The refactor that split integration commands moved
_clear_init_options_for_integration and _update_init_options_for_integration
into integrations/_helpers.py, but tests still import them from the top-level
specify_cli package, causing ImportError. Re-export them with explicit aliases
at the end of __init__.py to preserve the public import surface.
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
491 lines
21 KiB
Python
491 lines
21 KiB
Python
"""specify integration switch / upgrade command handlers."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import typer
|
|
|
|
from .._console import console
|
|
from ..integration_runtime import (
|
|
invoke_separator_for_integration as _invoke_separator_for_integration,
|
|
with_integration_setting as _with_integration_setting,
|
|
)
|
|
from ..integration_state import (
|
|
dedupe_integration_keys as _dedupe_integration_keys,
|
|
default_integration_key as _default_integration_key,
|
|
installed_integration_keys as _installed_integration_keys,
|
|
integration_settings as _integration_settings,
|
|
)
|
|
from ._commands import integration_app
|
|
from ._helpers import (
|
|
_MANIFEST_READ_ERRORS,
|
|
_SharedTemplateRefreshError,
|
|
_clear_init_options_for_integration,
|
|
_cli_error_detail,
|
|
_cli_phase_label,
|
|
_get_speckit_version,
|
|
_read_integration_json,
|
|
_refresh_init_options_speckit_version,
|
|
_remove_integration_json,
|
|
_resolve_integration_options,
|
|
_resolve_integration_script_type,
|
|
_resolve_script_type,
|
|
_set_default_integration,
|
|
_set_default_integration_or_exit,
|
|
_update_init_options_for_integration,
|
|
_write_integration_json,
|
|
)
|
|
|
|
|
|
@integration_app.command("switch")
|
|
def integration_switch(
|
|
target: str = typer.Argument(help="Integration key to switch to"),
|
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
|
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
|
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
|
):
|
|
"""Switch from the current integration to a different one."""
|
|
from . import INTEGRATION_REGISTRY, get_integration
|
|
from .manifest import IntegrationManifest
|
|
from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit
|
|
|
|
project_root = _require_specify_project()
|
|
target_integration = get_integration(target)
|
|
if target_integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
|
|
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
|
console.print(f"Available integrations: {available}")
|
|
raise typer.Exit(1)
|
|
|
|
current = _read_integration_json(project_root)
|
|
installed_keys = _installed_integration_keys(current)
|
|
installed_key = _default_integration_key(current)
|
|
|
|
if installed_key == target:
|
|
if integration_options is not None:
|
|
console.print(
|
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
|
"to an already installed integration."
|
|
)
|
|
console.print(
|
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
|
"to update managed files/options."
|
|
)
|
|
raise typer.Exit(1)
|
|
if force:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
target,
|
|
target_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
refresh_templates_force=True,
|
|
)
|
|
console.print(
|
|
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
|
"shared infrastructure refreshed."
|
|
)
|
|
raise typer.Exit(0)
|
|
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
if target in installed_keys:
|
|
if integration_options is not None:
|
|
console.print(
|
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
|
"to an already installed integration."
|
|
)
|
|
console.print(
|
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
|
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
|
|
)
|
|
raise typer.Exit(1)
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
target,
|
|
target_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
refresh_templates_force=force,
|
|
)
|
|
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
|
|
raise typer.Exit(0)
|
|
|
|
selected_script = _resolve_script_type(project_root, script)
|
|
|
|
# Phase 1: Uninstall current integration (if any)
|
|
if installed_key:
|
|
current_integration = get_integration(installed_key)
|
|
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
|
|
|
|
if current_integration and manifest_path.exists():
|
|
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
|
|
try:
|
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
|
|
console.print(f"[dim]{exc}[/dim]")
|
|
console.print(
|
|
f"To recover, delete the unreadable manifest at {manifest_path}, "
|
|
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
|
)
|
|
raise typer.Exit(1)
|
|
removed, skipped = current_integration.teardown(
|
|
project_root, old_manifest, force=force,
|
|
)
|
|
if removed:
|
|
console.print(f" Removed {len(removed)} file(s)")
|
|
if skipped:
|
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
|
elif not current_integration and manifest_path.exists():
|
|
# Integration removed from registry but manifest exists — use manifest-only uninstall
|
|
console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
|
|
try:
|
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
|
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
|
if removed:
|
|
console.print(f" Removed {len(removed)} file(s)")
|
|
if skipped:
|
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
|
|
console.print(
|
|
f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
|
|
f"then retry [cyan]specify integration switch {target}[/cyan]."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# Unregister extension commands for the old agent so they don't
|
|
# remain as orphans in the old agent's directory.
|
|
try:
|
|
from ..extensions import ExtensionManager
|
|
|
|
ext_mgr = ExtensionManager(project_root)
|
|
ext_mgr.unregister_agent_artifacts(installed_key)
|
|
except Exception as ext_err:
|
|
_print_cli_warning(
|
|
"clean up extension artifacts for",
|
|
"integration",
|
|
installed_key,
|
|
ext_err,
|
|
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
|
)
|
|
|
|
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
|
installed_keys = [installed for installed in installed_keys if installed != installed_key]
|
|
_clear_init_options_for_integration(project_root, installed_key)
|
|
if installed_keys:
|
|
fallback_key = installed_keys[0]
|
|
fallback_integration = get_integration(fallback_key)
|
|
if fallback_integration is not None:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
fallback_integration, current, fallback_key, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
fallback_key,
|
|
fallback_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
current = _read_integration_json(project_root)
|
|
|
|
# Build parsed options from --integration-options so the integration
|
|
# can determine its effective invoke separator before shared infra
|
|
# is installed.
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, integration_options
|
|
)
|
|
|
|
# Refresh shared infrastructure to the current CLI version. Switching
|
|
# integrations is exactly when stale vendored shared scripts (e.g.
|
|
# update-agent-context.sh that pre-dates the target integration's
|
|
# supported-agent list) would silently break the new integration.
|
|
#
|
|
# Use refresh_managed=True so only files that match their previously
|
|
# recorded hash are overwritten — user customizations are detected via
|
|
# hash divergence and preserved with a warning. Pass
|
|
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
|
_install_shared_infra_or_exit(
|
|
project_root,
|
|
selected_script,
|
|
force=refresh_shared_infra,
|
|
refresh_managed=True,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
target_integration, current, target, parsed_options
|
|
),
|
|
refresh_hint=(
|
|
"To overwrite customizations, re-run with "
|
|
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
|
),
|
|
)
|
|
if os.name != "nt":
|
|
from .. import ensure_executable_scripts
|
|
ensure_executable_scripts(project_root)
|
|
|
|
# Phase 2: Install target integration
|
|
console.print(f"Installing integration: [cyan]{target}[/cyan]")
|
|
manifest = IntegrationManifest(
|
|
target_integration.key, project_root, version=_get_speckit_version()
|
|
)
|
|
|
|
try:
|
|
target_integration.setup(
|
|
project_root, manifest,
|
|
parsed_options=parsed_options,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
)
|
|
manifest.save()
|
|
_set_default_integration(
|
|
project_root,
|
|
current,
|
|
target_integration.key,
|
|
target_integration,
|
|
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
|
|
# Re-register extension commands for the new agent so that
|
|
# previously-installed extensions are available in the new integration.
|
|
try:
|
|
from ..extensions import ExtensionManager
|
|
|
|
ext_mgr = ExtensionManager(project_root)
|
|
ext_mgr.register_enabled_extensions_for_agent(target)
|
|
except Exception as ext_err:
|
|
_print_cli_warning(
|
|
"register extension artifacts for",
|
|
"integration",
|
|
target,
|
|
ext_err,
|
|
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
|
)
|
|
|
|
except Exception as exc:
|
|
# Attempt rollback of any files written by setup
|
|
try:
|
|
target_integration.teardown(project_root, manifest, force=True)
|
|
except Exception as rollback_err:
|
|
# Suppress so the original setup error remains the primary failure
|
|
_print_cli_warning(
|
|
"rollback",
|
|
"integration",
|
|
target,
|
|
rollback_err,
|
|
continuing="The original switch failure is still the primary error.",
|
|
)
|
|
if installed_keys:
|
|
fallback_key = installed_keys[0]
|
|
fallback_integration = get_integration(fallback_key)
|
|
if fallback_integration is not None:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
fallback_integration, current, fallback_key, None
|
|
)
|
|
try:
|
|
_set_default_integration(
|
|
project_root,
|
|
current,
|
|
fallback_key,
|
|
fallback_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
except _SharedTemplateRefreshError as restore_err:
|
|
console.print(
|
|
f"[yellow]Warning:[/yellow] Failed to restore default "
|
|
f"integration '{fallback_key}': {restore_err}"
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
console.print(
|
|
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
|
|
f"during switch: {_cli_error_detail(exc)}"
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
name = (target_integration.config or {}).get("name", target)
|
|
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
|
|
|
|
|
@integration_app.command("upgrade")
|
|
def integration_upgrade(
|
|
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
|
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
|
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
|
):
|
|
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
|
|
|
Compares manifest hashes to detect locally modified files and
|
|
blocks the upgrade unless --force is used.
|
|
"""
|
|
from . import get_integration
|
|
from .manifest import IntegrationManifest
|
|
from .. import _require_specify_project, _install_shared_infra_or_exit, _install_shared_infra
|
|
|
|
project_root = _require_specify_project()
|
|
current = _read_integration_json(project_root)
|
|
installed_key = _default_integration_key(current)
|
|
installed_keys = _installed_integration_keys(current)
|
|
|
|
if key is None:
|
|
if not installed_key:
|
|
console.print("[yellow]No integration is currently installed.[/yellow]")
|
|
raise typer.Exit(0)
|
|
key = installed_key
|
|
|
|
if key not in installed_keys:
|
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
|
raise typer.Exit(1)
|
|
|
|
integration = get_integration(key)
|
|
if integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
|
raise typer.Exit(1)
|
|
|
|
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
|
if not manifest_path.exists():
|
|
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
|
|
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
|
|
raise typer.Exit(0)
|
|
|
|
try:
|
|
old_manifest = IntegrationManifest.load(key, project_root)
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
# Detect modified files via manifest hashes
|
|
modified = old_manifest.check_modified()
|
|
if modified and not force:
|
|
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
|
|
for rel in modified:
|
|
console.print(f" {rel}")
|
|
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
|
|
raise typer.Exit(1)
|
|
|
|
selected_script = _resolve_integration_script_type(project_root, current, key, script)
|
|
|
|
# Build parsed options from --integration-options so the integration
|
|
# can determine its effective invoke separator before shared infra
|
|
# is installed.
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
integration, current, key, integration_options
|
|
)
|
|
|
|
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
|
infra_integration = integration
|
|
infra_key = key
|
|
infra_parsed = parsed_options
|
|
if installed_key and installed_key != key:
|
|
default_integration = get_integration(installed_key)
|
|
if default_integration is not None:
|
|
infra_integration = default_integration
|
|
infra_key = installed_key
|
|
_, infra_parsed = _resolve_integration_options(
|
|
default_integration, current, installed_key, None
|
|
)
|
|
_install_shared_infra_or_exit(
|
|
project_root,
|
|
selected_script,
|
|
force=force,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
infra_integration, current, infra_key, infra_parsed
|
|
),
|
|
)
|
|
if os.name != "nt":
|
|
from .. import ensure_executable_scripts
|
|
ensure_executable_scripts(project_root)
|
|
|
|
# Phase 1: Install new files (overwrites existing; old-only files remain)
|
|
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
|
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
|
|
|
|
try:
|
|
integration.setup(
|
|
project_root,
|
|
new_manifest,
|
|
parsed_options=parsed_options,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
)
|
|
settings = _with_integration_setting(
|
|
current,
|
|
key,
|
|
integration,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
if installed_key == key:
|
|
try:
|
|
_install_shared_infra(
|
|
project_root,
|
|
selected_script,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
integration, {"integration_settings": settings}, key, parsed_options
|
|
),
|
|
force=force,
|
|
refresh_managed=True,
|
|
)
|
|
except (ValueError, OSError) as exc:
|
|
raise _SharedTemplateRefreshError(
|
|
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
|
) from exc
|
|
new_manifest.save()
|
|
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
|
if installed_key == key:
|
|
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
|
else:
|
|
_refresh_init_options_speckit_version(project_root)
|
|
except Exception as exc:
|
|
# Don't teardown — setup overwrites in-place, so teardown would
|
|
# delete files that were working before the upgrade. Just report.
|
|
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
|
|
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
|
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
|
raise typer.Exit(1)
|
|
|
|
# Phase 2: Remove stale files from old manifest that are not in the new one
|
|
old_files = old_manifest.files
|
|
new_files = new_manifest.files
|
|
stale_keys = set(old_files) - set(new_files)
|
|
if stale_keys:
|
|
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
|
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
|
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
|
if stale_removed:
|
|
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
|
|
|
name = (integration.config or {}).get("name", key)
|
|
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|