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.
@@ -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