pcf-toolkit 0.2.5__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.
- pcf_toolkit/__init__.py +6 -0
- pcf_toolkit/cli.py +738 -0
- pcf_toolkit/cli_helpers.py +62 -0
- pcf_toolkit/data/__init__.py +1 -0
- pcf_toolkit/data/manifest.schema.json +1097 -0
- pcf_toolkit/data/schema_snapshot.json +2377 -0
- pcf_toolkit/data/spec_raw.json +2877 -0
- pcf_toolkit/io.py +65 -0
- pcf_toolkit/json_schema.py +30 -0
- pcf_toolkit/models.py +384 -0
- pcf_toolkit/proxy/__init__.py +1 -0
- pcf_toolkit/proxy/addons/__init__.py +1 -0
- pcf_toolkit/proxy/addons/redirect_bundle.py +70 -0
- pcf_toolkit/proxy/browser.py +157 -0
- pcf_toolkit/proxy/cli.py +1570 -0
- pcf_toolkit/proxy/config.py +310 -0
- pcf_toolkit/proxy/doctor.py +279 -0
- pcf_toolkit/proxy/mitm.py +206 -0
- pcf_toolkit/proxy/server.py +50 -0
- pcf_toolkit/py.typed +1 -0
- pcf_toolkit/rich_help.py +173 -0
- pcf_toolkit/schema_snapshot.py +47 -0
- pcf_toolkit/types.py +95 -0
- pcf_toolkit/xml.py +484 -0
- pcf_toolkit/xml_import.py +548 -0
- pcf_toolkit-0.2.5.dist-info/METADATA +494 -0
- pcf_toolkit-0.2.5.dist-info/RECORD +31 -0
- pcf_toolkit-0.2.5.dist-info/WHEEL +5 -0
- pcf_toolkit-0.2.5.dist-info/entry_points.txt +2 -0
- pcf_toolkit-0.2.5.dist-info/licenses/LICENSE.md +183 -0
- pcf_toolkit-0.2.5.dist-info/top_level.txt +1 -0
pcf_toolkit/proxy/cli.py
ADDED
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
"""CLI commands for the proxy workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import questionary
|
|
23
|
+
except Exception: # pragma: no cover - fallback when questionary isn't available
|
|
24
|
+
questionary = None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from rich.panel import Panel
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
from rich.text import Text
|
|
30
|
+
except Exception: # pragma: no cover - fallback when rich isn't available
|
|
31
|
+
Panel = None
|
|
32
|
+
Table = None
|
|
33
|
+
Text = None
|
|
34
|
+
|
|
35
|
+
from pcf_toolkit.cli_helpers import rich_console
|
|
36
|
+
from pcf_toolkit.proxy.browser import find_browser_binary, launch_browser
|
|
37
|
+
from pcf_toolkit.proxy.config import (
|
|
38
|
+
EnvironmentConfig,
|
|
39
|
+
ProxyConfig,
|
|
40
|
+
default_config_path,
|
|
41
|
+
global_config_path,
|
|
42
|
+
load_config,
|
|
43
|
+
render_dist_path,
|
|
44
|
+
write_default_config,
|
|
45
|
+
)
|
|
46
|
+
from pcf_toolkit.proxy.doctor import CheckResult, run_doctor
|
|
47
|
+
from pcf_toolkit.proxy.mitm import ensure_mitmproxy, find_mitmproxy, spawn_mitmproxy
|
|
48
|
+
from pcf_toolkit.proxy.server import spawn_http_server
|
|
49
|
+
from pcf_toolkit.rich_help import RichTyperCommand, RichTyperGroup
|
|
50
|
+
|
|
51
|
+
app = typer.Typer(
|
|
52
|
+
name="proxy",
|
|
53
|
+
cls=RichTyperGroup,
|
|
54
|
+
help="Local dev proxy for PCF components.",
|
|
55
|
+
no_args_is_help=True,
|
|
56
|
+
rich_markup_mode="rich",
|
|
57
|
+
pretty_exceptions_enable=True,
|
|
58
|
+
pretty_exceptions_show_locals=False,
|
|
59
|
+
suggest_commands=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ProxySession:
|
|
65
|
+
"""Represents an active proxy session.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
mitm_pid: Process ID of the mitmproxy process.
|
|
69
|
+
http_pid: Process ID of the HTTP server process.
|
|
70
|
+
proxy_port: Port number for the proxy server.
|
|
71
|
+
http_port: Port number for the HTTP server.
|
|
72
|
+
component: PCF component name.
|
|
73
|
+
project_root: Project root directory path.
|
|
74
|
+
log_path: Optional path to log file (for detached sessions).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
mitm_pid: int
|
|
78
|
+
http_pid: int
|
|
79
|
+
proxy_port: int
|
|
80
|
+
http_port: int
|
|
81
|
+
component: str
|
|
82
|
+
project_root: str
|
|
83
|
+
log_path: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("init", cls=RichTyperCommand, help="Create a proxy config file.")
|
|
87
|
+
def init(
|
|
88
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to the proxy config file."),
|
|
89
|
+
use_global: bool = typer.Option(
|
|
90
|
+
False,
|
|
91
|
+
"--global",
|
|
92
|
+
help="Store config in ~/.pcf-toolkit/pcf-proxy.yaml.",
|
|
93
|
+
),
|
|
94
|
+
local_only: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--local",
|
|
97
|
+
help="Store config in the current directory.",
|
|
98
|
+
),
|
|
99
|
+
force: bool = typer.Option(False, "--force", help="Overwrite the config file if it exists."),
|
|
100
|
+
add_npm_script: bool = typer.Option(
|
|
101
|
+
True,
|
|
102
|
+
"--add-npm-script/--no-add-npm-script",
|
|
103
|
+
help="Add a dev:proxy script to package.json if present.",
|
|
104
|
+
),
|
|
105
|
+
npm_script_name: str = typer.Option("dev:proxy", "--npm-script", help="The npm script name to add."),
|
|
106
|
+
interactive: bool = typer.Option(
|
|
107
|
+
True,
|
|
108
|
+
"--interactive/--no-interactive",
|
|
109
|
+
help="Prompt for CRM URL and dependencies.",
|
|
110
|
+
),
|
|
111
|
+
crm_url: str | None = typer.Option(None, "--crm-url", help="Set CRM URL without prompting."),
|
|
112
|
+
install_mitmproxy: bool | None = typer.Option(
|
|
113
|
+
None,
|
|
114
|
+
"--install-mitmproxy/--no-install-mitmproxy",
|
|
115
|
+
help="Auto-install mitmproxy during setup.",
|
|
116
|
+
),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Creates a proxy configuration file.
|
|
119
|
+
|
|
120
|
+
Interactive setup flow that prompts for CRM URL, dist path, and other
|
|
121
|
+
settings. Can create local or global config files.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config_path: Explicit path to config file.
|
|
125
|
+
use_global: If True, stores config in global location.
|
|
126
|
+
local_only: If True, stores config in current directory.
|
|
127
|
+
force: If True, overwrites existing config file.
|
|
128
|
+
add_npm_script: If True, adds npm script to package.json.
|
|
129
|
+
npm_script_name: Name of the npm script to add.
|
|
130
|
+
interactive: If True, prompts for configuration values.
|
|
131
|
+
crm_url: CRM URL to set without prompting.
|
|
132
|
+
install_mitmproxy: Whether to install mitmproxy during setup.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
typer.BadParameter: If conflicting options are provided.
|
|
136
|
+
typer.Exit: Exit code 1 if config file exists and force is False.
|
|
137
|
+
"""
|
|
138
|
+
if use_global and local_only:
|
|
139
|
+
raise typer.BadParameter("Choose either --global or --local, not both.")
|
|
140
|
+
if interactive and not _is_interactive():
|
|
141
|
+
interactive = False
|
|
142
|
+
|
|
143
|
+
project_root = Path.cwd()
|
|
144
|
+
|
|
145
|
+
# Detect component name for npm script
|
|
146
|
+
component_candidates = _detect_component_names(project_root)
|
|
147
|
+
detected_component: str | None = None
|
|
148
|
+
if component_candidates:
|
|
149
|
+
if len(component_candidates) == 1:
|
|
150
|
+
detected_component = component_candidates[0]
|
|
151
|
+
elif interactive and _is_interactive():
|
|
152
|
+
detected_component = _prompt_select_component(component_candidates)
|
|
153
|
+
else:
|
|
154
|
+
detected_component = component_candidates[0]
|
|
155
|
+
if detected_component:
|
|
156
|
+
typer.secho(f"Detected component: {detected_component}", fg=typer.colors.CYAN)
|
|
157
|
+
|
|
158
|
+
target_path = _resolve_init_target_path(
|
|
159
|
+
config_path=config_path,
|
|
160
|
+
use_global=use_global,
|
|
161
|
+
local_only=local_only,
|
|
162
|
+
)
|
|
163
|
+
target_path = _run_init_flow(
|
|
164
|
+
target_path=target_path,
|
|
165
|
+
project_root=project_root,
|
|
166
|
+
force=force,
|
|
167
|
+
interactive=interactive,
|
|
168
|
+
crm_url=crm_url,
|
|
169
|
+
install_mitmproxy=install_mitmproxy,
|
|
170
|
+
)
|
|
171
|
+
typer.echo(f"Wrote config to {target_path}")
|
|
172
|
+
|
|
173
|
+
if add_npm_script:
|
|
174
|
+
_ensure_npm_script(project_root, npm_script_name, detected_component)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command("start", cls=RichTyperCommand, help="Start the proxy workflow.")
|
|
178
|
+
def start(
|
|
179
|
+
component: str = typer.Argument(..., help="PCF component name."),
|
|
180
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to the proxy config file."),
|
|
181
|
+
use_global: bool = typer.Option(
|
|
182
|
+
False,
|
|
183
|
+
"--global",
|
|
184
|
+
help="Use the global config even if a local config exists.",
|
|
185
|
+
),
|
|
186
|
+
project_root: Path | None = typer.Option(
|
|
187
|
+
None,
|
|
188
|
+
"--root",
|
|
189
|
+
help="Project root (component folder).",
|
|
190
|
+
exists=True,
|
|
191
|
+
file_okay=False,
|
|
192
|
+
dir_okay=True,
|
|
193
|
+
),
|
|
194
|
+
crm_url: str | None = typer.Option(None, "--crm-url", help="Override CRM URL."),
|
|
195
|
+
environment: str | None = typer.Option(
|
|
196
|
+
None,
|
|
197
|
+
"--env",
|
|
198
|
+
"--environment",
|
|
199
|
+
help="Environment name or index from config.",
|
|
200
|
+
),
|
|
201
|
+
proxy_port: int | None = typer.Option(None, "--proxy-port", help="Override proxy port."),
|
|
202
|
+
http_port: int | None = typer.Option(None, "--http-port", help="Override HTTP server port."),
|
|
203
|
+
browser: str | None = typer.Option(None, "--browser", help="Browser preference: chrome or edge."),
|
|
204
|
+
browser_path: Path | None = typer.Option(None, "--browser-path", help="Explicit browser executable path."),
|
|
205
|
+
mitmproxy_path: Path | None = typer.Option(None, "--mitmproxy-path", help="Explicit mitmproxy executable path."),
|
|
206
|
+
dist_path: str | None = typer.Option(None, "--dist-path", help="Override dist path template."),
|
|
207
|
+
open_browser: bool | None = typer.Option(
|
|
208
|
+
None,
|
|
209
|
+
"--open-browser/--no-open-browser",
|
|
210
|
+
help="Open a browser with proxy settings.",
|
|
211
|
+
),
|
|
212
|
+
auto_install: bool | None = typer.Option(
|
|
213
|
+
None,
|
|
214
|
+
"--auto-install/--no-auto-install",
|
|
215
|
+
help="Auto-install mitmproxy if missing.",
|
|
216
|
+
),
|
|
217
|
+
detach: bool = typer.Option(
|
|
218
|
+
False,
|
|
219
|
+
"--detach",
|
|
220
|
+
help="Run the proxy in the background and return immediately.",
|
|
221
|
+
),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Starts the proxy workflow for a PCF component.
|
|
224
|
+
|
|
225
|
+
Launches mitmproxy and HTTP server, optionally opens browser.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
component: PCF component name (or "auto"/"select"/"detect").
|
|
229
|
+
config_path: Path to proxy config file.
|
|
230
|
+
use_global: If True, uses global config even if local exists.
|
|
231
|
+
project_root: Project root directory.
|
|
232
|
+
crm_url: Override CRM URL from config.
|
|
233
|
+
environment: Environment name or index from config.
|
|
234
|
+
proxy_port: Override proxy port from config.
|
|
235
|
+
http_port: Override HTTP server port from config.
|
|
236
|
+
browser: Browser preference (chrome or edge).
|
|
237
|
+
browser_path: Explicit browser executable path.
|
|
238
|
+
mitmproxy_path: Explicit mitmproxy executable path.
|
|
239
|
+
dist_path: Override dist path template from config.
|
|
240
|
+
open_browser: Whether to open browser automatically.
|
|
241
|
+
auto_install: Whether to auto-install mitmproxy if missing.
|
|
242
|
+
detach: If True, runs in background and returns immediately.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
typer.BadParameter: If conflicting options are provided.
|
|
246
|
+
typer.Exit: Exit code 1 if config not found or dist path missing.
|
|
247
|
+
"""
|
|
248
|
+
if use_global and config_path is not None:
|
|
249
|
+
raise typer.BadParameter("Choose either --global or --config, not both.")
|
|
250
|
+
if crm_url is not None and environment is not None:
|
|
251
|
+
raise typer.BadParameter("Choose either --crm-url or --env, not both.")
|
|
252
|
+
if use_global:
|
|
253
|
+
config_path = global_config_path()
|
|
254
|
+
|
|
255
|
+
explicit_root = project_root is not None
|
|
256
|
+
project_root = (project_root or Path(".")).resolve()
|
|
257
|
+
try:
|
|
258
|
+
loaded = load_config(config_path, cwd=project_root)
|
|
259
|
+
except FileNotFoundError as exc:
|
|
260
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
261
|
+
_handle_missing_config(project_root, config_path)
|
|
262
|
+
raise typer.Exit(code=1) from exc
|
|
263
|
+
|
|
264
|
+
config = _apply_overrides(
|
|
265
|
+
loaded.config,
|
|
266
|
+
crm_url=crm_url,
|
|
267
|
+
proxy_port=proxy_port,
|
|
268
|
+
http_port=http_port,
|
|
269
|
+
browser=browser,
|
|
270
|
+
browser_path=browser_path,
|
|
271
|
+
mitmproxy_path=mitmproxy_path,
|
|
272
|
+
dist_path=dist_path,
|
|
273
|
+
open_browser=open_browser,
|
|
274
|
+
auto_install=auto_install,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
selected_env = None
|
|
278
|
+
if crm_url is None:
|
|
279
|
+
config, selected_env = _resolve_environment(config, environment)
|
|
280
|
+
if selected_env:
|
|
281
|
+
typer.echo(f"Using environment: {selected_env.name} ({selected_env.url})")
|
|
282
|
+
|
|
283
|
+
config_path_used = loaded.path
|
|
284
|
+
if config_path_used == global_config_path():
|
|
285
|
+
if config.project_root and not explicit_root:
|
|
286
|
+
project_root = Path(config.project_root).expanduser().resolve()
|
|
287
|
+
typer.echo(f"Using project root from global config: {project_root}")
|
|
288
|
+
typer.echo(f"Using global config: {config_path_used}")
|
|
289
|
+
|
|
290
|
+
if component.lower() in {"auto", "select", "detect"}:
|
|
291
|
+
component = _resolve_component_name(project_root, config)
|
|
292
|
+
typer.echo(f"Using component: {component}")
|
|
293
|
+
|
|
294
|
+
dist_dir = render_dist_path(config, component, project_root)
|
|
295
|
+
if not dist_dir.exists():
|
|
296
|
+
lines = [
|
|
297
|
+
f"Expected: {dist_dir}",
|
|
298
|
+
"Run your PCF build, or update bundle.dist_path in pcf-proxy.yaml.",
|
|
299
|
+
]
|
|
300
|
+
candidates = _detect_component_names(project_root)
|
|
301
|
+
if candidates:
|
|
302
|
+
lines.append(f"Detected control names: {', '.join(candidates)}")
|
|
303
|
+
lines.append(f"Run: pcf-toolkit proxy start {candidates[0]}")
|
|
304
|
+
_rich_tip("Dist path missing", lines)
|
|
305
|
+
raise typer.Exit(code=1)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
mitm_binary = ensure_mitmproxy(config.auto_install, config.mitmproxy.path)
|
|
309
|
+
except FileNotFoundError as exc:
|
|
310
|
+
_rich_tip(
|
|
311
|
+
"mitmproxy not found",
|
|
312
|
+
[
|
|
313
|
+
"Install it or run: pcf-toolkit proxy doctor --fix",
|
|
314
|
+
"Tip: set auto_install: true in pcf-proxy.yaml",
|
|
315
|
+
],
|
|
316
|
+
)
|
|
317
|
+
raise typer.Exit(code=1) from exc
|
|
318
|
+
|
|
319
|
+
session_dir = _session_dir(project_root)
|
|
320
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
log_file = session_dir / "proxy.log"
|
|
322
|
+
stdout = None
|
|
323
|
+
stderr = None
|
|
324
|
+
creationflags = 0
|
|
325
|
+
start_new_session = False
|
|
326
|
+
log_handle = None
|
|
327
|
+
if detach:
|
|
328
|
+
log_handle = log_file.open("a", encoding="utf-8")
|
|
329
|
+
stdout = log_handle
|
|
330
|
+
stderr = log_handle
|
|
331
|
+
if os.name == "nt":
|
|
332
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
333
|
+
else:
|
|
334
|
+
start_new_session = True
|
|
335
|
+
|
|
336
|
+
with _addon_path() as addon_path:
|
|
337
|
+
env = os.environ.copy()
|
|
338
|
+
env.update(
|
|
339
|
+
{
|
|
340
|
+
"PCF_COMPONENT_NAME": component,
|
|
341
|
+
"PCF_EXPECTED_PATH": config.expected_path,
|
|
342
|
+
"HTTP_SERVER_PORT": str(config.http_server.port),
|
|
343
|
+
"HTTP_SERVER_HOST": config.http_server.host,
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
http_proc = spawn_http_server(
|
|
348
|
+
dist_dir,
|
|
349
|
+
config.http_server.host,
|
|
350
|
+
config.http_server.port,
|
|
351
|
+
stdout=stdout,
|
|
352
|
+
stderr=stderr,
|
|
353
|
+
start_new_session=start_new_session,
|
|
354
|
+
creationflags=creationflags,
|
|
355
|
+
)
|
|
356
|
+
mitm_proc = spawn_mitmproxy(
|
|
357
|
+
mitm_binary,
|
|
358
|
+
addon_path,
|
|
359
|
+
config.proxy.host,
|
|
360
|
+
config.proxy.port,
|
|
361
|
+
env=env,
|
|
362
|
+
stdout=stdout,
|
|
363
|
+
stderr=stderr,
|
|
364
|
+
start_new_session=start_new_session,
|
|
365
|
+
creationflags=creationflags,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if log_handle is not None:
|
|
369
|
+
log_handle.close()
|
|
370
|
+
|
|
371
|
+
if config.open_browser and config.crm_url:
|
|
372
|
+
browser_binary = find_browser_binary(config.browser.prefer, config.browser.path)
|
|
373
|
+
if browser_binary:
|
|
374
|
+
profile_dir = session_dir / f"profile-{component}"
|
|
375
|
+
launch_browser(
|
|
376
|
+
browser_binary,
|
|
377
|
+
config.crm_url,
|
|
378
|
+
config.proxy.host,
|
|
379
|
+
config.proxy.port,
|
|
380
|
+
profile_dir,
|
|
381
|
+
)
|
|
382
|
+
else:
|
|
383
|
+
typer.secho(
|
|
384
|
+
"Browser not found. Set browser.path in config to auto-launch.",
|
|
385
|
+
fg=typer.colors.YELLOW,
|
|
386
|
+
)
|
|
387
|
+
elif config.open_browser:
|
|
388
|
+
typer.secho(
|
|
389
|
+
"CRM URL missing. Set crm_url in config to auto-launch the browser.",
|
|
390
|
+
fg=typer.colors.YELLOW,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
session = ProxySession(
|
|
394
|
+
mitm_pid=mitm_proc.pid,
|
|
395
|
+
http_pid=http_proc.pid,
|
|
396
|
+
proxy_port=config.proxy.port,
|
|
397
|
+
http_port=config.http_server.port,
|
|
398
|
+
component=component,
|
|
399
|
+
project_root=str(project_root),
|
|
400
|
+
log_path=str(log_file) if detach else None,
|
|
401
|
+
)
|
|
402
|
+
_write_session(session_dir, session)
|
|
403
|
+
|
|
404
|
+
typer.secho(
|
|
405
|
+
f"Proxy running for {component} on ports {config.proxy.port}/{config.http_server.port}.",
|
|
406
|
+
fg=typer.colors.GREEN,
|
|
407
|
+
)
|
|
408
|
+
if detach:
|
|
409
|
+
typer.echo(f"Detached. Logs: {log_file}")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
typer.secho("Use Ctrl+C to stop.", fg=typer.colors.GREEN)
|
|
413
|
+
|
|
414
|
+
_run_foreground(http_proc, mitm_proc, session_dir)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@app.command("stop", cls=RichTyperCommand, help="Stop a running proxy session.")
|
|
418
|
+
def stop(
|
|
419
|
+
project_root: Path = typer.Option(
|
|
420
|
+
Path("."),
|
|
421
|
+
"--root",
|
|
422
|
+
help="Project root used when starting the proxy.",
|
|
423
|
+
exists=True,
|
|
424
|
+
file_okay=False,
|
|
425
|
+
dir_okay=True,
|
|
426
|
+
),
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Stops a running proxy session.
|
|
429
|
+
|
|
430
|
+
Terminates mitmproxy and HTTP server processes.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
project_root: Project root directory used when starting the proxy.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
typer.Exit: Exit code 1 if no active session found.
|
|
437
|
+
"""
|
|
438
|
+
project_root = project_root.resolve()
|
|
439
|
+
session_dir = _session_dir(project_root)
|
|
440
|
+
session_file = session_dir / "proxy.session.json"
|
|
441
|
+
if not session_file.exists():
|
|
442
|
+
typer.secho("No active proxy session found.", fg=typer.colors.RED)
|
|
443
|
+
raise typer.Exit(code=1)
|
|
444
|
+
|
|
445
|
+
session = _read_session(session_file)
|
|
446
|
+
_terminate_pid(session.mitm_pid)
|
|
447
|
+
_terminate_pid(session.http_pid)
|
|
448
|
+
session_file.unlink(missing_ok=True)
|
|
449
|
+
typer.secho("Proxy session stopped.", fg=typer.colors.GREEN)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@app.command("doctor", cls=RichTyperCommand, help="Check proxy prerequisites.")
|
|
453
|
+
def doctor(
|
|
454
|
+
component: str | None = typer.Option(None, "--component", help="Component name to validate dist path."),
|
|
455
|
+
config_path: Path | None = typer.Option(None, "--config", "-c", help="Path to the proxy config file."),
|
|
456
|
+
project_root: Path = typer.Option(
|
|
457
|
+
Path("."),
|
|
458
|
+
"--root",
|
|
459
|
+
help="Project root (component folder).",
|
|
460
|
+
exists=True,
|
|
461
|
+
file_okay=False,
|
|
462
|
+
dir_okay=True,
|
|
463
|
+
),
|
|
464
|
+
fix: bool = typer.Option(False, "--fix", help="Attempt safe fixes (mitmproxy install)."),
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Checks proxy workflow prerequisites.
|
|
467
|
+
|
|
468
|
+
Validates config, ports, mitmproxy, certificates, browser, and dist paths.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
component: Component name to validate dist path.
|
|
472
|
+
config_path: Path to proxy config file.
|
|
473
|
+
project_root: Project root directory.
|
|
474
|
+
fix: If True, attempts safe fixes (e.g., install mitmproxy).
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
typer.Exit: Exit code 1 if checks fail.
|
|
478
|
+
"""
|
|
479
|
+
project_root = project_root.resolve()
|
|
480
|
+
config: ProxyConfig | None = None
|
|
481
|
+
resolved_path = config_path or default_config_path(project_root)
|
|
482
|
+
if resolved_path.exists():
|
|
483
|
+
try:
|
|
484
|
+
config = load_config(resolved_path, cwd=project_root).config
|
|
485
|
+
except Exception as exc: # noqa: BLE001
|
|
486
|
+
typer.secho(f"Failed to read config: {exc}", fg=typer.colors.RED)
|
|
487
|
+
raise typer.Exit(code=1) from exc
|
|
488
|
+
|
|
489
|
+
results = run_doctor(config, resolved_path, component, project_root)
|
|
490
|
+
_print_results(results)
|
|
491
|
+
|
|
492
|
+
if fix and config:
|
|
493
|
+
_apply_fixes(results, config)
|
|
494
|
+
|
|
495
|
+
if any(result.status == "fail" for result in results):
|
|
496
|
+
raise typer.Exit(code=1)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _apply_overrides(config: ProxyConfig, **overrides: object) -> ProxyConfig:
|
|
500
|
+
"""Applies command-line overrides to proxy configuration.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
config: Base proxy configuration.
|
|
504
|
+
**overrides: Keyword arguments with override values.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
New ProxyConfig instance with overrides applied.
|
|
508
|
+
"""
|
|
509
|
+
update: dict[str, object] = {}
|
|
510
|
+
if overrides.get("crm_url") is not None:
|
|
511
|
+
update["crm_url"] = overrides["crm_url"]
|
|
512
|
+
if overrides.get("proxy_port") is not None:
|
|
513
|
+
update.setdefault("proxy", {})["port"] = overrides["proxy_port"]
|
|
514
|
+
if overrides.get("http_port") is not None:
|
|
515
|
+
update.setdefault("http_server", {})["port"] = overrides["http_port"]
|
|
516
|
+
if overrides.get("browser") is not None:
|
|
517
|
+
update.setdefault("browser", {})["prefer"] = overrides["browser"]
|
|
518
|
+
if overrides.get("browser_path") is not None:
|
|
519
|
+
update.setdefault("browser", {})["path"] = overrides["browser_path"]
|
|
520
|
+
if overrides.get("mitmproxy_path") is not None:
|
|
521
|
+
update.setdefault("mitmproxy", {})["path"] = overrides["mitmproxy_path"]
|
|
522
|
+
if overrides.get("dist_path") is not None:
|
|
523
|
+
update.setdefault("bundle", {})["dist_path"] = overrides["dist_path"]
|
|
524
|
+
if overrides.get("open_browser") is not None:
|
|
525
|
+
update["open_browser"] = overrides["open_browser"]
|
|
526
|
+
if overrides.get("auto_install") is not None:
|
|
527
|
+
update["auto_install"] = overrides["auto_install"]
|
|
528
|
+
|
|
529
|
+
if not update:
|
|
530
|
+
return config
|
|
531
|
+
return config.model_copy(update=update)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _apply_fixes(results: list[CheckResult], config: ProxyConfig) -> None:
|
|
535
|
+
"""Applies automatic fixes based on doctor check results.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
results: List of check results from doctor.
|
|
539
|
+
config: Proxy configuration.
|
|
540
|
+
"""
|
|
541
|
+
for result in results:
|
|
542
|
+
if result.name == "mitmproxy" and result.status == "fail":
|
|
543
|
+
try:
|
|
544
|
+
ensure_mitmproxy(True, config.mitmproxy.path)
|
|
545
|
+
typer.secho("Installed mitmproxy.", fg=typer.colors.GREEN)
|
|
546
|
+
except Exception as exc: # noqa: BLE001
|
|
547
|
+
typer.secho(f"Failed to install mitmproxy: {exc}", fg=typer.colors.RED)
|
|
548
|
+
if result.name == "mitmproxy_cert" and result.status == "warn":
|
|
549
|
+
typer.secho(result.fix or "", fg=typer.colors.YELLOW)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _print_results(results: list[CheckResult]) -> None:
|
|
553
|
+
"""Prints doctor check results with color coding.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
results: List of check results to print.
|
|
557
|
+
"""
|
|
558
|
+
for result in results:
|
|
559
|
+
if result.status == "ok":
|
|
560
|
+
color = typer.colors.GREEN
|
|
561
|
+
elif result.status == "warn":
|
|
562
|
+
color = typer.colors.YELLOW
|
|
563
|
+
else:
|
|
564
|
+
color = typer.colors.RED
|
|
565
|
+
typer.secho(f"[{result.status.upper()}] {result.name}: {result.message}", fg=color)
|
|
566
|
+
if result.fix and result.status != "ok":
|
|
567
|
+
typer.echo(f" -> {result.fix}")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _run_foreground(
|
|
571
|
+
http_proc: subprocess.Popen,
|
|
572
|
+
mitm_proc: subprocess.Popen,
|
|
573
|
+
session_dir: Path,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Runs proxy processes in foreground with signal handling.
|
|
576
|
+
|
|
577
|
+
Monitors processes and cleans up on SIGINT/SIGTERM.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
http_proc: HTTP server process.
|
|
581
|
+
mitm_proc: Mitmproxy process.
|
|
582
|
+
session_dir: Session directory for cleanup.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
def _shutdown(_sig, _frame) -> None:
|
|
586
|
+
_terminate_proc(http_proc)
|
|
587
|
+
_terminate_proc(mitm_proc)
|
|
588
|
+
_clear_session(session_dir)
|
|
589
|
+
raise SystemExit
|
|
590
|
+
|
|
591
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
592
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
while True:
|
|
596
|
+
if http_proc.poll() is not None or mitm_proc.poll() is not None:
|
|
597
|
+
break
|
|
598
|
+
time.sleep(0.2)
|
|
599
|
+
finally:
|
|
600
|
+
_terminate_proc(http_proc)
|
|
601
|
+
_terminate_proc(mitm_proc)
|
|
602
|
+
_clear_session(session_dir)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@contextmanager
|
|
606
|
+
def _addon_path():
|
|
607
|
+
"""Context manager providing path to mitmproxy redirect addon.
|
|
608
|
+
|
|
609
|
+
Yields:
|
|
610
|
+
Path to the redirect_bundle.py addon file.
|
|
611
|
+
"""
|
|
612
|
+
addon = resources.files("pcf_toolkit.proxy.addons").joinpath("redirect_bundle.py")
|
|
613
|
+
with resources.as_file(addon) as path:
|
|
614
|
+
yield path
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _ensure_npm_script(project_root: Path, script_name: str, component: str | None = None) -> None:
|
|
618
|
+
"""Ensures npm script exists in package.json.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
project_root: Project root directory.
|
|
622
|
+
script_name: Name of the npm script to add.
|
|
623
|
+
component: PCF component name to include in the script command.
|
|
624
|
+
"""
|
|
625
|
+
package_json = project_root / "package.json"
|
|
626
|
+
if not package_json.exists():
|
|
627
|
+
typer.secho(
|
|
628
|
+
"package.json not found; skipping npm script update.",
|
|
629
|
+
fg=typer.colors.YELLOW,
|
|
630
|
+
)
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
634
|
+
scripts = data.get("scripts") or {}
|
|
635
|
+
if script_name in scripts:
|
|
636
|
+
typer.echo(f"npm script '{script_name}' already exists.")
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
# Use detected component name, or 'auto' for runtime detection
|
|
640
|
+
component_arg = component or "auto"
|
|
641
|
+
scripts[script_name] = f"uvx pcf-toolkit proxy start {component_arg}"
|
|
642
|
+
data["scripts"] = scripts
|
|
643
|
+
package_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
644
|
+
typer.echo(f"Added npm script '{script_name}' to package.json")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _resolve_init_target_path(
|
|
648
|
+
*,
|
|
649
|
+
config_path: Path | None,
|
|
650
|
+
use_global: bool,
|
|
651
|
+
local_only: bool,
|
|
652
|
+
) -> Path:
|
|
653
|
+
if config_path is not None:
|
|
654
|
+
return config_path
|
|
655
|
+
if use_global:
|
|
656
|
+
return global_config_path()
|
|
657
|
+
if local_only:
|
|
658
|
+
return Path.cwd() / "pcf-proxy.yaml"
|
|
659
|
+
return default_config_path()
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _run_init_flow(
|
|
663
|
+
*,
|
|
664
|
+
target_path: Path,
|
|
665
|
+
project_root: Path,
|
|
666
|
+
force: bool,
|
|
667
|
+
interactive: bool,
|
|
668
|
+
crm_url: str | None,
|
|
669
|
+
install_mitmproxy: bool | None,
|
|
670
|
+
) -> Path:
|
|
671
|
+
header_comment = _build_config_header_comment(project_root, target_path)
|
|
672
|
+
resolved_crm_url = crm_url
|
|
673
|
+
selected_envs: list[EnvironmentConfig] = []
|
|
674
|
+
existing = target_path.exists()
|
|
675
|
+
if existing and not force:
|
|
676
|
+
if interactive and _is_interactive():
|
|
677
|
+
if typer.confirm("Config exists. Update it instead of overwriting?", default=True):
|
|
678
|
+
_update_existing_config(
|
|
679
|
+
target_path=target_path,
|
|
680
|
+
project_root=project_root,
|
|
681
|
+
crm_url=crm_url,
|
|
682
|
+
install_mitmproxy=install_mitmproxy,
|
|
683
|
+
)
|
|
684
|
+
return target_path
|
|
685
|
+
typer.secho(f"Config file already exists: {target_path}", fg=typer.colors.RED)
|
|
686
|
+
raise typer.Exit(code=1)
|
|
687
|
+
|
|
688
|
+
if interactive:
|
|
689
|
+
component_candidates = _detect_component_names(project_root)
|
|
690
|
+
selected_envs, default_env_url, pac_was_available = _prompt_for_environments()
|
|
691
|
+
if default_env_url is not None:
|
|
692
|
+
resolved_crm_url = default_env_url
|
|
693
|
+
elif not pac_was_available:
|
|
694
|
+
# Only ask for manual URL if PAC wasn't available
|
|
695
|
+
resolved_crm_url = _prompt_for_crm_url()
|
|
696
|
+
dist_path = _prompt_for_dist_path(
|
|
697
|
+
default=ProxyConfig().bundle.dist_path,
|
|
698
|
+
project_root=project_root,
|
|
699
|
+
component_candidates=component_candidates,
|
|
700
|
+
)
|
|
701
|
+
if install_mitmproxy is None:
|
|
702
|
+
install_mitmproxy = typer.confirm("Install mitmproxy now (recommended)?", default=True)
|
|
703
|
+
if install_mitmproxy:
|
|
704
|
+
_attempt_mitmproxy_install()
|
|
705
|
+
mitm_path = _prompt_for_mitmproxy_path(current=None, install_mitmproxy=install_mitmproxy)
|
|
706
|
+
else:
|
|
707
|
+
if install_mitmproxy:
|
|
708
|
+
_attempt_mitmproxy_install()
|
|
709
|
+
dist_path = None
|
|
710
|
+
mitm_path = None
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
write_default_config(
|
|
714
|
+
target_path,
|
|
715
|
+
overwrite=force,
|
|
716
|
+
header_comment=header_comment,
|
|
717
|
+
)
|
|
718
|
+
except FileExistsError as exc:
|
|
719
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
720
|
+
raise typer.Exit(code=1) from exc
|
|
721
|
+
|
|
722
|
+
if resolved_crm_url:
|
|
723
|
+
_patch_crm_url(target_path, resolved_crm_url)
|
|
724
|
+
if dist_path:
|
|
725
|
+
_patch_bundle_dist_path(target_path, dist_path)
|
|
726
|
+
if mitm_path:
|
|
727
|
+
_patch_mitmproxy_path(target_path, mitm_path)
|
|
728
|
+
if selected_envs:
|
|
729
|
+
_patch_environments(target_path, selected_envs)
|
|
730
|
+
if target_path == global_config_path():
|
|
731
|
+
resolved_project_root = _prompt_for_project_root(project_root) if interactive else project_root
|
|
732
|
+
_patch_project_root(target_path, resolved_project_root)
|
|
733
|
+
typer.echo(f"Global config saved at {target_path}. You can run the proxy from any directory.")
|
|
734
|
+
typer.echo("Start anywhere with: pcf-toolkit proxy start --global <component>")
|
|
735
|
+
return target_path
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _attempt_mitmproxy_install() -> None:
|
|
739
|
+
try:
|
|
740
|
+
ensure_mitmproxy(True, None)
|
|
741
|
+
typer.secho("mitmproxy is ready.", fg=typer.colors.GREEN)
|
|
742
|
+
except Exception as exc: # noqa: BLE001
|
|
743
|
+
typer.secho(f"mitmproxy install failed: {exc}", fg=typer.colors.RED)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _build_config_header_comment(project_root: Path, target_path: Path) -> list[str]:
|
|
747
|
+
project_name = project_root.name
|
|
748
|
+
repo_url = _git_remote_url(project_root)
|
|
749
|
+
lines = [f"Project: {project_name}"]
|
|
750
|
+
if repo_url:
|
|
751
|
+
lines.append(f"Repo: {repo_url}")
|
|
752
|
+
if target_path == global_config_path():
|
|
753
|
+
lines.append("Global config: used when no local config is found.")
|
|
754
|
+
lines.append("Install: uv tool install pcf-toolkit@latest")
|
|
755
|
+
lines.append("Run without install: uvx pcf-toolkit")
|
|
756
|
+
return lines
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _git_remote_url(project_root: Path) -> str | None:
|
|
760
|
+
try:
|
|
761
|
+
result = subprocess.run(
|
|
762
|
+
["git", "remote", "get-url", "origin"],
|
|
763
|
+
cwd=project_root,
|
|
764
|
+
capture_output=True,
|
|
765
|
+
text=True,
|
|
766
|
+
check=True,
|
|
767
|
+
)
|
|
768
|
+
except Exception:
|
|
769
|
+
return None
|
|
770
|
+
return result.stdout.strip() or None
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _pac_available() -> bool:
|
|
774
|
+
return shutil.which("pac") is not None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _pac_auth_list() -> list[tuple[str, str, bool]]:
|
|
778
|
+
try:
|
|
779
|
+
result = subprocess.run(
|
|
780
|
+
["pac", "auth", "list"],
|
|
781
|
+
capture_output=True,
|
|
782
|
+
text=True,
|
|
783
|
+
check=True,
|
|
784
|
+
)
|
|
785
|
+
except Exception:
|
|
786
|
+
return []
|
|
787
|
+
return _parse_pac_auth_list(result.stdout)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _parse_pac_auth_list(output: str) -> list[tuple[str, str, bool]]:
|
|
791
|
+
lines = [line.rstrip() for line in output.splitlines() if line.strip()]
|
|
792
|
+
header_index = None
|
|
793
|
+
header_line = ""
|
|
794
|
+
for idx, line in enumerate(lines):
|
|
795
|
+
if line.lstrip().startswith("Index") and "Environment Url" in line:
|
|
796
|
+
header_index = idx
|
|
797
|
+
header_line = line
|
|
798
|
+
break
|
|
799
|
+
if header_index is None:
|
|
800
|
+
return []
|
|
801
|
+
|
|
802
|
+
env_col_start = header_line.find("Environment")
|
|
803
|
+
url_col_start = header_line.find("Environment Url")
|
|
804
|
+
if env_col_start < 0:
|
|
805
|
+
env_col_start = None
|
|
806
|
+
if url_col_start < 0:
|
|
807
|
+
url_col_start = None
|
|
808
|
+
|
|
809
|
+
entries: list[tuple[str, str, bool]] = []
|
|
810
|
+
for line in lines[header_index + 1 :]:
|
|
811
|
+
if not line.strip().startswith("["):
|
|
812
|
+
continue
|
|
813
|
+
url_match = re.search(r"https?://\S+", line)
|
|
814
|
+
if not url_match:
|
|
815
|
+
continue
|
|
816
|
+
url = url_match.group(0)
|
|
817
|
+
active = "*" in line
|
|
818
|
+
env_name = None
|
|
819
|
+
if env_col_start is not None:
|
|
820
|
+
end = url_match.start()
|
|
821
|
+
if url_col_start is not None and url_col_start > env_col_start:
|
|
822
|
+
end = min(end, url_col_start)
|
|
823
|
+
if end > env_col_start:
|
|
824
|
+
env_name = line[env_col_start:end].strip() or None
|
|
825
|
+
if not env_name:
|
|
826
|
+
parts = re.split(r"\s{2,}", line.strip())
|
|
827
|
+
if len(parts) >= 2:
|
|
828
|
+
env_name = parts[-2].strip()
|
|
829
|
+
if not env_name:
|
|
830
|
+
env_name = url
|
|
831
|
+
entries.append((env_name, url, active))
|
|
832
|
+
return entries
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _prompt_for_environments() -> tuple[list[EnvironmentConfig], str | None, bool]:
|
|
836
|
+
"""Prompt user to select environments from PAC auth.
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Tuple of (selected environments, default URL, pac_available flag).
|
|
840
|
+
The pac_available flag indicates if PAC was available - if True,
|
|
841
|
+
the caller should NOT prompt for a manual URL since we've already
|
|
842
|
+
handled environment selection from PAC.
|
|
843
|
+
"""
|
|
844
|
+
if not _pac_available():
|
|
845
|
+
typer.secho(
|
|
846
|
+
"PAC CLI not detected. Install it to auto-select environments.",
|
|
847
|
+
fg=typer.colors.YELLOW,
|
|
848
|
+
)
|
|
849
|
+
return [], None, False
|
|
850
|
+
|
|
851
|
+
entries = _pac_auth_list()
|
|
852
|
+
if not entries:
|
|
853
|
+
typer.secho("No PAC auth environments found.", fg=typer.colors.YELLOW)
|
|
854
|
+
return [], None, False
|
|
855
|
+
|
|
856
|
+
selected_entries = _prompt_select_pac_environments(entries)
|
|
857
|
+
if not selected_entries:
|
|
858
|
+
# User had environments available but chose not to select any.
|
|
859
|
+
# Use the active environment as default instead of asking again.
|
|
860
|
+
active_entry = next((e for e in entries if e[2]), entries[0])
|
|
861
|
+
typer.secho(
|
|
862
|
+
f"Using active environment: {active_entry[0]} ({active_entry[1]})",
|
|
863
|
+
fg=typer.colors.CYAN,
|
|
864
|
+
)
|
|
865
|
+
return [], active_entry[1], True
|
|
866
|
+
|
|
867
|
+
envs = [EnvironmentConfig(name=name, url=url, active=active) for name, url, active in selected_entries]
|
|
868
|
+
default_env = _pick_active_environment(envs)
|
|
869
|
+
if default_env:
|
|
870
|
+
typer.secho(
|
|
871
|
+
f"Default environment: {default_env.name} ({default_env.url})",
|
|
872
|
+
fg=typer.colors.CYAN,
|
|
873
|
+
)
|
|
874
|
+
return envs, default_env.url if default_env else None, True
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _prompt_for_crm_url() -> str | None:
|
|
878
|
+
return typer.prompt("Dynamics environment URL", default="", show_default=False) or None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _prompt_select_pac_environments(entries: list[tuple[str, str, bool]]) -> list[tuple[str, str, bool]]:
|
|
882
|
+
if not entries:
|
|
883
|
+
return []
|
|
884
|
+
choices = [_format_env_choice(idx, name, url, active) for idx, (name, url, active) in enumerate(entries, start=1)]
|
|
885
|
+
if questionary is not None and sys.stdout.isatty():
|
|
886
|
+
# Find the active environment indicator for the prompt hint
|
|
887
|
+
active_name = next((name for name, _, active in entries if active), entries[0][0])
|
|
888
|
+
selected = questionary.checkbox(
|
|
889
|
+
f"Select environment(s) to save (skip to use {active_name})",
|
|
890
|
+
choices=choices,
|
|
891
|
+
).ask()
|
|
892
|
+
if not selected:
|
|
893
|
+
return []
|
|
894
|
+
indices = [_parse_choice_index(item) for item in selected]
|
|
895
|
+
return [entries[idx - 1] for idx in indices if 1 <= idx <= len(entries)]
|
|
896
|
+
|
|
897
|
+
selected_entries: list[tuple[str, str, bool]] = []
|
|
898
|
+
while True:
|
|
899
|
+
picked_url = _prompt_select_environment(entries)
|
|
900
|
+
if picked_url is None:
|
|
901
|
+
break
|
|
902
|
+
for entry in entries:
|
|
903
|
+
if entry[1] == picked_url and entry not in selected_entries:
|
|
904
|
+
selected_entries.append(entry)
|
|
905
|
+
break
|
|
906
|
+
if not typer.confirm("Work with another environment?", default=False):
|
|
907
|
+
break
|
|
908
|
+
return selected_entries
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _prompt_for_project_root(project_root: Path) -> Path:
|
|
912
|
+
if not _is_interactive():
|
|
913
|
+
return project_root
|
|
914
|
+
entered = typer.prompt("Project root for this global config", default=str(project_root))
|
|
915
|
+
return Path(entered).expanduser()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _prompt_for_dist_path(
|
|
919
|
+
*,
|
|
920
|
+
default: str,
|
|
921
|
+
project_root: Path,
|
|
922
|
+
component_candidates: list[str],
|
|
923
|
+
) -> str | None:
|
|
924
|
+
if not _is_interactive():
|
|
925
|
+
return default
|
|
926
|
+
candidates = _collect_dist_path_candidates(project_root, default)
|
|
927
|
+
if component_candidates:
|
|
928
|
+
sample = component_candidates[0]
|
|
929
|
+
resolved = project_root / default.replace("{PCF_NAME}", sample)
|
|
930
|
+
suffix = "exists" if resolved.exists() else "missing"
|
|
931
|
+
typer.secho(
|
|
932
|
+
f"Detected control name: {sample} (resolved {resolved} is {suffix})",
|
|
933
|
+
fg=typer.colors.CYAN if resolved.exists() else typer.colors.YELLOW,
|
|
934
|
+
)
|
|
935
|
+
if questionary is not None and sys.stdout.isatty() and len(candidates) > 1:
|
|
936
|
+
choices = list(candidates)
|
|
937
|
+
custom_choice = "Enter a custom path"
|
|
938
|
+
choices.append(custom_choice)
|
|
939
|
+
picked = questionary.select(
|
|
940
|
+
"Select a dist path template",
|
|
941
|
+
choices=choices,
|
|
942
|
+
default=default if default in choices else choices[0],
|
|
943
|
+
use_shortcuts=True,
|
|
944
|
+
).ask()
|
|
945
|
+
if picked == custom_choice:
|
|
946
|
+
return typer.prompt("Dist path template", default=default)
|
|
947
|
+
if picked:
|
|
948
|
+
return str(picked)
|
|
949
|
+
return typer.prompt("Dist path template", default=default)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _collect_dist_path_candidates(project_root: Path, default: str) -> list[str]:
|
|
953
|
+
candidates: list[str] = []
|
|
954
|
+
for template in (default, "dist/controls/{PCF_NAME}", "dist/{PCF_NAME}"):
|
|
955
|
+
if template not in candidates:
|
|
956
|
+
candidates.append(template)
|
|
957
|
+
for base in ("out/controls", "dist/controls", "dist", "build"):
|
|
958
|
+
if (project_root / base).exists():
|
|
959
|
+
template = f"{base}/{{PCF_NAME}}"
|
|
960
|
+
if template not in candidates:
|
|
961
|
+
candidates.append(template)
|
|
962
|
+
return candidates
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _prompt_for_mitmproxy_path(
|
|
966
|
+
*,
|
|
967
|
+
current: Path | None,
|
|
968
|
+
install_mitmproxy: bool | None,
|
|
969
|
+
) -> Path | None:
|
|
970
|
+
if not _is_interactive():
|
|
971
|
+
return current
|
|
972
|
+
detected = find_mitmproxy(current)
|
|
973
|
+
if detected is None:
|
|
974
|
+
return current
|
|
975
|
+
prompt = f"Store mitmproxy path in config ({detected})?"
|
|
976
|
+
if typer.confirm(prompt, default=True):
|
|
977
|
+
return detected
|
|
978
|
+
return current
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _update_existing_config(
|
|
982
|
+
*,
|
|
983
|
+
target_path: Path,
|
|
984
|
+
project_root: Path,
|
|
985
|
+
crm_url: str | None,
|
|
986
|
+
install_mitmproxy: bool | None,
|
|
987
|
+
) -> None:
|
|
988
|
+
existing = load_config(target_path, cwd=project_root).config
|
|
989
|
+
component_candidates = _detect_component_names(project_root)
|
|
990
|
+
resolved_crm_url = crm_url
|
|
991
|
+
selected_envs: list[EnvironmentConfig] = []
|
|
992
|
+
pac_was_available = False
|
|
993
|
+
if _pac_available() and _is_interactive():
|
|
994
|
+
if typer.confirm("Update environment list from PAC auth?", default=True):
|
|
995
|
+
selected_envs, default_env_url, pac_was_available = _prompt_for_environments()
|
|
996
|
+
if default_env_url is not None:
|
|
997
|
+
resolved_crm_url = default_env_url
|
|
998
|
+
if resolved_crm_url is None and not pac_was_available:
|
|
999
|
+
# Only ask for manual URL if we didn't get one from PAC
|
|
1000
|
+
resolved_crm_url = (
|
|
1001
|
+
typer.prompt(
|
|
1002
|
+
"Dynamics environment URL",
|
|
1003
|
+
default=existing.crm_url or "",
|
|
1004
|
+
show_default=bool(existing.crm_url),
|
|
1005
|
+
)
|
|
1006
|
+
or None
|
|
1007
|
+
)
|
|
1008
|
+
dist_path = _prompt_for_dist_path(
|
|
1009
|
+
default=existing.bundle.dist_path,
|
|
1010
|
+
project_root=project_root,
|
|
1011
|
+
component_candidates=component_candidates,
|
|
1012
|
+
)
|
|
1013
|
+
if install_mitmproxy is None:
|
|
1014
|
+
install_mitmproxy = typer.confirm("Install mitmproxy now (recommended)?", default=existing.auto_install)
|
|
1015
|
+
if install_mitmproxy:
|
|
1016
|
+
_attempt_mitmproxy_install()
|
|
1017
|
+
mitm_path = _prompt_for_mitmproxy_path(current=existing.mitmproxy.path, install_mitmproxy=install_mitmproxy)
|
|
1018
|
+
if resolved_crm_url:
|
|
1019
|
+
_patch_crm_url(target_path, resolved_crm_url)
|
|
1020
|
+
if dist_path:
|
|
1021
|
+
_patch_bundle_dist_path(target_path, dist_path)
|
|
1022
|
+
if mitm_path:
|
|
1023
|
+
_patch_mitmproxy_path(target_path, mitm_path)
|
|
1024
|
+
if selected_envs:
|
|
1025
|
+
_patch_environments(target_path, selected_envs)
|
|
1026
|
+
if target_path == global_config_path():
|
|
1027
|
+
resolved_project_root = _prompt_for_project_root(project_root)
|
|
1028
|
+
_patch_project_root(target_path, resolved_project_root)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _patch_crm_url(path: Path, crm_url: str) -> None:
|
|
1032
|
+
if path.suffix.lower() == ".json":
|
|
1033
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1034
|
+
data["crm_url"] = crm_url
|
|
1035
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1036
|
+
return
|
|
1037
|
+
text = path.read_text(encoding="utf-8")
|
|
1038
|
+
lines = text.splitlines()
|
|
1039
|
+
updated = False
|
|
1040
|
+
for idx, line in enumerate(lines):
|
|
1041
|
+
if line.strip().startswith("crm_url:"):
|
|
1042
|
+
lines[idx] = f'crm_url: "{crm_url}"'
|
|
1043
|
+
updated = True
|
|
1044
|
+
break
|
|
1045
|
+
if not updated:
|
|
1046
|
+
lines.append(f'crm_url: "{crm_url}"')
|
|
1047
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _patch_project_root(path: Path, project_root: Path) -> None:
|
|
1051
|
+
if path.suffix.lower() == ".json":
|
|
1052
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1053
|
+
data["project_root"] = str(project_root)
|
|
1054
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1055
|
+
return
|
|
1056
|
+
text = path.read_text(encoding="utf-8")
|
|
1057
|
+
lines = text.splitlines()
|
|
1058
|
+
updated = False
|
|
1059
|
+
for idx, line in enumerate(lines):
|
|
1060
|
+
if line.strip().startswith("project_root:"):
|
|
1061
|
+
lines[idx] = f'project_root: "{project_root}"'
|
|
1062
|
+
updated = True
|
|
1063
|
+
break
|
|
1064
|
+
if not updated:
|
|
1065
|
+
lines.append(f'project_root: "{project_root}"')
|
|
1066
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _patch_bundle_dist_path(path: Path, dist_path: str) -> None:
|
|
1070
|
+
if path.suffix.lower() == ".json":
|
|
1071
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1072
|
+
bundle = data.get("bundle") or {}
|
|
1073
|
+
bundle["dist_path"] = dist_path
|
|
1074
|
+
data["bundle"] = bundle
|
|
1075
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1076
|
+
return
|
|
1077
|
+
_patch_yaml_nested_key(path, "bundle", "dist_path", dist_path)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _patch_mitmproxy_path(path: Path, mitm_path: Path) -> None:
|
|
1081
|
+
value = str(mitm_path)
|
|
1082
|
+
if path.suffix.lower() == ".json":
|
|
1083
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1084
|
+
mitm = data.get("mitmproxy") or {}
|
|
1085
|
+
mitm["path"] = value
|
|
1086
|
+
data["mitmproxy"] = mitm
|
|
1087
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1088
|
+
return
|
|
1089
|
+
_patch_yaml_nested_key(path, "mitmproxy", "path", value)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _patch_environments(path: Path, envs: list[EnvironmentConfig]) -> None:
|
|
1093
|
+
if path.suffix.lower() == ".json":
|
|
1094
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1095
|
+
data["environments"] = [{"name": env.name, "url": env.url, "active": env.active} for env in envs]
|
|
1096
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1097
|
+
return
|
|
1098
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
1099
|
+
block = _format_environments_yaml(envs)
|
|
1100
|
+
lines = _replace_yaml_block(lines, "environments", block)
|
|
1101
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _format_environments_yaml(envs: list[EnvironmentConfig]) -> list[str]:
|
|
1105
|
+
lines = ["environments:"]
|
|
1106
|
+
for env in envs:
|
|
1107
|
+
lines.append(f" - name: {_yaml_format_value(env.name)}")
|
|
1108
|
+
lines.append(f" url: {_yaml_format_value(env.url)}")
|
|
1109
|
+
if env.active:
|
|
1110
|
+
lines.append(" active: true")
|
|
1111
|
+
return lines
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _replace_yaml_block(lines: list[str], key: str, block: list[str]) -> list[str]:
|
|
1115
|
+
start = None
|
|
1116
|
+
end = None
|
|
1117
|
+
for idx, line in enumerate(lines):
|
|
1118
|
+
stripped = line.strip()
|
|
1119
|
+
if not stripped or stripped.startswith("#"):
|
|
1120
|
+
continue
|
|
1121
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1122
|
+
if indent == 0 and stripped.startswith(f"{key}:"):
|
|
1123
|
+
start = idx
|
|
1124
|
+
continue
|
|
1125
|
+
if start is not None and indent == 0 and idx != start:
|
|
1126
|
+
end = idx
|
|
1127
|
+
break
|
|
1128
|
+
if start is None:
|
|
1129
|
+
if lines and lines[-1].strip():
|
|
1130
|
+
lines.append("")
|
|
1131
|
+
return lines + block
|
|
1132
|
+
if end is None:
|
|
1133
|
+
end = len(lines)
|
|
1134
|
+
return lines[:start] + block + lines[end:]
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _patch_yaml_nested_key(path: Path, section: str, key: str, value: str) -> None:
|
|
1138
|
+
text = path.read_text(encoding="utf-8")
|
|
1139
|
+
lines = text.splitlines()
|
|
1140
|
+
formatted = _yaml_format_value(value)
|
|
1141
|
+
section_index = None
|
|
1142
|
+
section_indent = 0
|
|
1143
|
+
updated = False
|
|
1144
|
+
insert_at = None
|
|
1145
|
+
|
|
1146
|
+
for idx, line in enumerate(lines):
|
|
1147
|
+
stripped = line.strip()
|
|
1148
|
+
if not stripped or stripped.startswith("#"):
|
|
1149
|
+
continue
|
|
1150
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1151
|
+
if stripped.startswith(f"{section}:"):
|
|
1152
|
+
section_index = idx
|
|
1153
|
+
section_indent = indent
|
|
1154
|
+
insert_at = idx + 1
|
|
1155
|
+
continue
|
|
1156
|
+
if section_index is not None:
|
|
1157
|
+
if indent <= section_indent and stripped.endswith(":"):
|
|
1158
|
+
insert_at = idx
|
|
1159
|
+
break
|
|
1160
|
+
if stripped.startswith(f"{key}:"):
|
|
1161
|
+
lines[idx] = " " * (section_indent + 2) + f"{key}: {formatted}"
|
|
1162
|
+
updated = True
|
|
1163
|
+
break
|
|
1164
|
+
insert_at = idx + 1
|
|
1165
|
+
|
|
1166
|
+
if not updated:
|
|
1167
|
+
if section_index is None:
|
|
1168
|
+
lines.append(f"{section}:")
|
|
1169
|
+
lines.append(" " * 2 + f"{key}: {formatted}")
|
|
1170
|
+
else:
|
|
1171
|
+
if insert_at is None:
|
|
1172
|
+
insert_at = section_index + 1
|
|
1173
|
+
lines.insert(insert_at, " " * (section_indent + 2) + f"{key}: {formatted}")
|
|
1174
|
+
|
|
1175
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _yaml_format_value(value: str) -> str:
|
|
1179
|
+
if value == "":
|
|
1180
|
+
return '""'
|
|
1181
|
+
if re.search(r"[:#\\s]", value):
|
|
1182
|
+
escaped = value.replace('"', '\\"')
|
|
1183
|
+
return f'"{escaped}"'
|
|
1184
|
+
return value
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _handle_missing_config(project_root: Path, config_path: Path | None) -> None:
|
|
1188
|
+
if config_path is not None:
|
|
1189
|
+
_rich_tip(
|
|
1190
|
+
"Config not found",
|
|
1191
|
+
[
|
|
1192
|
+
"Pass a valid config via --config.",
|
|
1193
|
+
"Or run: pcf-toolkit proxy init",
|
|
1194
|
+
],
|
|
1195
|
+
)
|
|
1196
|
+
return
|
|
1197
|
+
if not sys.stdout.isatty():
|
|
1198
|
+
_rich_tip(
|
|
1199
|
+
"Config not found",
|
|
1200
|
+
[
|
|
1201
|
+
"Run: pcf-toolkit proxy init",
|
|
1202
|
+
"Tip: use --global to run from anywhere.",
|
|
1203
|
+
],
|
|
1204
|
+
)
|
|
1205
|
+
return
|
|
1206
|
+
_rich_tip(
|
|
1207
|
+
"Config not found",
|
|
1208
|
+
[
|
|
1209
|
+
"No proxy config found in this directory or global config.",
|
|
1210
|
+
"Create one now to continue.",
|
|
1211
|
+
],
|
|
1212
|
+
)
|
|
1213
|
+
if typer.confirm("Create one now?", default=True):
|
|
1214
|
+
target = _resolve_init_target_path(
|
|
1215
|
+
config_path=None,
|
|
1216
|
+
use_global=typer.confirm("Store globally (so you can run anywhere)?", default=False),
|
|
1217
|
+
local_only=False,
|
|
1218
|
+
)
|
|
1219
|
+
_run_init_flow(
|
|
1220
|
+
target_path=target,
|
|
1221
|
+
project_root=project_root,
|
|
1222
|
+
force=False,
|
|
1223
|
+
interactive=True,
|
|
1224
|
+
crm_url=None,
|
|
1225
|
+
install_mitmproxy=None,
|
|
1226
|
+
)
|
|
1227
|
+
typer.echo("Config created. Re-run your command.")
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _resolve_environment(config: ProxyConfig, requested: str | None) -> tuple[ProxyConfig, EnvironmentConfig | None]:
|
|
1231
|
+
envs = config.environments or []
|
|
1232
|
+
if not envs:
|
|
1233
|
+
return config, None
|
|
1234
|
+
|
|
1235
|
+
selected: EnvironmentConfig | None = None
|
|
1236
|
+
if requested:
|
|
1237
|
+
selected = _match_environment(envs, requested)
|
|
1238
|
+
if selected is None:
|
|
1239
|
+
available = ", ".join(env.name for env in envs) or "none"
|
|
1240
|
+
_rich_tip(
|
|
1241
|
+
"Environment not found",
|
|
1242
|
+
[
|
|
1243
|
+
f"Requested: {requested}",
|
|
1244
|
+
f"Available: {available}",
|
|
1245
|
+
"Run: pcf-toolkit proxy start --env <name>",
|
|
1246
|
+
],
|
|
1247
|
+
)
|
|
1248
|
+
raise typer.Exit(code=1)
|
|
1249
|
+
elif _is_interactive() and len(envs) > 1:
|
|
1250
|
+
selected = _prompt_select_config_environment(envs)
|
|
1251
|
+
if selected is None:
|
|
1252
|
+
selected = _pick_active_environment(envs) or envs[0]
|
|
1253
|
+
|
|
1254
|
+
if selected.url:
|
|
1255
|
+
config = config.model_copy(update={"crm_url": selected.url})
|
|
1256
|
+
return config, selected
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _match_environment(envs: list[EnvironmentConfig], requested: str) -> EnvironmentConfig | None:
|
|
1260
|
+
cleaned = requested.strip()
|
|
1261
|
+
if cleaned.isdigit():
|
|
1262
|
+
index = int(cleaned)
|
|
1263
|
+
if 1 <= index <= len(envs):
|
|
1264
|
+
return envs[index - 1]
|
|
1265
|
+
for env in envs:
|
|
1266
|
+
if env.name.lower() == cleaned.lower():
|
|
1267
|
+
return env
|
|
1268
|
+
for env in envs:
|
|
1269
|
+
if env.url.lower() == cleaned.lower():
|
|
1270
|
+
return env
|
|
1271
|
+
return None
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _pick_active_environment(
|
|
1275
|
+
envs: list[EnvironmentConfig],
|
|
1276
|
+
) -> EnvironmentConfig | None:
|
|
1277
|
+
for env in envs:
|
|
1278
|
+
if env.active:
|
|
1279
|
+
return env
|
|
1280
|
+
return envs[0] if envs else None
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _prompt_select_config_environment(
|
|
1284
|
+
envs: list[EnvironmentConfig],
|
|
1285
|
+
) -> EnvironmentConfig | None:
|
|
1286
|
+
if not envs:
|
|
1287
|
+
return None
|
|
1288
|
+
choices = [_format_env_choice(idx, env.name, env.url, env.active) for idx, env in enumerate(envs, start=1)]
|
|
1289
|
+
default_env = _pick_active_environment(envs) or envs[0]
|
|
1290
|
+
default_index = envs.index(default_env) + 1
|
|
1291
|
+
if questionary is not None and sys.stdout.isatty():
|
|
1292
|
+
choice = questionary.select(
|
|
1293
|
+
"Select a Dynamics environment",
|
|
1294
|
+
choices=choices,
|
|
1295
|
+
default=choices[default_index - 1],
|
|
1296
|
+
use_shortcuts=True,
|
|
1297
|
+
).ask()
|
|
1298
|
+
if not choice:
|
|
1299
|
+
return None
|
|
1300
|
+
selected_index = _parse_choice_index(choice)
|
|
1301
|
+
if 1 <= selected_index <= len(envs):
|
|
1302
|
+
return envs[selected_index - 1]
|
|
1303
|
+
return None
|
|
1304
|
+
|
|
1305
|
+
typer.echo("Select a Dynamics environment:")
|
|
1306
|
+
for item in choices:
|
|
1307
|
+
typer.echo(f" {item}")
|
|
1308
|
+
pick = typer.prompt("Pick an environment", default=default_index, type=int)
|
|
1309
|
+
pick = max(1, min(pick, len(envs)))
|
|
1310
|
+
return envs[pick - 1]
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _format_env_choice(index: int, name: str, url: str, active: bool) -> str:
|
|
1314
|
+
marker = "★" if active else " "
|
|
1315
|
+
return f"[{index}] {marker} {name} - {url}"
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _parse_choice_index(choice: str) -> int:
|
|
1319
|
+
try:
|
|
1320
|
+
return int(choice.split("]")[0].lstrip("["))
|
|
1321
|
+
except Exception:
|
|
1322
|
+
return 0
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _prompt_select_environment(entries: list[tuple[str, str, bool]]) -> str | None:
|
|
1326
|
+
if not entries:
|
|
1327
|
+
return typer.prompt("Dynamics environment URL", default="", show_default=False) or None
|
|
1328
|
+
default_index = next((i for i, item in enumerate(entries, start=1) if item[2]), 1)
|
|
1329
|
+
choices = [_format_env_choice(idx, name, url, active) for idx, (name, url, active) in enumerate(entries, start=1)]
|
|
1330
|
+
|
|
1331
|
+
if questionary is not None and sys.stdout.isatty():
|
|
1332
|
+
choice = questionary.select(
|
|
1333
|
+
"Select a Dynamics environment",
|
|
1334
|
+
choices=choices,
|
|
1335
|
+
default=choices[default_index - 1],
|
|
1336
|
+
use_shortcuts=True,
|
|
1337
|
+
).ask()
|
|
1338
|
+
if not choice:
|
|
1339
|
+
return None
|
|
1340
|
+
selected_index = _parse_choice_index(choice)
|
|
1341
|
+
return entries[selected_index - 1][1]
|
|
1342
|
+
|
|
1343
|
+
typer.echo("Select a Dynamics environment:")
|
|
1344
|
+
for item in choices:
|
|
1345
|
+
typer.echo(f" {item}")
|
|
1346
|
+
pick = typer.prompt("Pick an environment", default=default_index, type=int)
|
|
1347
|
+
pick = max(1, min(pick, len(entries)))
|
|
1348
|
+
return entries[pick - 1][1]
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _rich_tip(title: str, lines: list[str]) -> None:
|
|
1352
|
+
console = rich_console(stderr=True)
|
|
1353
|
+
if console is None or Panel is None or Text is None or Table is None:
|
|
1354
|
+
typer.echo(f"{title}: " + " ".join(lines), err=True)
|
|
1355
|
+
return
|
|
1356
|
+
sections = _split_tip_lines(lines)
|
|
1357
|
+
body = _build_tip_table(sections)
|
|
1358
|
+
panel = Panel(body, title=title, title_align="left", border_style="cyan")
|
|
1359
|
+
console.print(panel)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _resolve_component_name(project_root: Path, config: ProxyConfig | None = None) -> str:
|
|
1363
|
+
candidates = _detect_component_names(project_root)
|
|
1364
|
+
if not candidates:
|
|
1365
|
+
_rich_tip(
|
|
1366
|
+
"Component not found",
|
|
1367
|
+
[
|
|
1368
|
+
"No ControlManifest.Input.xml or manifest.yaml/json found.",
|
|
1369
|
+
"Run: pcf-toolkit proxy start <ComponentName>",
|
|
1370
|
+
],
|
|
1371
|
+
)
|
|
1372
|
+
raise typer.Exit(code=1)
|
|
1373
|
+
if config is not None:
|
|
1374
|
+
existing = [name for name in candidates if render_dist_path(config, name, project_root).exists()]
|
|
1375
|
+
if existing:
|
|
1376
|
+
candidates = existing
|
|
1377
|
+
if len(candidates) == 1 or not _is_interactive():
|
|
1378
|
+
return candidates[0]
|
|
1379
|
+
choice = _prompt_select_component(candidates)
|
|
1380
|
+
return choice or candidates[0]
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _prompt_select_component(candidates: list[str]) -> str | None:
|
|
1384
|
+
if not candidates:
|
|
1385
|
+
return None
|
|
1386
|
+
if questionary is not None and sys.stdout.isatty():
|
|
1387
|
+
return questionary.select(
|
|
1388
|
+
"Select a component",
|
|
1389
|
+
choices=candidates,
|
|
1390
|
+
default=candidates[0],
|
|
1391
|
+
use_shortcuts=True,
|
|
1392
|
+
).ask()
|
|
1393
|
+
typer.echo("Select a component:")
|
|
1394
|
+
for idx, name in enumerate(candidates, start=1):
|
|
1395
|
+
typer.echo(f" [{idx}] {name}")
|
|
1396
|
+
pick = typer.prompt("Pick a component", default=1, type=int)
|
|
1397
|
+
pick = max(1, min(pick, len(candidates)))
|
|
1398
|
+
return candidates[pick - 1]
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _detect_component_names(project_root: Path) -> list[str]:
|
|
1402
|
+
candidates: list[str] = []
|
|
1403
|
+
for path in project_root.rglob("ControlManifest.Input.xml"):
|
|
1404
|
+
if not path.is_file():
|
|
1405
|
+
continue
|
|
1406
|
+
parsed = _parse_manifest_xml(path)
|
|
1407
|
+
for name in parsed:
|
|
1408
|
+
if name not in candidates:
|
|
1409
|
+
candidates.append(name)
|
|
1410
|
+
if len(candidates) >= 5:
|
|
1411
|
+
break
|
|
1412
|
+
for name in _parse_manifest_yaml_json(project_root):
|
|
1413
|
+
if name not in candidates:
|
|
1414
|
+
candidates.append(name)
|
|
1415
|
+
return candidates
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _parse_manifest_xml(path: Path) -> list[str]:
|
|
1419
|
+
try:
|
|
1420
|
+
import xml.etree.ElementTree as ET
|
|
1421
|
+
|
|
1422
|
+
tree = ET.parse(path)
|
|
1423
|
+
root = tree.getroot()
|
|
1424
|
+
|
|
1425
|
+
def _strip_ns(tag: str) -> str:
|
|
1426
|
+
if "}" in tag:
|
|
1427
|
+
return tag.split("}", 1)[1]
|
|
1428
|
+
return tag
|
|
1429
|
+
|
|
1430
|
+
if _strip_ns(root.tag) == "control":
|
|
1431
|
+
control = root
|
|
1432
|
+
else:
|
|
1433
|
+
control = next(
|
|
1434
|
+
(child for child in root if _strip_ns(child.tag) == "control"),
|
|
1435
|
+
None,
|
|
1436
|
+
)
|
|
1437
|
+
if control is None:
|
|
1438
|
+
return []
|
|
1439
|
+
namespace = control.attrib.get("namespace")
|
|
1440
|
+
constructor = control.attrib.get("constructor")
|
|
1441
|
+
if not constructor:
|
|
1442
|
+
return []
|
|
1443
|
+
names = [constructor]
|
|
1444
|
+
if namespace:
|
|
1445
|
+
names.insert(0, f"{namespace}.{constructor}")
|
|
1446
|
+
return names
|
|
1447
|
+
except Exception: # noqa: BLE001
|
|
1448
|
+
return []
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _parse_manifest_yaml_json(project_root: Path) -> list[str]:
|
|
1452
|
+
candidates: list[str] = []
|
|
1453
|
+
for filename in ("manifest.yaml", "manifest.yml", "manifest.json"):
|
|
1454
|
+
path = project_root / filename
|
|
1455
|
+
if not path.exists():
|
|
1456
|
+
continue
|
|
1457
|
+
try:
|
|
1458
|
+
if path.suffix.lower() == ".json":
|
|
1459
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1460
|
+
else:
|
|
1461
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
1462
|
+
except Exception: # noqa: BLE001
|
|
1463
|
+
continue
|
|
1464
|
+
control = data.get("control", {}) if isinstance(data, dict) else {}
|
|
1465
|
+
namespace = control.get("namespace")
|
|
1466
|
+
constructor = control.get("constructor")
|
|
1467
|
+
if constructor:
|
|
1468
|
+
candidates.append(constructor)
|
|
1469
|
+
if namespace:
|
|
1470
|
+
candidates.append(f"{namespace}.{constructor}")
|
|
1471
|
+
return candidates
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _split_tip_lines(lines: list[str]) -> dict[str, list[str]]:
|
|
1475
|
+
sections = {"info": [], "expected": [], "actions": [], "tips": []}
|
|
1476
|
+
for raw in lines:
|
|
1477
|
+
line = raw.strip()
|
|
1478
|
+
if not line:
|
|
1479
|
+
continue
|
|
1480
|
+
lower = line.lower()
|
|
1481
|
+
if lower.startswith("expected:"):
|
|
1482
|
+
sections["expected"].append(line.split(":", 1)[1].strip())
|
|
1483
|
+
continue
|
|
1484
|
+
if lower.startswith("tip:"):
|
|
1485
|
+
sections["tips"].append(line.split(":", 1)[1].strip())
|
|
1486
|
+
continue
|
|
1487
|
+
action_match = re.search(r"\brun:\s*(.+)$", line, re.IGNORECASE)
|
|
1488
|
+
if action_match:
|
|
1489
|
+
prefix = line[: action_match.start()].strip(" :")
|
|
1490
|
+
command = action_match.group(1).strip()
|
|
1491
|
+
if prefix and prefix.lower() not in ("or", "run"):
|
|
1492
|
+
sections["info"].append(prefix)
|
|
1493
|
+
if command:
|
|
1494
|
+
sections["actions"].append(command)
|
|
1495
|
+
continue
|
|
1496
|
+
sections["info"].append(line)
|
|
1497
|
+
return sections
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _build_tip_table(sections: dict[str, list[str]]) -> Table:
|
|
1501
|
+
table = Table.grid(padding=(0, 1))
|
|
1502
|
+
table.add_column(style="bold", no_wrap=True)
|
|
1503
|
+
table.add_column()
|
|
1504
|
+
info = sections.get("info", [])
|
|
1505
|
+
for idx, line in enumerate(info):
|
|
1506
|
+
label = "Info" if idx == 0 else ""
|
|
1507
|
+
table.add_row(label, Text(line))
|
|
1508
|
+
for line in sections.get("expected", []):
|
|
1509
|
+
table.add_row("Expected", Text(line))
|
|
1510
|
+
for line in sections.get("actions", []):
|
|
1511
|
+
table.add_row("Try", _style_command(line))
|
|
1512
|
+
for line in sections.get("tips", []):
|
|
1513
|
+
table.add_row("Tip", Text(line))
|
|
1514
|
+
return table
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _style_command(command: str) -> Text:
|
|
1518
|
+
text = Text(command)
|
|
1519
|
+
if command:
|
|
1520
|
+
text.stylize("bold cyan")
|
|
1521
|
+
return text
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _is_interactive() -> bool:
|
|
1525
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _session_dir(project_root: Path) -> Path:
|
|
1529
|
+
return project_root / ".pcf-toolkit"
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _write_session(session_dir: Path, session: ProxySession) -> None:
|
|
1533
|
+
session_path = session_dir / "proxy.session.json"
|
|
1534
|
+
session_path.write_text(json.dumps(asdict(session), indent=2) + "\n", encoding="utf-8")
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _read_session(session_path: Path) -> ProxySession:
|
|
1538
|
+
data = json.loads(session_path.read_text(encoding="utf-8"))
|
|
1539
|
+
return ProxySession(**data)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _clear_session(session_dir: Path) -> None:
|
|
1543
|
+
session_path = session_dir / "proxy.session.json"
|
|
1544
|
+
if session_path.exists():
|
|
1545
|
+
session_path.unlink()
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _terminate_proc(proc: subprocess.Popen) -> None:
|
|
1549
|
+
if proc.poll() is None:
|
|
1550
|
+
try:
|
|
1551
|
+
proc.terminate()
|
|
1552
|
+
except Exception: # noqa: BLE001
|
|
1553
|
+
return
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _terminate_pid(pid: int) -> None:
|
|
1557
|
+
if pid <= 0:
|
|
1558
|
+
return
|
|
1559
|
+
if os.name == "nt":
|
|
1560
|
+
subprocess.run(
|
|
1561
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
1562
|
+
stdout=subprocess.DEVNULL,
|
|
1563
|
+
stderr=subprocess.DEVNULL,
|
|
1564
|
+
check=False,
|
|
1565
|
+
)
|
|
1566
|
+
return
|
|
1567
|
+
try:
|
|
1568
|
+
os.kill(pid, signal.SIGTERM)
|
|
1569
|
+
except ProcessLookupError:
|
|
1570
|
+
return
|