cortexapps-cli 1.22.0__tar.gz → 1.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/PKG-INFO +1 -1
- cortexapps_cli-1.23.0/cortexapps_cli/commands/solutions.py +682 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/cortex_client.py +23 -7
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/.github/workflows/cortex-service-version.yaml +57 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/README.md +95 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.0.1.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.1.0.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.1.1.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway.yaml +12 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.1.0.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.1.1.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.2.0.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth.yaml +12 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/aws-dev-us-west-2.yaml +15 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing-1.1.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing-1.2.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/cart-1.0.2.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/cart.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.2.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.3.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.4.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout.yaml +12 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-prod-us-east-1.yaml +15 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-prod-us-west-1.yaml +15 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-staging-us-east-1.yaml +15 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.0.0.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.0.1.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.1.0.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications.yaml +12 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service-2.0.1.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service-2.1.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.6.1.yaml +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.7.0.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.7.1.yaml +17 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments.yaml +13 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-06-20.yaml +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-01.yaml +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-09.yaml +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-10.yaml +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search-4.0.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search-4.1.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service-1.2.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service-1.3.0.yaml +10 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service.yaml +11 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-relationship-types/environments.json +26 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-relationship-types/versions.json +20 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/environment.json +1 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/release.json +1 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/service-version.json +1 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/create-environment.yaml +71 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/create-release.yaml +88 -0
- cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/trigger-github-release.yaml +26 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/pyproject.toml +1 -1
- cortexapps_cli-1.22.0/cortexapps_cli/commands/solutions.py +0 -190
- cortexapps_cli-1.22.0/cortexapps_cli/solutions/github-starter/README.md +0 -50
- cortexapps_cli-1.22.0/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml +0 -15
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/LICENSE +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/README.rst +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/__init__.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/cli.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/command_options.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/api_keys.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/audit_logs.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/backup.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/backup_commands/cortex_export.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/catalog.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_data.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_events.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_metrics.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/dependencies.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/deploys.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/discovery_audit.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/docs.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_relationship_types.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_relationships.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_types.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/gitops_logs.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/groups.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/initiatives.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/apiiro.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/argocd.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/aws.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_active_directory.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_devops.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_resources.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bamboohr.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bitbucket.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bugsnag.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/buildkite.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/checkmarx_sast.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/circleci.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/clickup.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/codecov.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/coralogix.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/datadog.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/dynatrace.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/firehydrant.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/github.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/gitlab.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/incidentio.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/instana.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/jenkins.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/jira.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/launchdarkly.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/mend_sast.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/mend_sca.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/newrelic.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/okta.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/opsgenie.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/pagerduty.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/prometheus.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/rollbar.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/rootly.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/semgrep.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sentry.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/servicenow.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/servicenow_cloud_observability.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/snyk.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sonarqube.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/splunk_observability_cloud.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/splunk_on_call.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sumologic.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/veracode.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/wiz.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/workday.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/xmatters.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/ip_allowlist.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/on_call.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/go.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/java.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/node.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/nuget.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/python.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/plugins.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/queries.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/rest.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scim.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scorecards.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scorecards_commands/exemptions.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/secrets.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/teams.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/users.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/users_commands/roles.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/workflows.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/models/team.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/solutions/__init__.py +0 -0
- {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/utils.py +0 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from importlib.resources import as_file, files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
import yaml
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markdown import Markdown
|
|
18
|
+
from rich.prompt import Prompt
|
|
19
|
+
from rich.rule import Rule
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.theme import Theme
|
|
22
|
+
|
|
23
|
+
from cortexapps_cli.cortex_client import CortexClient
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import select as _select
|
|
27
|
+
import termios as _termios
|
|
28
|
+
import tty as _tty
|
|
29
|
+
_TTY_SUPPORT = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_TTY_SUPPORT = False
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(help="Solutions commands", no_args_is_help=True)
|
|
34
|
+
console = Console(theme=Theme({"markdown.code": "cyan"}))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ToggleableCapture:
|
|
38
|
+
"""Stdout proxy that buffers all output and optionally mirrors it live."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, real_stdout):
|
|
41
|
+
self._real = real_stdout
|
|
42
|
+
self._buf = io.StringIO()
|
|
43
|
+
self._on = False
|
|
44
|
+
self._shown_pos = 0 # chars already written to real_stdout
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
def write(self, text: str) -> int:
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._buf.write(text)
|
|
50
|
+
if self._on:
|
|
51
|
+
self._real.write(text)
|
|
52
|
+
self._real.flush()
|
|
53
|
+
self._shown_pos += len(text)
|
|
54
|
+
return len(text)
|
|
55
|
+
|
|
56
|
+
def flush(self) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def toggle(self) -> bool:
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._on = not self._on
|
|
62
|
+
if self._on:
|
|
63
|
+
# Flush everything buffered since the last toggle-on
|
|
64
|
+
content = self._buf.getvalue()
|
|
65
|
+
pending = content[self._shown_pos:]
|
|
66
|
+
if pending:
|
|
67
|
+
self._real.write(pending)
|
|
68
|
+
self._real.flush()
|
|
69
|
+
self._shown_pos = len(content)
|
|
70
|
+
return self._on
|
|
71
|
+
|
|
72
|
+
def getvalue(self) -> str:
|
|
73
|
+
return self._buf.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_import_with_toggle(fn) -> str:
|
|
77
|
+
"""Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output."""
|
|
78
|
+
real_stdout = sys.stdout
|
|
79
|
+
capture = _ToggleableCapture(real_stdout)
|
|
80
|
+
|
|
81
|
+
if not (_TTY_SUPPORT and sys.stdin.isatty()):
|
|
82
|
+
with contextlib.redirect_stdout(capture):
|
|
83
|
+
fn()
|
|
84
|
+
return capture.getvalue()
|
|
85
|
+
|
|
86
|
+
done = threading.Event()
|
|
87
|
+
fd = sys.stdin.fileno()
|
|
88
|
+
old_settings = _termios.tcgetattr(fd)
|
|
89
|
+
|
|
90
|
+
def _listen() -> None:
|
|
91
|
+
try:
|
|
92
|
+
# setcbreak() keeps IEXTEN, which makes the kernel intercept \x0f
|
|
93
|
+
# as VDISCARD before it reaches the app. Manually disable ECHO,
|
|
94
|
+
# ICANON, and IEXTEN while keeping OPOST (output processing) and
|
|
95
|
+
# ISIG (so Ctrl+c still works).
|
|
96
|
+
mode = _termios.tcgetattr(fd)
|
|
97
|
+
mode[3] &= ~(_termios.ECHO | _termios.ICANON | _termios.IEXTEN)
|
|
98
|
+
mode[6][_termios.VMIN] = 1
|
|
99
|
+
mode[6][_termios.VTIME] = 0
|
|
100
|
+
_termios.tcsetattr(fd, _termios.TCSAFLUSH, mode)
|
|
101
|
+
while not done.is_set():
|
|
102
|
+
r, _, _ = _select.select([fd], [], [], 0.05)
|
|
103
|
+
if r:
|
|
104
|
+
ch = os.read(fd, 1)
|
|
105
|
+
if ch == b"\x0f": # Ctrl+o
|
|
106
|
+
on = capture.toggle()
|
|
107
|
+
label = "on" if on else "off"
|
|
108
|
+
real_stdout.write(f" -- output {label} --\n")
|
|
109
|
+
real_stdout.flush()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
t = threading.Thread(target=_listen, daemon=True)
|
|
114
|
+
t.start()
|
|
115
|
+
try:
|
|
116
|
+
with contextlib.redirect_stdout(capture):
|
|
117
|
+
fn()
|
|
118
|
+
finally:
|
|
119
|
+
done.set()
|
|
120
|
+
t.join(timeout=0.5)
|
|
121
|
+
try:
|
|
122
|
+
_termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
return capture.getvalue()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.callback()
|
|
130
|
+
def solutions_callback(
|
|
131
|
+
ctx: typer.Context,
|
|
132
|
+
solutions_dir: str | None = typer.Option(
|
|
133
|
+
None,
|
|
134
|
+
"--solutions-dir",
|
|
135
|
+
help="Path to a custom solutions directory (overrides the built-in solutions).",
|
|
136
|
+
show_default=False,
|
|
137
|
+
),
|
|
138
|
+
) -> None:
|
|
139
|
+
ctx.ensure_object(dict)
|
|
140
|
+
if solutions_dir:
|
|
141
|
+
ctx.obj["solutions_dir"] = solutions_dir
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _solutions_root(path: str | None = None):
|
|
145
|
+
if path:
|
|
146
|
+
return Path(path)
|
|
147
|
+
return files("cortexapps_cli.solutions")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _list_solution_tags(path: str | None = None) -> list[str]:
|
|
151
|
+
root = _solutions_root(path)
|
|
152
|
+
return sorted(
|
|
153
|
+
item.name
|
|
154
|
+
for item in root.iterdir()
|
|
155
|
+
if item.is_dir() and not item.name.startswith("_")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_frontmatter(content: str) -> dict:
|
|
160
|
+
"""Parse YAML frontmatter block from README content."""
|
|
161
|
+
match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
|
|
162
|
+
if not match:
|
|
163
|
+
return {}
|
|
164
|
+
try:
|
|
165
|
+
return yaml.safe_load(match.group(1)) or {}
|
|
166
|
+
except yaml.YAMLError:
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _get_readme(tag: str, path: str | None = None) -> str | None:
|
|
171
|
+
"""Return README.md content for a solution tag, or None if not found."""
|
|
172
|
+
try:
|
|
173
|
+
return (_solutions_root(path) / tag / "README.md").read_text(encoding="utf-8")
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_first_codeblock(text: str) -> str | None:
|
|
179
|
+
"""Return content of the first fenced code block."""
|
|
180
|
+
m = re.search(r"```[^\n]*\n(.*?)```", text, re.DOTALL)
|
|
181
|
+
return m.group(1).rstrip("\n") if m else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _extract_section(text: str, heading: str) -> str | None:
|
|
185
|
+
"""Return the body of the first section with the given heading text."""
|
|
186
|
+
body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL)
|
|
187
|
+
pattern = re.compile(r"^(#{1,3})\s+" + re.escape(heading) + r"\s*$", re.MULTILINE)
|
|
188
|
+
m = pattern.search(body)
|
|
189
|
+
if not m:
|
|
190
|
+
return None
|
|
191
|
+
level = len(m.group(1))
|
|
192
|
+
start = m.end()
|
|
193
|
+
next_heading = re.compile(r"^#{1," + str(level) + r"}\s", re.MULTILINE)
|
|
194
|
+
m2 = next_heading.search(body, start + 1)
|
|
195
|
+
end = m2.start() if m2 else len(body)
|
|
196
|
+
return body[start:end].strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _get_ui_url(ctx: typer.Context) -> str:
|
|
200
|
+
"""Derive the Cortex app UI URL from the configured API URL."""
|
|
201
|
+
params = ctx.obj.get("_auth_params", {})
|
|
202
|
+
api_url = (params.get("url") or "https://api.getcortexapp.com").strip("\"' /")
|
|
203
|
+
if api_url == "https://api.getcortexapp.com":
|
|
204
|
+
return "https://app.getcortexapp.com"
|
|
205
|
+
return api_url.replace("://api.", "://app.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _build_client(ctx: typer.Context) -> CortexClient:
|
|
209
|
+
"""Build a CortexClient from auth params stored by global_callback."""
|
|
210
|
+
params = ctx.obj.get("_auth_params", {})
|
|
211
|
+
api_key = params.get("api_key")
|
|
212
|
+
url = params.get("url")
|
|
213
|
+
config_file = params.get(
|
|
214
|
+
"config_file",
|
|
215
|
+
os.path.join(os.path.expanduser("~"), ".cortex", "config"),
|
|
216
|
+
)
|
|
217
|
+
tenant = params.get("tenant", "default")
|
|
218
|
+
log_level_str = params.get("log_level", "WARNING")
|
|
219
|
+
rate_limit = params.get("rate_limit")
|
|
220
|
+
|
|
221
|
+
if not os.path.isfile(config_file):
|
|
222
|
+
if not api_key:
|
|
223
|
+
typer.echo(
|
|
224
|
+
"Error: Authentication required. Run 'cortex login' first or set CORTEX_API_KEY."
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(1)
|
|
227
|
+
else:
|
|
228
|
+
config = configparser.ConfigParser()
|
|
229
|
+
config.read(config_file)
|
|
230
|
+
if not api_key:
|
|
231
|
+
if tenant not in config:
|
|
232
|
+
typer.echo(
|
|
233
|
+
f"Error: Tenant '{tenant}' not found in config. Run 'cortex login' first."
|
|
234
|
+
)
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
api_key = config[tenant].get("api_key")
|
|
237
|
+
if not api_key:
|
|
238
|
+
typer.echo(
|
|
239
|
+
f"Error: No api_key found for tenant '{tenant}' in config. Run 'cortex login' first."
|
|
240
|
+
)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
if not url:
|
|
243
|
+
url = config[tenant].get("base_url", "https://api.getcortexapp.com")
|
|
244
|
+
|
|
245
|
+
if not url:
|
|
246
|
+
url = "https://api.getcortexapp.com"
|
|
247
|
+
|
|
248
|
+
api_key = api_key.strip("\"' ")
|
|
249
|
+
url = url.strip("\"' /")
|
|
250
|
+
|
|
251
|
+
numeric_level = getattr(logging, log_level_str.upper(), logging.WARNING)
|
|
252
|
+
return CortexClient(api_key, tenant, numeric_level, url, rate_limit)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _read_catalog_tag(content: str) -> str | None:
|
|
256
|
+
"""Extract x-cortex-tag from a catalog YAML file."""
|
|
257
|
+
try:
|
|
258
|
+
data = yaml.safe_load(content)
|
|
259
|
+
return data.get("info", {}).get("x-cortex-tag") if isinstance(data, dict) else None
|
|
260
|
+
except Exception:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _read_resource_tag(content: str, kind: str) -> str | None:
|
|
265
|
+
"""Extract tag/type identifier from a JSON or YAML resource file."""
|
|
266
|
+
try:
|
|
267
|
+
data = yaml.safe_load(content)
|
|
268
|
+
if not isinstance(data, dict):
|
|
269
|
+
data = json.loads(content)
|
|
270
|
+
return data.get("type") if kind == "entity-types" else data.get("tag")
|
|
271
|
+
except Exception:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _collect_solution_resources(path: Path) -> dict[str, list[str]]:
|
|
276
|
+
"""Return {kind: [tag, ...]} for all deletable resources in a solution directory."""
|
|
277
|
+
resources: dict[str, list[str]] = {
|
|
278
|
+
"entity-types": [],
|
|
279
|
+
"entity-relationship-types": [],
|
|
280
|
+
"catalog": [],
|
|
281
|
+
"scorecards": [],
|
|
282
|
+
"workflows": [],
|
|
283
|
+
}
|
|
284
|
+
for kind in resources:
|
|
285
|
+
subdir = path / kind
|
|
286
|
+
if not subdir.exists() or not subdir.is_dir():
|
|
287
|
+
continue
|
|
288
|
+
for f in sorted(subdir.iterdir()):
|
|
289
|
+
if not f.is_file() or f.suffix not in (".json", ".yaml", ".yml"):
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
content = f.read_text(encoding="utf-8")
|
|
293
|
+
tag = (
|
|
294
|
+
_read_catalog_tag(content)
|
|
295
|
+
if kind == "catalog"
|
|
296
|
+
else _read_resource_tag(content, kind)
|
|
297
|
+
)
|
|
298
|
+
if tag:
|
|
299
|
+
resources[kind].append(tag)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
return resources
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _delete_parallel(client, tags: list[str], endpoint_fn) -> tuple[int, int]:
|
|
306
|
+
"""Delete resources in parallel. Returns (removed, failed) counts."""
|
|
307
|
+
deleted = failed = 0
|
|
308
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
309
|
+
future_to_tag = {
|
|
310
|
+
executor.submit(client.delete, endpoint_fn(tag)): tag for tag in tags
|
|
311
|
+
}
|
|
312
|
+
for future in as_completed(future_to_tag):
|
|
313
|
+
tag = future_to_tag[future]
|
|
314
|
+
try:
|
|
315
|
+
future.result()
|
|
316
|
+
typer.echo(f" Deleted: {tag}")
|
|
317
|
+
deleted += 1
|
|
318
|
+
except Exception as e:
|
|
319
|
+
err = str(e)
|
|
320
|
+
if "404" in err or "Not Found" in err:
|
|
321
|
+
typer.echo(f" Not found (skipped): {tag}")
|
|
322
|
+
deleted += 1
|
|
323
|
+
else:
|
|
324
|
+
typer.echo(f" Failed: {tag} — {err}")
|
|
325
|
+
failed += 1
|
|
326
|
+
return deleted, failed
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _run_uninstall(client, path: Path, yes: bool) -> None:
|
|
330
|
+
"""Collect resources, confirm, then delete in reverse import order."""
|
|
331
|
+
resources = _collect_solution_resources(path)
|
|
332
|
+
total = sum(len(v) for v in resources.values())
|
|
333
|
+
|
|
334
|
+
if total == 0:
|
|
335
|
+
typer.echo("No resources found to remove.")
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
typer.echo("\nThis will remove the following resources:")
|
|
339
|
+
for kind in ("workflows", "scorecards", "catalog", "entity-relationship-types", "entity-types"):
|
|
340
|
+
count = len(resources[kind])
|
|
341
|
+
if count:
|
|
342
|
+
typer.echo(f" {kind}: {count}")
|
|
343
|
+
typer.echo()
|
|
344
|
+
|
|
345
|
+
if not yes:
|
|
346
|
+
confirmed = typer.confirm("Proceed with uninstall?", default=False)
|
|
347
|
+
if not confirmed:
|
|
348
|
+
typer.echo("Aborted.")
|
|
349
|
+
raise typer.Exit(0)
|
|
350
|
+
|
|
351
|
+
# Delete in reverse import order
|
|
352
|
+
steps = [
|
|
353
|
+
("workflows", lambda t: f"api/v1/workflows/{t}"),
|
|
354
|
+
("scorecards", lambda t: f"api/v1/scorecards/{t}"),
|
|
355
|
+
("catalog", lambda t: f"api/v1/catalog/{t}"),
|
|
356
|
+
("entity-relationship-types", lambda t: f"api/v1/relationship-types/{t}"),
|
|
357
|
+
("entity-types", lambda t: f"api/v1/catalog/definitions/{t}"),
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
stats: dict[str, tuple[int, int]] = {}
|
|
361
|
+
for kind, endpoint_fn in steps:
|
|
362
|
+
tags = resources[kind]
|
|
363
|
+
if not tags:
|
|
364
|
+
continue
|
|
365
|
+
typer.echo(f"\nRemoving {kind}...")
|
|
366
|
+
deleted, failed = _delete_parallel(client, tags, endpoint_fn)
|
|
367
|
+
stats[kind] = (deleted, failed)
|
|
368
|
+
|
|
369
|
+
total_removed = sum(d for d, _ in stats.values())
|
|
370
|
+
total_failed = sum(f for _, f in stats.values())
|
|
371
|
+
|
|
372
|
+
width = 80
|
|
373
|
+
typer.echo(f"\n{'=' * width}")
|
|
374
|
+
typer.echo("UNINSTALL SUMMARY")
|
|
375
|
+
typer.echo(f"{'=' * width}\n")
|
|
376
|
+
for kind, (d, f) in stats.items():
|
|
377
|
+
if d + f > 0:
|
|
378
|
+
typer.echo(f"{kind}:")
|
|
379
|
+
typer.echo(f" Removed: {d}")
|
|
380
|
+
if f:
|
|
381
|
+
typer.echo(f" Failed: {f}")
|
|
382
|
+
typer.echo(f"\nTOTAL: {total_removed} removed, {total_failed} failed\n")
|
|
383
|
+
typer.echo("=" * width)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command("list")
|
|
387
|
+
def list_solutions(ctx: typer.Context):
|
|
388
|
+
"""List all available solutions."""
|
|
389
|
+
solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
|
|
390
|
+
tags = _list_solution_tags(solutions_dir)
|
|
391
|
+
table = Table(title="Available Solutions")
|
|
392
|
+
table.add_column("Tag", style="cyan", no_wrap=True)
|
|
393
|
+
table.add_column("Name")
|
|
394
|
+
table.add_column("Description")
|
|
395
|
+
for tag in tags:
|
|
396
|
+
readme = _get_readme(tag, solutions_dir)
|
|
397
|
+
if readme is None:
|
|
398
|
+
continue
|
|
399
|
+
fm = _parse_frontmatter(readme)
|
|
400
|
+
table.add_row(tag, fm.get("name", tag), fm.get("description", ""))
|
|
401
|
+
console.print(table)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _print_readme(text: str, plain: bool = False, entity_tags: set[str] | None = None, ui_url: str = "https://app.getcortexapp.com") -> None:
|
|
405
|
+
"""Render README with left-justified headings and Rich Markdown body blocks.
|
|
406
|
+
|
|
407
|
+
When plain=True, markdown structure is preserved (bold, code blocks, etc.)
|
|
408
|
+
but all color is removed — suitable for light-mode terminals.
|
|
409
|
+
"""
|
|
410
|
+
# Strip YAML frontmatter
|
|
411
|
+
body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL).strip()
|
|
412
|
+
|
|
413
|
+
out = Console(no_color=True) if plain else console
|
|
414
|
+
|
|
415
|
+
heading_styles = {"# ": "bold", "## ": "bold underline", "### ": "bold"}
|
|
416
|
+
pending: list[str] = []
|
|
417
|
+
in_code_block = False
|
|
418
|
+
|
|
419
|
+
def flush() -> None:
|
|
420
|
+
block = "\n".join(pending).strip()
|
|
421
|
+
if block:
|
|
422
|
+
out.print(Markdown(block))
|
|
423
|
+
pending.clear()
|
|
424
|
+
|
|
425
|
+
for line in body.split("\n"):
|
|
426
|
+
if line.startswith("```"):
|
|
427
|
+
if not in_code_block:
|
|
428
|
+
flush()
|
|
429
|
+
in_code_block = not in_code_block
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
if in_code_block:
|
|
433
|
+
if entity_tags and not plain:
|
|
434
|
+
linked = _apply_hyperlinks(line, entity_tags, ui_url)
|
|
435
|
+
if linked != line:
|
|
436
|
+
# Use print() not console.print(): Rich counts OSC 8 escape
|
|
437
|
+
# bytes as visible characters, shifting ASCII art alignment.
|
|
438
|
+
print(f" {linked}")
|
|
439
|
+
continue
|
|
440
|
+
out.print(f" [cyan]{line}[/cyan]" if line else "")
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
for prefix, style in heading_styles.items():
|
|
444
|
+
if line.startswith(prefix):
|
|
445
|
+
flush()
|
|
446
|
+
out.print(f"\n[{style}]{line[len(prefix):]}[/{style}]")
|
|
447
|
+
break
|
|
448
|
+
else:
|
|
449
|
+
pending.append(line)
|
|
450
|
+
|
|
451
|
+
flush()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _terminal_supports_hyperlinks() -> bool:
|
|
455
|
+
"""Return True if the terminal is known to support OSC 8 hyperlinks."""
|
|
456
|
+
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
457
|
+
if term_program == "Apple_Terminal":
|
|
458
|
+
return False
|
|
459
|
+
if term_program in ("iTerm.app", "vscode", "WarpTerminal", "Hyper"):
|
|
460
|
+
return True
|
|
461
|
+
if os.environ.get("WT_SESSION"): # Windows Terminal
|
|
462
|
+
return True
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _osc8(url: str, text: str) -> str:
|
|
467
|
+
"""Wrap text in an OSC 8 terminal hyperlink."""
|
|
468
|
+
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _apply_hyperlinks(line: str, entity_tags: set[str], ui_url: str) -> str:
|
|
472
|
+
"""Single-pass replacement: find non-overlapping tag matches, longest wins."""
|
|
473
|
+
covered: list[tuple[int, int]] = []
|
|
474
|
+
matches: list[tuple[int, int, str]] = []
|
|
475
|
+
|
|
476
|
+
for tag in sorted(entity_tags, key=len, reverse=True):
|
|
477
|
+
start = 0
|
|
478
|
+
while True:
|
|
479
|
+
pos = line.find(tag, start)
|
|
480
|
+
if pos == -1:
|
|
481
|
+
break
|
|
482
|
+
end = pos + len(tag)
|
|
483
|
+
if not any(pos < c_end and end > c_start for c_start, c_end in covered):
|
|
484
|
+
matches.append((pos, end, tag))
|
|
485
|
+
covered.append((pos, end))
|
|
486
|
+
start = pos + 1
|
|
487
|
+
|
|
488
|
+
if not matches:
|
|
489
|
+
return line
|
|
490
|
+
|
|
491
|
+
matches.sort()
|
|
492
|
+
parts = []
|
|
493
|
+
cur = 0
|
|
494
|
+
for start, end, tag in matches:
|
|
495
|
+
parts.append(line[cur:start])
|
|
496
|
+
parts.append(_osc8(f"{ui_url}/admin/resources?tag={tag}", tag))
|
|
497
|
+
cur = end
|
|
498
|
+
parts.append(line[cur:])
|
|
499
|
+
return "".join(parts)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _show_diagram(readme: str, entity_tags: set[str] | None = None, ui_url: str = "https://app.getcortexapp.com") -> None:
|
|
503
|
+
block = _extract_first_codeblock(readme)
|
|
504
|
+
if not block:
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
links_supported = _terminal_supports_hyperlinks()
|
|
508
|
+
|
|
509
|
+
print()
|
|
510
|
+
for line in block.split("\n"):
|
|
511
|
+
if entity_tags and links_supported:
|
|
512
|
+
line = _apply_hyperlinks(line, entity_tags, ui_url)
|
|
513
|
+
# Use print() not console.print(): Rich counts OSC 8 escape bytes as
|
|
514
|
+
# visible characters, shifting ASCII art alignment.
|
|
515
|
+
print(f" {line}")
|
|
516
|
+
|
|
517
|
+
if entity_tags and not links_supported:
|
|
518
|
+
diagram_tags = sorted(
|
|
519
|
+
(tag for tag in entity_tags if tag in block),
|
|
520
|
+
key=lambda t: t.lower(),
|
|
521
|
+
)
|
|
522
|
+
if diagram_tags:
|
|
523
|
+
print()
|
|
524
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2, 0, 0))
|
|
525
|
+
table.add_column("Entity")
|
|
526
|
+
table.add_column("URL")
|
|
527
|
+
for tag in diagram_tags:
|
|
528
|
+
table.add_row(tag, f"{ui_url}/admin/resources?tag={tag}")
|
|
529
|
+
console.print(table)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _show_next_steps(readme: str) -> None:
|
|
533
|
+
section = _extract_section(readme, "After Installing")
|
|
534
|
+
if section:
|
|
535
|
+
if not _terminal_supports_hyperlinks():
|
|
536
|
+
section = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 (\2)', section)
|
|
537
|
+
console.print()
|
|
538
|
+
console.print(Markdown(section))
|
|
539
|
+
console.print()
|
|
540
|
+
console.print(
|
|
541
|
+
"[magenta]Planned for Q4 2027:[/magenta] CQL metadata traversal will enable scorecard rules "
|
|
542
|
+
"across relationship chains — for example, a Vulnerability Scorecard checking that no "
|
|
543
|
+
"deployed service-version has open Snyk issues:"
|
|
544
|
+
)
|
|
545
|
+
console.print(
|
|
546
|
+
" [dim]entity.destinations(relationshipType = \"environments\", depth = 3)\n"
|
|
547
|
+
" .filter((d) => d.type == \"service-version\")\n"
|
|
548
|
+
" .all((sv) => sv.snyk.issues == 0)[/dim]"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _post_install_menu(
|
|
553
|
+
readme: str,
|
|
554
|
+
import_report: str = "",
|
|
555
|
+
entity_tags: set[str] | None = None,
|
|
556
|
+
ui_url: str = "https://app.getcortexapp.com",
|
|
557
|
+
) -> None:
|
|
558
|
+
options = [
|
|
559
|
+
("1", "Data Model"),
|
|
560
|
+
("2", "Next steps"),
|
|
561
|
+
("3", "Import report"),
|
|
562
|
+
("4", "Exit"),
|
|
563
|
+
]
|
|
564
|
+
actions = {
|
|
565
|
+
"1": lambda: _show_diagram(readme, entity_tags=entity_tags, ui_url=ui_url),
|
|
566
|
+
"2": lambda: _show_next_steps(readme),
|
|
567
|
+
"3": lambda: (console.print(), typer.echo(import_report)),
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
while True:
|
|
571
|
+
console.print()
|
|
572
|
+
console.print(Rule(" What next? ", style="bold"))
|
|
573
|
+
for key, label in options:
|
|
574
|
+
console.print(f" [cyan]{key}[/cyan] {label}")
|
|
575
|
+
console.print()
|
|
576
|
+
|
|
577
|
+
choice = Prompt.ask("Choice", choices=[k for k, _ in options], show_choices=False)
|
|
578
|
+
|
|
579
|
+
if choice == "4":
|
|
580
|
+
break
|
|
581
|
+
actions[choice]()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.command()
|
|
585
|
+
def info(
|
|
586
|
+
ctx: typer.Context,
|
|
587
|
+
solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
|
|
588
|
+
plain: bool = typer.Option(False, "--plain", help="Render README in plain black text (no colors)."),
|
|
589
|
+
):
|
|
590
|
+
"""Show README for a solution."""
|
|
591
|
+
solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
|
|
592
|
+
readme = _get_readme(solution, solutions_dir)
|
|
593
|
+
if readme is None:
|
|
594
|
+
avail = ", ".join(_list_solution_tags(solutions_dir))
|
|
595
|
+
typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
|
|
596
|
+
raise typer.Exit(1)
|
|
597
|
+
_print_readme(readme, plain=plain)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@app.command()
|
|
601
|
+
def install(
|
|
602
|
+
ctx: typer.Context,
|
|
603
|
+
solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
|
|
604
|
+
no_prompt: bool = typer.Option(False, "--no-prompt", help="Skip the post-install interactive menu"),
|
|
605
|
+
):
|
|
606
|
+
"""Install a solution."""
|
|
607
|
+
solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
|
|
608
|
+
if solution not in _list_solution_tags(solutions_dir):
|
|
609
|
+
avail = ", ".join(_list_solution_tags(solutions_dir))
|
|
610
|
+
typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
|
|
611
|
+
raise typer.Exit(1)
|
|
612
|
+
|
|
613
|
+
ctx.obj["client"] = _build_client(ctx)
|
|
614
|
+
|
|
615
|
+
import cortexapps_cli.commands.backup as backup
|
|
616
|
+
|
|
617
|
+
root = _solutions_root(solutions_dir)
|
|
618
|
+
typer.echo(f"\nInstalling {solution}...")
|
|
619
|
+
if _TTY_SUPPORT and sys.stdin.isatty():
|
|
620
|
+
console.print(" [dim]Ctrl+o to show/hide import details[/dim]")
|
|
621
|
+
|
|
622
|
+
def _do_import() -> None:
|
|
623
|
+
if solutions_dir:
|
|
624
|
+
backup.import_tenant(ctx, directory=str(root / solution), force=False)
|
|
625
|
+
else:
|
|
626
|
+
with as_file(root / solution) as solution_path:
|
|
627
|
+
backup.import_tenant(ctx, directory=str(solution_path), force=False)
|
|
628
|
+
|
|
629
|
+
output = _run_import_with_toggle(_do_import)
|
|
630
|
+
total_match = re.search(r"TOTAL: (\d+) imported, (\d+) failed", output)
|
|
631
|
+
if total_match:
|
|
632
|
+
total_imported = int(total_match.group(1))
|
|
633
|
+
total_failed = int(total_match.group(2))
|
|
634
|
+
if total_failed:
|
|
635
|
+
failed_m = re.search(r"={20,}\nFAILED IMPORTS.*", output, re.DOTALL)
|
|
636
|
+
if failed_m:
|
|
637
|
+
typer.echo(failed_m.group(0))
|
|
638
|
+
typer.echo(f"\n {total_imported} imported, {total_failed} failed")
|
|
639
|
+
else:
|
|
640
|
+
typer.echo(f" {total_imported} resources imported")
|
|
641
|
+
else:
|
|
642
|
+
typer.echo(output)
|
|
643
|
+
|
|
644
|
+
if not no_prompt:
|
|
645
|
+
readme = _get_readme(solution, solutions_dir)
|
|
646
|
+
if readme:
|
|
647
|
+
entity_tags: set[str] = set()
|
|
648
|
+
ui_url = _get_ui_url(ctx)
|
|
649
|
+
try:
|
|
650
|
+
if solutions_dir:
|
|
651
|
+
resources = _collect_solution_resources(root / solution)
|
|
652
|
+
else:
|
|
653
|
+
with as_file(root / solution) as sp:
|
|
654
|
+
resources = _collect_solution_resources(sp)
|
|
655
|
+
entity_tags = set(resources.get("catalog", []))
|
|
656
|
+
except Exception:
|
|
657
|
+
pass
|
|
658
|
+
_post_install_menu(readme, import_report=output, entity_tags=entity_tags, ui_url=ui_url)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@app.command()
|
|
662
|
+
def uninstall(
|
|
663
|
+
ctx: typer.Context,
|
|
664
|
+
solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
|
|
665
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
666
|
+
):
|
|
667
|
+
"""Remove all entities installed by a solution."""
|
|
668
|
+
solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
|
|
669
|
+
if solution not in _list_solution_tags(solutions_dir):
|
|
670
|
+
avail = ", ".join(_list_solution_tags(solutions_dir))
|
|
671
|
+
typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
|
|
672
|
+
raise typer.Exit(1)
|
|
673
|
+
|
|
674
|
+
ctx.obj["client"] = _build_client(ctx)
|
|
675
|
+
client = ctx.obj["client"]
|
|
676
|
+
|
|
677
|
+
root = _solutions_root(solutions_dir)
|
|
678
|
+
if solutions_dir:
|
|
679
|
+
_run_uninstall(client, root / solution, yes)
|
|
680
|
+
else:
|
|
681
|
+
with as_file(root / solution) as solution_path:
|
|
682
|
+
_run_uninstall(client, solution_path, yes)
|