nat-engine 1__py3-none-any.whl

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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,873 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Interactive setup wizard for NAT — guides users through full end-to-end configuration.
7
+
8
+ This module implements a 7-phase interactive wizard that configures NAT from
9
+ scratch, covering mode selection, spec ingestion, authentication, exporter
10
+ setup (with live connection tests), and writing the ``.natrc`` config file.
11
+
12
+ Non-interactive mode is also supported via the ``--non-interactive`` flag
13
+ combined with environment variables (see :func:`run_setup_wizard`).
14
+
15
+ Entry point::
16
+
17
+ async def run_setup_wizard(args: argparse.Namespace) -> int:
18
+ ...
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import asyncio
25
+ import logging
26
+ import os
27
+ import platform
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Constants
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _SEVERITY_CHOICES = ("critical", "high", "medium", "low", "info")
41
+
42
+ _MODE_SAAS = "saas"
43
+ _MODE_SELF_HOSTED = "self-hosted"
44
+ _MODE_CLI = "cli"
45
+
46
+ _AUTH_BEARER = "bearer"
47
+ _AUTH_API_KEY = "api-key"
48
+ _AUTH_OAUTH2 = "oauth2"
49
+ _AUTH_NONE = "none"
50
+
51
+ # Exporter-specific environment variable mappings
52
+ # Maps exporter name -> {config_key: env_var_name}
53
+ _EXPORTER_ENV_VARS: dict[str, dict[str, str]] = {
54
+ "jira": {
55
+ "base_url": "NAT_JIRA_BASE_URL",
56
+ "project_key": "NAT_JIRA_PROJECT_KEY",
57
+ "email": "NAT_JIRA_EMAIL",
58
+ "api_token": "NAT_JIRA_API_TOKEN",
59
+ },
60
+ "github-issues": {
61
+ "token": "NAT_GITHUB_TOKEN",
62
+ "repo": "NAT_GITHUB_REPO",
63
+ },
64
+ "gitlab": {
65
+ "gitlab_url": "NAT_GITLAB_URL",
66
+ "project_id": "NAT_GITLAB_PROJECT_ID",
67
+ "private_token": "NAT_GITLAB_TOKEN",
68
+ },
69
+ "linear": {
70
+ "api_key": "NAT_LINEAR_API_KEY",
71
+ "team_id": "NAT_LINEAR_TEAM_ID",
72
+ },
73
+ "azure-devops": {
74
+ "organization": "NAT_ADO_ORG",
75
+ "project": "NAT_ADO_PROJECT",
76
+ "pat": "NAT_ADO_PAT",
77
+ },
78
+ "shortcut": {
79
+ "api_token": "NAT_SHORTCUT_TOKEN",
80
+ "project_id": "NAT_SHORTCUT_PROJECT_ID",
81
+ },
82
+ "pagerduty": {
83
+ "routing_key": "NAT_PAGERDUTY_ROUTING_KEY",
84
+ },
85
+ "servicenow": {
86
+ "instance_url": "NAT_SERVICENOW_URL",
87
+ "username": "NAT_SERVICENOW_USER",
88
+ "password": "NAT_SERVICENOW_PASSWORD",
89
+ },
90
+ "webhook": {
91
+ "webhook_url": "NAT_WEBHOOK_URL",
92
+ },
93
+ }
94
+
95
+ # Human-readable prompts for exporter config fields
96
+ _EXPORTER_FIELD_PROMPTS: dict[str, dict[str, str]] = {
97
+ "jira": {
98
+ "base_url": "Jira Cloud base URL (e.g. https://myco.atlassian.net)",
99
+ "project_key": "Jira project key (e.g. SEC)",
100
+ "email": "Atlassian account email",
101
+ "api_token": "Atlassian API token (from id.atlassian.com → Security → API tokens)",
102
+ "issue_type": "Issue type (default: Bug)",
103
+ "component": "Jira component name (optional)",
104
+ },
105
+ "github-issues": {
106
+ "token": "GitHub personal access token (PAT) with 'repo' scope",
107
+ "repo": "Repository in owner/repo format (e.g. myorg/myrepo)",
108
+ "labels": "Comma-separated extra labels (optional)",
109
+ "deduplicate": "Deduplicate issues? [true/false] (default: false)",
110
+ },
111
+ "gitlab": {
112
+ "gitlab_url": "GitLab instance URL (e.g. https://gitlab.com)",
113
+ "project_id": "GitLab project ID or namespace/project slug",
114
+ "private_token": "GitLab Personal Access Token with 'api' scope",
115
+ "labels": "Comma-separated extra labels (optional)",
116
+ },
117
+ "linear": {
118
+ "api_key": "Linear personal API key",
119
+ "team_id": "Linear team ID",
120
+ "project_id": "Linear project ID (optional)",
121
+ },
122
+ "azure-devops": {
123
+ "organization": "Azure DevOps organization name",
124
+ "project": "Azure DevOps project name",
125
+ "pat": "Personal Access Token with Work Items (Read & Write) scope",
126
+ "work_item_type": "Work item type (default: Bug)",
127
+ },
128
+ "shortcut": {
129
+ "api_token": "Shortcut API token",
130
+ "project_id": "Shortcut project ID (integer)",
131
+ "story_type": "Story type: bug, chore, or feature (default: bug)",
132
+ },
133
+ "pagerduty": {
134
+ "routing_key": "PagerDuty integration key (Events API v2 routing key)",
135
+ "source": "Source label (default: nat-security-scan)",
136
+ },
137
+ "servicenow": {
138
+ "instance_url": "ServiceNow instance URL (e.g. https://myco.service-now.com)",
139
+ "username": "ServiceNow username",
140
+ "password": "ServiceNow password",
141
+ "oauth_token": "OAuth bearer token (alternative to username/password, optional)",
142
+ "table": "Table: incident or change_request (default: incident)",
143
+ },
144
+ "webhook": {
145
+ "webhook_url": "Webhook URL (HTTP/HTTPS endpoint to POST findings to)",
146
+ "auth_header": "Authorization header value (optional, e.g. Bearer mytoken)",
147
+ "hmac_secret": "HMAC-SHA256 signing secret (optional)",
148
+ "batch_mode": "Send all findings in one POST? [true/false] (default: false)",
149
+ },
150
+ }
151
+
152
+ # Required fields per exporter (subset of above that must not be empty)
153
+ _EXPORTER_REQUIRED_FIELDS: dict[str, list[str]] = {
154
+ "jira": ["base_url", "project_key", "email", "api_token"],
155
+ "github-issues": ["token", "repo"],
156
+ "gitlab": ["gitlab_url", "project_id", "private_token"],
157
+ "linear": ["api_key", "team_id"],
158
+ "azure-devops": ["organization", "project", "pat"],
159
+ "shortcut": ["api_token", "project_id"],
160
+ "pagerduty": ["routing_key"],
161
+ "servicenow": ["instance_url"],
162
+ "webhook": ["webhook_url"],
163
+ }
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # I/O helpers
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def _print(msg: str = "") -> None:
172
+ print(msg) # noqa: T201 # lgtm[py/clear-text-logging-sensitive-data]
173
+
174
+
175
+ def _prompt(message: str, default: str = "", secret: bool = False) -> str:
176
+ """Prompt the user for input, optionally with a default value.
177
+
178
+ Parameters
179
+ ----------
180
+ message:
181
+ The prompt message to display.
182
+ default:
183
+ Default value shown in brackets. Used if the user presses Enter.
184
+ secret:
185
+ If True, suppress echoing the input (for passwords/tokens).
186
+ """
187
+ if default:
188
+ display = f"{message} [{default}]: "
189
+ else:
190
+ display = f"{message}: "
191
+
192
+ if secret:
193
+ import getpass # noqa: PLC0415
194
+ value = getpass.getpass(display)
195
+ else:
196
+ value = input(display)
197
+
198
+ return value.strip() or default
199
+
200
+
201
+ def _prompt_choice(message: str, choices: list[str], default: str = "") -> str:
202
+ """Prompt the user to choose one of *choices*.
203
+
204
+ Accepts the choice number or the value directly. Repeats until valid.
205
+ """
206
+ for i, choice in enumerate(choices, 1):
207
+ _print(f" {i}. {choice}")
208
+
209
+ while True:
210
+ raw = _prompt(message, default=default)
211
+ # Accept numeric index
212
+ if raw.isdigit():
213
+ idx = int(raw) - 1
214
+ if 0 <= idx < len(choices):
215
+ return choices[idx]
216
+ # Accept the value directly (case-insensitive)
217
+ for choice in choices:
218
+ if raw.lower() == choice.lower():
219
+ return choice
220
+ _print(f" ⚠️ Please enter a number (1–{len(choices)}) or one of {choices}")
221
+
222
+
223
+ def _prompt_yes_no(message: str, default: bool = True) -> bool:
224
+ """Prompt for a yes/no answer."""
225
+ hint = "[Y/n]" if default else "[y/N]"
226
+ raw = _prompt(f"{message} {hint}").lower()
227
+ if not raw:
228
+ return default
229
+ return raw in ("y", "yes")
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Environment detection helpers
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ def _detect_docker() -> bool:
238
+ """Return True if Docker is available on the system PATH."""
239
+ return shutil.which("docker") is not None
240
+
241
+
242
+ def _detect_existing_natrc() -> Path | None:
243
+ """Return the path to an existing .natrc file, or None."""
244
+ for candidate in (Path.cwd() / ".natrc", Path.home() / ".natrc"):
245
+ if candidate.is_file():
246
+ return candidate
247
+ return None
248
+
249
+
250
+ def _detect_python_version() -> str:
251
+ return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Phase 1 — Environment detection
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ def _phase1_detect_environment() -> dict[str, Any]:
260
+ """Detect and display system environment information."""
261
+ _print()
262
+ _print("━" * 60)
263
+ _print(" Phase 1 — Environment Detection")
264
+ _print("━" * 60)
265
+
266
+ python_ver = _detect_python_version()
267
+ os_name = f"{platform.system()} {platform.release()}"
268
+ docker_ok = _detect_docker()
269
+ existing_rc = _detect_existing_natrc()
270
+
271
+ _print()
272
+ _print(f" Python: {python_ver}")
273
+ _print(f" OS: {os_name}")
274
+ _print(f" Docker: {'✅ available' if docker_ok else '❌ not found'}")
275
+ _print(
276
+ f" .natrc: {'✅ found at ' + str(existing_rc) if existing_rc else '❌ none found'}"
277
+ )
278
+ _print()
279
+
280
+ return {
281
+ "python_version": python_ver,
282
+ "os": os_name,
283
+ "docker_available": docker_ok,
284
+ "existing_natrc": existing_rc,
285
+ }
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Phase 2 — Mode selection
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def _phase2_select_mode(env: dict[str, Any], non_interactive: bool) -> dict[str, Any]:
294
+ """Prompt user for deployment mode and associated credentials."""
295
+ _print("━" * 60)
296
+ _print(" Phase 2 — Deployment Mode")
297
+ _print("━" * 60)
298
+ _print()
299
+
300
+ mode_config: dict[str, Any] = {}
301
+
302
+ if non_interactive:
303
+ raw_mode = os.environ.get("NAT_MODE", _MODE_CLI).lower()
304
+ if raw_mode not in (_MODE_SAAS, _MODE_SELF_HOSTED, _MODE_CLI):
305
+ logger.warning("Unknown NAT_MODE '%s' — defaulting to cli", raw_mode)
306
+ raw_mode = _MODE_CLI
307
+ mode_config["mode"] = raw_mode
308
+ if raw_mode == _MODE_SAAS:
309
+ mode_config["nat_api_key"] = os.environ.get("NAT_API_KEY", "")
310
+ elif raw_mode == _MODE_SELF_HOSTED:
311
+ mode_config["database_url"] = os.environ.get("DATABASE_URL", "")
312
+ _print(f" Mode: {raw_mode} (from NAT_MODE env var)")
313
+ _print()
314
+ return mode_config
315
+
316
+ _print(" How will you use NAT?")
317
+ mode = _prompt_choice(
318
+ " Choice",
319
+ [_MODE_SAAS, _MODE_SELF_HOSTED, _MODE_CLI],
320
+ default="1",
321
+ )
322
+ mode_config["mode"] = mode
323
+
324
+ if mode == _MODE_SAAS:
325
+ _print()
326
+ _print(" Enter your NAT API key (from app.nat-testing.io → Settings → API Keys):")
327
+ api_key = _prompt(" API key", secret=True)
328
+ mode_config["nat_api_key"] = api_key
329
+
330
+ elif mode == _MODE_SELF_HOSTED:
331
+ _print()
332
+ _print(" Self-hosted mode requires a PostgreSQL (or SQLite) database.")
333
+ db_url = _prompt(
334
+ " DATABASE_URL",
335
+ default=os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./nat.db"),
336
+ )
337
+ mode_config["database_url"] = db_url
338
+
339
+ _print()
340
+ _print(" 💡 Tip: run 'alembic upgrade head' to apply database migrations.")
341
+ if _prompt_yes_no(" Run Alembic migrations now?", default=False):
342
+ _run_alembic_upgrade()
343
+
344
+ _print()
345
+ return mode_config
346
+
347
+
348
+ def _run_alembic_upgrade() -> None:
349
+ """Attempt to run 'alembic upgrade head' in a subprocess."""
350
+ try:
351
+ result = subprocess.run( # noqa: S603, S607
352
+ ["alembic", "upgrade", "head"],
353
+ capture_output=True,
354
+ text=True,
355
+ timeout=60,
356
+ )
357
+ if result.returncode == 0:
358
+ _print(" ✅ Alembic migrations applied successfully.")
359
+ else:
360
+ _print(f" ⚠️ Alembic exited with code {result.returncode}:")
361
+ _print(f" {result.stderr.strip()}")
362
+ except FileNotFoundError:
363
+ _print(" ⚠️ 'alembic' command not found — run it manually: alembic upgrade head")
364
+ except subprocess.TimeoutExpired:
365
+ _print(" ⚠️ Alembic timed out — run it manually: alembic upgrade head")
366
+ except Exception as exc: # noqa: BLE001
367
+ _print(f" ⚠️ Failed to run Alembic: {exc}")
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Phase 3 — Spec / ingestion setup
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ def _phase3_spec_setup(non_interactive: bool) -> dict[str, Any]:
376
+ """Prompt for API spec location, detect format, count endpoints."""
377
+ _print("━" * 60)
378
+ _print(" Phase 3 — API Spec / Ingestion Setup")
379
+ _print("━" * 60)
380
+ _print()
381
+
382
+ spec_config: dict[str, Any] = {}
383
+
384
+ if non_interactive:
385
+ spec_path = os.environ.get("NAT_SPEC_PATH", "")
386
+ base_url = os.environ.get("NAT_BASE_URL", "")
387
+ else:
388
+ _print(" Where is your API spec?")
389
+ _print(" (Accepts a file path or a URL)")
390
+ spec_path = _prompt(" Spec path or URL")
391
+ base_url = _prompt(" Base URL of your API (e.g. https://api.example.com)")
392
+
393
+ spec_config["spec_path"] = spec_path
394
+ spec_config["base_url"] = base_url
395
+
396
+ if spec_path:
397
+ detected = _try_detect_spec(spec_path)
398
+ if detected:
399
+ format_name = detected.get("format", "unknown")
400
+ endpoint_count = detected.get("endpoint_count", "?")
401
+ _print(
402
+ f" ✅ Detected: {format_name} — {endpoint_count} endpoint(s)"
403
+ )
404
+ spec_config["detected_format"] = format_name
405
+ spec_config["endpoint_count"] = endpoint_count
406
+ else:
407
+ _print(" ⚠️ Could not detect spec format — will attempt auto-detection at scan time.")
408
+
409
+ _print()
410
+ return spec_config
411
+
412
+
413
+ def _try_detect_spec(spec_path: str) -> dict[str, Any] | None:
414
+ """Attempt to detect the format and count of endpoints in *spec_path*.
415
+
416
+ Returns a dict with ``format`` and ``endpoint_count`` keys, or None.
417
+ """
418
+ try:
419
+ from mannf.product.ingestors.loader import get_ingestor_for_source # noqa: PLC0415
420
+ from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
421
+
422
+ # Build a minimal IngestorSource
423
+ if spec_path.startswith(("http://", "https://")):
424
+ source = IngestorSource(format="auto", url=spec_path)
425
+ else:
426
+ source = IngestorSource(format="auto", path=spec_path)
427
+
428
+ ingestor = get_ingestor_for_source(source)
429
+ if ingestor is None:
430
+ return None
431
+
432
+ # Run ingest in a nested event loop call
433
+ result = asyncio.get_event_loop().run_until_complete(
434
+ ingestor.ingest(source, {})
435
+ )
436
+ if result and result.success:
437
+ return {
438
+ "format": ingestor.display_name,
439
+ "endpoint_count": len(result.endpoints),
440
+ }
441
+ except Exception as exc: # noqa: BLE001
442
+ logger.debug("Spec detection failed: %s", exc)
443
+
444
+ return None
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # Phase 4 — Authentication
449
+ # ---------------------------------------------------------------------------
450
+
451
+
452
+ def _phase4_auth_setup(spec_config: dict[str, Any], non_interactive: bool) -> dict[str, Any]:
453
+ """Prompt for API authentication credentials."""
454
+ _print("━" * 60)
455
+ _print(" Phase 4 — API Authentication")
456
+ _print("━" * 60)
457
+ _print()
458
+
459
+ auth_config: dict[str, Any] = {}
460
+
461
+ if non_interactive:
462
+ token = os.environ.get("NAT_AUTH_TOKEN", "")
463
+ if token:
464
+ auth_config["auth_type"] = _AUTH_BEARER
465
+ auth_config["token"] = token
466
+ else:
467
+ auth_config["auth_type"] = _AUTH_NONE
468
+ _print(f" Auth type: {auth_config['auth_type']} (from env vars)")
469
+ _print()
470
+ return auth_config
471
+
472
+ _print(" Does your API require authentication?")
473
+ auth_choices = [
474
+ "None — public API",
475
+ "Bearer token",
476
+ "API key header",
477
+ "OAuth2 client credentials",
478
+ ]
479
+ auth_choice = _prompt_choice(" Choice", auth_choices, default="1")
480
+
481
+ if "None" in auth_choice:
482
+ auth_config["auth_type"] = _AUTH_NONE
483
+ elif "Bearer" in auth_choice:
484
+ auth_config["auth_type"] = _AUTH_BEARER
485
+ auth_config["token"] = _prompt(" Bearer token", secret=True)
486
+ elif "API key" in auth_choice:
487
+ auth_config["auth_type"] = _AUTH_API_KEY
488
+ auth_config["api_key"] = _prompt(" API key value", secret=True)
489
+ auth_config["api_key_header"] = _prompt(
490
+ " Header name", default="X-API-Key"
491
+ )
492
+ elif "OAuth2" in auth_choice:
493
+ auth_config["auth_type"] = _AUTH_OAUTH2
494
+ auth_config["client_id"] = _prompt(" Client ID")
495
+ auth_config["client_secret"] = _prompt(" Client secret", secret=True)
496
+ auth_config["token_url"] = _prompt(" Token URL (e.g. https://auth.example.com/oauth2/token)")
497
+
498
+ _print()
499
+ return auth_config
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Phase 5 — Export destination
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def _phase5_exporter_setup(non_interactive: bool) -> dict[str, Any]:
508
+ """Prompt for exporter selection, config, and run a live connection test."""
509
+ _print("━" * 60)
510
+ _print(" Phase 5 — Export Destination")
511
+ _print("━" * 60)
512
+ _print()
513
+
514
+ from mannf.product.exporters import BUILTIN_EXPORTERS # noqa: PLC0415
515
+
516
+ exporters = BUILTIN_EXPORTERS
517
+
518
+ if non_interactive:
519
+ exporter_name = os.environ.get("NAT_EXPORT", "").lower()
520
+ if not exporter_name:
521
+ _print(" No NAT_EXPORT set — skipping exporter configuration.")
522
+ _print()
523
+ return {}
524
+
525
+ exporter = next((e for e in exporters if e.name == exporter_name), None)
526
+ if exporter is None:
527
+ _print(f" ⚠️ Unknown exporter '{exporter_name}' — skipping.")
528
+ _print()
529
+ return {}
530
+
531
+ # Build config from env vars
532
+ env_map = _EXPORTER_ENV_VARS.get(exporter_name, {})
533
+ exporter_config: dict[str, str] = {}
534
+ for key, env_var in env_map.items():
535
+ value = os.environ.get(env_var, "")
536
+ if value:
537
+ exporter_config[key] = value
538
+
539
+ return _finalize_exporter_config(exporter, exporter_config)
540
+
541
+ _print(" Which system should NAT export findings to?")
542
+ exporter_names = [f"{e.display_name} ({e.name})" for e in exporters]
543
+ exporter_names.append("Skip — no exporter")
544
+
545
+ choice = _prompt_choice(" Choice", exporter_names, default="1")
546
+
547
+ if "Skip" in choice:
548
+ _print()
549
+ return {}
550
+
551
+ # Find the selected exporter
552
+ selected_exporter = None
553
+ for exporter in exporters:
554
+ label = f"{exporter.display_name} ({exporter.name})"
555
+ if label == choice:
556
+ selected_exporter = exporter
557
+ break
558
+
559
+ if selected_exporter is None:
560
+ _print(" ⚠️ Could not identify exporter — skipping.")
561
+ _print()
562
+ return {}
563
+
564
+ _print()
565
+ _print(f" Configuring: {selected_exporter.display_name}")
566
+ _print()
567
+
568
+ # Collect required fields
569
+ field_prompts = _EXPORTER_FIELD_PROMPTS.get(selected_exporter.name, {})
570
+ required_fields = _EXPORTER_REQUIRED_FIELDS.get(selected_exporter.name, [])
571
+ exporter_config = {}
572
+
573
+ for field_key, prompt_text in field_prompts.items():
574
+ is_required = field_key in required_fields
575
+ is_secret = any(word in field_key.lower() for word in ("token", "secret", "password", "pat", "key"))
576
+ prefix = " * " if is_required else " "
577
+ value = _prompt(f"{prefix}{prompt_text}", secret=is_secret)
578
+ if value:
579
+ exporter_config[field_key] = value
580
+
581
+ # Validate config
582
+ validation_errors = selected_exporter.validate_config(exporter_config)
583
+ if validation_errors:
584
+ _print()
585
+ _print(" ⚠️ Config validation errors:")
586
+ for err in validation_errors:
587
+ _print(f" • {err}")
588
+ _print(" Proceeding anyway — you can fix these in your .natrc file.")
589
+
590
+ # Severity threshold
591
+ _print()
592
+ _print(" Minimum severity to export:")
593
+ severity = _prompt_choice(" Choice", list(_SEVERITY_CHOICES), default="2")
594
+ exporter_config["min_severity"] = severity
595
+
596
+ # Live connection test
597
+ _print()
598
+ if _prompt_yes_no(" Test connection now?", default=True):
599
+ _run_connection_test(selected_exporter, exporter_config)
600
+
601
+ return _finalize_exporter_config(selected_exporter, exporter_config)
602
+
603
+
604
+ def _run_connection_test(exporter: Any, config: dict) -> None:
605
+ """Run test_connection() on *exporter* and print the result."""
606
+ _print(" 🔌 Testing connection…")
607
+ try:
608
+ loop = asyncio.get_event_loop()
609
+ if loop.is_running():
610
+ # Already inside an async context — schedule as a task
611
+ import concurrent.futures # noqa: PLC0415
612
+ future = asyncio.ensure_future(exporter.test_connection(config))
613
+ result = loop.run_until_complete(future)
614
+ else:
615
+ result = loop.run_until_complete(exporter.test_connection(config))
616
+ except Exception as exc: # noqa: BLE001
617
+ _print(f" ❌ Connection test raised an exception: {exc}")
618
+ return
619
+
620
+ if result.success:
621
+ _print(f" ✅ {result.message}")
622
+ else:
623
+ _print(f" ❌ {result.message}")
624
+ if result.details:
625
+ _print(f" Details: {result.details}")
626
+
627
+
628
+ def _finalize_exporter_config(exporter: Any, config: dict) -> dict[str, Any]:
629
+ """Wrap the exporter config with its name for storage in .natrc."""
630
+ return {
631
+ "exporter_name": exporter.name,
632
+ "exporter_config": config,
633
+ }
634
+
635
+
636
+ # ---------------------------------------------------------------------------
637
+ # Phase 6 — Write .natrc
638
+ # ---------------------------------------------------------------------------
639
+
640
+
641
+ def _phase6_write_natrc(
642
+ mode_config: dict[str, Any],
643
+ spec_config: dict[str, Any],
644
+ auth_config: dict[str, Any],
645
+ exporter_data: dict[str, Any],
646
+ non_interactive: bool,
647
+ ) -> Path | None:
648
+ """Serialize collected config to YAML and write .natrc."""
649
+ _print("━" * 60)
650
+ _print(" Phase 6 — Write Configuration")
651
+ _print("━" * 60)
652
+ _print()
653
+
654
+ try:
655
+ import yaml # noqa: PLC0415
656
+ except ImportError:
657
+ _print(" ❌ PyYAML is not installed — cannot write .natrc.")
658
+ _print(" Install it with: pip install pyyaml")
659
+ _print()
660
+ return None
661
+
662
+ # Build the YAML config structure
663
+ config: dict[str, Any] = {}
664
+
665
+ # Mode
666
+ mode = mode_config.get("mode", _MODE_CLI)
667
+ config["mode"] = mode
668
+ if mode == _MODE_SAAS and mode_config.get("nat_api_key"):
669
+ config["nat_api_key"] = mode_config["nat_api_key"]
670
+ elif mode == _MODE_SELF_HOSTED and mode_config.get("database_url"):
671
+ config["database_url"] = mode_config["database_url"]
672
+
673
+ # Spec
674
+ if spec_config.get("spec_path"):
675
+ config["spec"] = spec_config["spec_path"]
676
+ if spec_config.get("base_url"):
677
+ config["base_url"] = spec_config["base_url"]
678
+
679
+ # Auth
680
+ auth_type = auth_config.get("auth_type", _AUTH_NONE)
681
+ if auth_type == _AUTH_BEARER and auth_config.get("token"):
682
+ config["auth"] = {"type": "bearer", "token": auth_config["token"]}
683
+ elif auth_type == _AUTH_API_KEY and auth_config.get("api_key"):
684
+ config["auth"] = {
685
+ "type": "api-key",
686
+ "key": auth_config["api_key"],
687
+ "header": auth_config.get("api_key_header", "X-API-Key"),
688
+ }
689
+ elif auth_type == _AUTH_OAUTH2:
690
+ config["auth"] = {
691
+ "type": "oauth2",
692
+ "client_id": auth_config.get("client_id", ""),
693
+ "client_secret": auth_config.get("client_secret", ""),
694
+ "token_url": auth_config.get("token_url", ""),
695
+ }
696
+
697
+ # Exporter
698
+ if exporter_data.get("exporter_name"):
699
+ exporter_cfg = dict(exporter_data.get("exporter_config", {}))
700
+ # Remove meta keys not part of exporter config
701
+ min_severity = exporter_cfg.pop("min_severity", "high")
702
+ config["export"] = exporter_data["exporter_name"]
703
+ config["export_config"] = exporter_cfg
704
+ config["export_min_severity"] = min_severity
705
+
706
+ yaml_text = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=True)
707
+
708
+ # Determine destination
709
+ if non_interactive:
710
+ dest = Path.cwd() / ".natrc"
711
+ else:
712
+ _print(" Where should the config be saved?")
713
+ choices = [str(Path.cwd() / ".natrc"), str(Path.home() / ".natrc")]
714
+ dest_str = _prompt_choice(" Choice", choices, default="2")
715
+ dest = Path(dest_str)
716
+
717
+ # Warn about secrets
718
+ has_secrets = any(
719
+ k in config
720
+ for k in ("nat_api_key", "auth")
721
+ ) or bool(exporter_data.get("exporter_config"))
722
+
723
+ if has_secrets:
724
+ _print()
725
+ _print(" ⚠️ Your config contains credentials/secrets.")
726
+ _print(" Ensure the .natrc file has restricted permissions:")
727
+ _print(" chmod 600 " + str(dest))
728
+
729
+ # Write the file
730
+ try:
731
+ dest.write_text(yaml_text, encoding="utf-8")
732
+ _print()
733
+ _print(f" ✅ Config written to {dest}")
734
+ except OSError as exc:
735
+ _print(f" ❌ Failed to write config: {exc}")
736
+ _print()
737
+ return None
738
+
739
+ _print()
740
+ return dest
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # Phase 7 — Verify + offer first run
745
+ # ---------------------------------------------------------------------------
746
+
747
+
748
+ def _phase7_verify_and_run(
749
+ natrc_path: Path | None,
750
+ spec_config: dict[str, Any],
751
+ non_interactive: bool,
752
+ ) -> int:
753
+ """Run validation checks and optionally kick off a first scan."""
754
+ _print("━" * 60)
755
+ _print(" Phase 7 — Verify & First Run")
756
+ _print("━" * 60)
757
+ _print()
758
+
759
+ all_ok = True
760
+
761
+ # Check 1: .natrc exists
762
+ if natrc_path and natrc_path.is_file():
763
+ _print(f" ✅ Config file: {natrc_path}")
764
+ else:
765
+ _print(" ⚠️ No .natrc file was written.")
766
+ all_ok = False
767
+
768
+ # Check 2: spec provided
769
+ spec_path = spec_config.get("spec_path", "")
770
+ if spec_path:
771
+ _print(f" ✅ Spec path: {spec_path}")
772
+ else:
773
+ _print(" ⚠️ No spec path configured — provide --spec when running nat scan.")
774
+ all_ok = False
775
+
776
+ # Check 3: base URL
777
+ base_url = spec_config.get("base_url", "")
778
+ if base_url:
779
+ _print(f" ✅ Base URL: {base_url}")
780
+ else:
781
+ _print(" ⚠️ No base URL configured — provide --base-url when running nat scan.")
782
+
783
+ _print()
784
+
785
+ if all_ok and not non_interactive:
786
+ if _prompt_yes_no(" Run a scan now?", default=False):
787
+ _print()
788
+ _print(" 🚀 Launching nat security-scan…")
789
+ _print()
790
+ cmd = ["nat", "security-scan"]
791
+ if spec_path:
792
+ cmd += ["--spec", spec_path]
793
+ if base_url:
794
+ cmd += ["--base-url", base_url]
795
+ try:
796
+ subprocess.run(cmd, check=False) # noqa: S603
797
+ except FileNotFoundError:
798
+ _print(" ❌ 'nat' command not found — install nat-engine with pip.")
799
+ return 1
800
+
801
+ _print()
802
+ _print(" 🎉 Setup complete!")
803
+ _print(" Run 'nat security-scan --help' to see all available options.")
804
+ _print()
805
+ return 0
806
+
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # Main entry point
810
+ # ---------------------------------------------------------------------------
811
+
812
+
813
+ async def run_setup_wizard(args: argparse.Namespace) -> int:
814
+ """Main entry point for the interactive setup wizard.
815
+
816
+ Parameters
817
+ ----------
818
+ args:
819
+ Parsed CLI arguments. The following attributes are read:
820
+
821
+ - ``non_interactive`` (bool) — skip all prompts, read from env vars.
822
+
823
+ Returns
824
+ -------
825
+ int
826
+ Exit code (0 = success, non-zero = error).
827
+ """
828
+ from mannf._version import __version__ # noqa: PLC0415
829
+
830
+ non_interactive: bool = getattr(args, "non_interactive", False)
831
+
832
+ _print()
833
+ _print("┏" + "━" * 58 + "┓")
834
+ _print(f" 🚀 NAT Setup Wizard (nat-engine v{__version__})")
835
+ _print("┗" + "━" * 58 + "┛")
836
+
837
+ # Phase 1 — Detect environment
838
+ env = _phase1_detect_environment()
839
+
840
+ # Warn if an existing .natrc will be overwritten
841
+ if env.get("existing_natrc") and not non_interactive:
842
+ _print(f" ⚠️ Existing config found at {env['existing_natrc']}")
843
+ if not _prompt_yes_no(" Overwrite existing config?", default=False):
844
+ _print(" Setup cancelled. Existing config unchanged.")
845
+ _print()
846
+ return 0
847
+ _print()
848
+
849
+ # Phase 2 — Mode
850
+ mode_config = _phase2_select_mode(env, non_interactive)
851
+
852
+ # Phase 3 — Spec setup
853
+ spec_config = _phase3_spec_setup(non_interactive)
854
+
855
+ # Phase 4 — Auth
856
+ auth_config = _phase4_auth_setup(spec_config, non_interactive)
857
+
858
+ # Phase 5 — Exporter
859
+ exporter_data = _phase5_exporter_setup(non_interactive)
860
+
861
+ # Phase 6 — Write .natrc (run synchronously within async context)
862
+ natrc_path = await asyncio.get_event_loop().run_in_executor(
863
+ None,
864
+ _phase6_write_natrc,
865
+ mode_config,
866
+ spec_config,
867
+ auth_config,
868
+ exporter_data,
869
+ non_interactive,
870
+ )
871
+
872
+ # Phase 7 — Verify + optional first run
873
+ return _phase7_verify_and_run(natrc_path, spec_config, non_interactive)