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,310 @@
1
+ """Proxy configuration schema and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+
11
+ DEFAULT_CONFIG_FILENAMES = ("pcf-proxy.yaml", "pcf-proxy.yml", "pcf-proxy.json")
12
+
13
+
14
+ class ProxyEndpoint(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+ host: str = Field(default="127.0.0.1")
18
+ port: int = Field(default=8080)
19
+
20
+
21
+ class HttpServerConfig(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ host: str = Field(default="127.0.0.1")
25
+ port: int = Field(default=8082)
26
+
27
+
28
+ class BundleConfig(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ dist_path: str = Field(
32
+ default="out/controls/{PCF_NAME}",
33
+ description="Relative path to the built control output.",
34
+ )
35
+
36
+
37
+ class BrowserConfig(BaseModel):
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ prefer: str | None = Field(default="chrome", description="Preferred browser: chrome or edge.")
41
+ path: Path | None = Field(default=None, description="Explicit browser executable path.")
42
+
43
+
44
+ class MitmproxyConfig(BaseModel):
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ path: Path | None = Field(default=None, description="Explicit mitmproxy/mitmdump executable path.")
48
+
49
+
50
+ class EnvironmentConfig(BaseModel):
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ name: str = Field(description="Environment name from PAC CLI.")
54
+ url: str = Field(description="Environment URL.")
55
+ active: bool = Field(default=False, description="Active PAC environment.")
56
+
57
+
58
+ class ProxyConfig(BaseModel):
59
+ """Configuration for the PCF proxy workflow."""
60
+
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ project_root: str | None = Field(
64
+ default=None,
65
+ description="Project root path for global configs.",
66
+ )
67
+ crm_url: str | None = Field(default=None, description="Base CRM URL, e.g. https://yourorg.crm.dynamics.com/")
68
+ expected_path: str = Field(
69
+ default="/webresources/{PCF_NAME}/",
70
+ description="Request path template that matches the PCF webresource base.",
71
+ )
72
+ proxy: ProxyEndpoint = Field(default_factory=ProxyEndpoint)
73
+ http_server: HttpServerConfig = Field(default_factory=HttpServerConfig)
74
+ bundle: BundleConfig = Field(default_factory=BundleConfig)
75
+ browser: BrowserConfig = Field(default_factory=BrowserConfig)
76
+ mitmproxy: MitmproxyConfig = Field(default_factory=MitmproxyConfig)
77
+ environments: list[EnvironmentConfig] | None = Field(
78
+ default=None,
79
+ description="Known CRM environments for selection.",
80
+ )
81
+ open_browser: bool = Field(default=True)
82
+ auto_install: bool = Field(default=True)
83
+
84
+ @model_validator(mode="before")
85
+ @classmethod
86
+ def _migrate_flat_keys(cls, data: Any) -> Any:
87
+ if not isinstance(data, dict):
88
+ return data
89
+ data = dict(data)
90
+ data.pop("$schema", None)
91
+ if "proxy" in data or "http_server" in data or "bundle" in data:
92
+ return data
93
+ migrated = dict(data)
94
+ proxy = {"host": migrated.pop("proxy_host", None), "port": migrated.pop("proxy_port", None)}
95
+ http_server = {
96
+ "host": migrated.pop("http_host", None),
97
+ "port": migrated.pop("http_port", None),
98
+ }
99
+ bundle = {"dist_path": migrated.pop("dist_path", None)}
100
+ browser = {
101
+ "prefer": migrated.pop("browser", None),
102
+ "path": migrated.pop("browser_path", None),
103
+ }
104
+ mitmproxy = {"path": migrated.pop("mitmproxy_path", None)}
105
+ migrated["proxy"] = {k: v for k, v in proxy.items() if v is not None}
106
+ migrated["http_server"] = {k: v for k, v in http_server.items() if v is not None}
107
+ migrated["bundle"] = {k: v for k, v in bundle.items() if v is not None}
108
+ migrated["browser"] = {k: v for k, v in browser.items() if v is not None}
109
+ migrated["mitmproxy"] = {k: v for k, v in mitmproxy.items() if v is not None}
110
+ return migrated
111
+
112
+
113
+ class LoadedConfig(BaseModel):
114
+ """Configuration plus metadata about where it came from."""
115
+
116
+ model_config = ConfigDict(arbitrary_types_allowed=True)
117
+
118
+ path: Path
119
+ config: ProxyConfig
120
+
121
+
122
+ def global_config_path() -> Path:
123
+ """Returns the path to the global proxy config file.
124
+
125
+ Returns:
126
+ Path to ~/.pcf-toolkit/pcf-proxy.yaml.
127
+ """
128
+ return Path.home() / ".pcf-toolkit" / "pcf-proxy.yaml"
129
+
130
+
131
+ def default_config_path(cwd: Path | None = None) -> Path:
132
+ """Returns the default config path for the current directory.
133
+
134
+ Searches for existing config files first, otherwise returns the default name.
135
+
136
+ Args:
137
+ cwd: Current working directory. If None, uses Path.cwd().
138
+
139
+ Returns:
140
+ Path to the config file (existing or default).
141
+ """
142
+ root = cwd or Path.cwd()
143
+ for filename in DEFAULT_CONFIG_FILENAMES:
144
+ candidate = root / filename
145
+ if candidate.exists():
146
+ return candidate
147
+ return root / DEFAULT_CONFIG_FILENAMES[0]
148
+
149
+
150
+ def load_config(path: Path | None = None, cwd: Path | None = None) -> LoadedConfig:
151
+ """Loads proxy configuration from a file.
152
+
153
+ Tries the specified path, then local config, then global config.
154
+
155
+ Args:
156
+ path: Explicit config file path. If None, searches for local config.
157
+ cwd: Current working directory for local config search.
158
+
159
+ Returns:
160
+ LoadedConfig instance with config and path metadata.
161
+
162
+ Raises:
163
+ FileNotFoundError: If no config file can be found.
164
+ """
165
+ resolved_path = path or default_config_path(cwd)
166
+ if not resolved_path.exists():
167
+ global_path = global_config_path()
168
+ if global_path.exists():
169
+ resolved_path = global_path
170
+ if not resolved_path.exists():
171
+ raise FileNotFoundError(f"Config file not found: {resolved_path}")
172
+ content = resolved_path.read_text(encoding="utf-8").strip()
173
+ data: dict[str, Any]
174
+ if not content:
175
+ data = {}
176
+ elif resolved_path.suffix.lower() == ".json":
177
+ data = _load_json(content)
178
+ else:
179
+ data = _load_yaml(content)
180
+ return LoadedConfig(path=resolved_path, config=ProxyConfig.model_validate(data))
181
+
182
+
183
+ def write_default_config(
184
+ path: Path,
185
+ overwrite: bool = False,
186
+ header_comment: list[str] | None = None,
187
+ ) -> Path:
188
+ """Writes a default proxy configuration file.
189
+
190
+ Args:
191
+ path: File path to write the config to.
192
+ overwrite: If True, overwrites existing file.
193
+ header_comment: Optional list of comment lines to include in header.
194
+
195
+ Returns:
196
+ The path where the config was written.
197
+
198
+ Raises:
199
+ FileExistsError: If file exists and overwrite is False.
200
+ """
201
+ if path.exists() and not overwrite:
202
+ raise FileExistsError(f"Config file already exists: {path}")
203
+ path.parent.mkdir(parents=True, exist_ok=True)
204
+ config = ProxyConfig(
205
+ crm_url="https://yourorg.crm.dynamics.com/",
206
+ expected_path="/webresources/{PCF_NAME}/",
207
+ proxy=ProxyEndpoint(host="127.0.0.1", port=8080),
208
+ http_server=HttpServerConfig(host="127.0.0.1", port=8082),
209
+ bundle=BundleConfig(dist_path="out/controls/{PCF_NAME}"),
210
+ browser=BrowserConfig(prefer="chrome"),
211
+ mitmproxy=MitmproxyConfig(path=None),
212
+ open_browser=True,
213
+ auto_install=True,
214
+ )
215
+ schema_url = (
216
+ "https://raw.githubusercontent.com/vectorfy-co/pcf-toolkit/refs/heads/main/schemas/pcf-proxy.schema.json"
217
+ )
218
+ payload = config.model_dump()
219
+ if path.suffix.lower() == ".json":
220
+ if "$schema" not in payload:
221
+ payload = {"$schema": schema_url, **payload}
222
+ text = _dump_json(payload)
223
+ else:
224
+ text = _dump_yaml(payload)
225
+ header_lines = [f"# yaml-language-server: $schema={schema_url}"]
226
+ if header_comment:
227
+ header_lines.extend(f"# {line}" for line in header_comment if line)
228
+ text = "\n".join(header_lines) + "\n" + text
229
+ path.write_text(text, encoding="utf-8")
230
+ return path
231
+
232
+
233
+ def render_dist_path(config: ProxyConfig, component: str, root: Path) -> Path:
234
+ """Renders the dist path template with component name.
235
+
236
+ Args:
237
+ config: Proxy configuration containing dist_path template.
238
+ component: PCF component name to substitute.
239
+ root: Project root directory.
240
+
241
+ Returns:
242
+ Resolved Path object for the component's dist directory.
243
+ """
244
+ relative = config.bundle.dist_path.replace("{PCF_NAME}", component)
245
+ return root / relative
246
+
247
+
248
+ def _load_json(content: str) -> dict[str, Any]:
249
+ """Loads JSON content into a dictionary.
250
+
251
+ Args:
252
+ content: JSON string to parse.
253
+
254
+ Returns:
255
+ Parsed dictionary data.
256
+ """
257
+ import json
258
+
259
+ return json.loads(content)
260
+
261
+
262
+ def _load_yaml(content: str) -> dict[str, Any]:
263
+ """Loads YAML content into a dictionary.
264
+
265
+ Args:
266
+ content: YAML string to parse.
267
+
268
+ Returns:
269
+ Parsed dictionary data.
270
+
271
+ Raises:
272
+ ValueError: If content is not a mapping.
273
+ """
274
+ data = yaml.safe_load(content)
275
+ if data is None:
276
+ return {}
277
+ if not isinstance(data, dict):
278
+ raise ValueError("Config file must be a mapping")
279
+ return data
280
+
281
+
282
+ def _dump_json(data: dict[str, Any]) -> str:
283
+ """Serializes dictionary to JSON string.
284
+
285
+ Args:
286
+ data: Dictionary to serialize.
287
+
288
+ Returns:
289
+ Formatted JSON string with newline.
290
+ """
291
+ import json
292
+
293
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"
294
+
295
+
296
+ def _dump_yaml(data: dict[str, Any]) -> str:
297
+ """Serializes dictionary to YAML string.
298
+
299
+ Args:
300
+ data: Dictionary to serialize.
301
+
302
+ Returns:
303
+ Formatted YAML string.
304
+ """
305
+ return yaml.safe_dump(
306
+ data,
307
+ sort_keys=False,
308
+ default_flow_style=False,
309
+ allow_unicode=False,
310
+ )
@@ -0,0 +1,279 @@
1
+ """Doctor checks for the proxy workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from pcf_toolkit.proxy.browser import find_browser_binary
12
+ from pcf_toolkit.proxy.config import ProxyConfig, render_dist_path
13
+ from pcf_toolkit.proxy.mitm import find_mitmproxy
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CheckResult:
18
+ name: str
19
+ status: str
20
+ message: str
21
+ fix: str | None = None
22
+
23
+
24
+ def run_doctor(
25
+ config: ProxyConfig | None,
26
+ config_path: Path | None,
27
+ component: str | None,
28
+ project_root: Path,
29
+ ) -> list[CheckResult]:
30
+ """Runs diagnostic checks for proxy workflow prerequisites.
31
+
32
+ Args:
33
+ config: Proxy configuration (optional).
34
+ config_path: Path to config file (optional).
35
+ component: Component name to validate (optional).
36
+ project_root: Project root directory.
37
+
38
+ Returns:
39
+ List of CheckResult objects describing check outcomes.
40
+ """
41
+ results: list[CheckResult] = []
42
+
43
+ if config_path and not config_path.exists():
44
+ results.append(
45
+ CheckResult(
46
+ name="config",
47
+ status="fail",
48
+ message=f"Config not found at {config_path}",
49
+ fix="Run 'pcf-toolkit proxy init' to create one.",
50
+ )
51
+ )
52
+ elif config_path:
53
+ results.append(
54
+ CheckResult(
55
+ name="config",
56
+ status="ok",
57
+ message=f"Loaded config from {config_path}",
58
+ )
59
+ )
60
+
61
+ if config:
62
+ if not config.crm_url or "yourorg" in config.crm_url:
63
+ results.append(
64
+ CheckResult(
65
+ name="crm_url",
66
+ status="warn",
67
+ message="CRM URL is missing or still a placeholder.",
68
+ fix="Set 'crm_url' in your proxy config.",
69
+ )
70
+ )
71
+ else:
72
+ results.append(
73
+ CheckResult(
74
+ name="crm_url",
75
+ status="ok",
76
+ message="CRM URL configured.",
77
+ )
78
+ )
79
+
80
+ results.extend(_check_ports(config))
81
+ results.extend(_check_mitmproxy(config))
82
+ results.extend(_check_certificates())
83
+ results.extend(_check_browser(config))
84
+
85
+ if component:
86
+ results.extend(_check_dist_path(config, component, project_root))
87
+ else:
88
+ results.append(
89
+ CheckResult(
90
+ name="config",
91
+ status="fail",
92
+ message="Config was not loaded.",
93
+ fix="Run 'pcf-toolkit proxy init' and set required values.",
94
+ )
95
+ )
96
+
97
+ return results
98
+
99
+
100
+ def _check_ports(config: ProxyConfig) -> list[CheckResult]:
101
+ """Checks if proxy and HTTP server ports are available.
102
+
103
+ Args:
104
+ config: Proxy configuration.
105
+
106
+ Returns:
107
+ List of CheckResult objects for port availability.
108
+ """
109
+ results = []
110
+ for label, host, port in (
111
+ ("proxy_port", config.proxy.host, config.proxy.port),
112
+ ("http_port", config.http_server.host, config.http_server.port),
113
+ ):
114
+ if _port_available(host, port):
115
+ results.append(
116
+ CheckResult(
117
+ name=label,
118
+ status="ok",
119
+ message=f"{host}:{port} is available.",
120
+ )
121
+ )
122
+ else:
123
+ results.append(
124
+ CheckResult(
125
+ name=label,
126
+ status="fail",
127
+ message=f"{host}:{port} is already in use.",
128
+ fix="Change the port in config or stop the process using it.",
129
+ )
130
+ )
131
+ return results
132
+
133
+
134
+ def _check_mitmproxy(config: ProxyConfig) -> list[CheckResult]:
135
+ """Checks if mitmproxy is available.
136
+
137
+ Args:
138
+ config: Proxy configuration.
139
+
140
+ Returns:
141
+ List of CheckResult objects for mitmproxy availability.
142
+ """
143
+ binary = find_mitmproxy(config.mitmproxy.path)
144
+ if binary:
145
+ return [
146
+ CheckResult(
147
+ name="mitmproxy",
148
+ status="ok",
149
+ message=f"mitmproxy available at {binary}",
150
+ )
151
+ ]
152
+ return [
153
+ CheckResult(
154
+ name="mitmproxy",
155
+ status="fail",
156
+ message="mitmproxy not found.",
157
+ fix="Install mitmproxy or run 'pcf-toolkit proxy doctor --fix'.",
158
+ )
159
+ ]
160
+
161
+
162
+ def _check_certificates() -> list[CheckResult]:
163
+ """Checks if mitmproxy CA certificate is installed.
164
+
165
+ Returns:
166
+ List of CheckResult objects for certificate status.
167
+ """
168
+ cert_dir = Path.home() / ".mitmproxy"
169
+ cert_file = cert_dir / "mitmproxy-ca-cert.pem"
170
+ if cert_file.exists():
171
+ return [
172
+ CheckResult(
173
+ name="mitmproxy_cert",
174
+ status="ok",
175
+ message=f"mitmproxy CA cert found at {cert_file}",
176
+ )
177
+ ]
178
+ return [
179
+ CheckResult(
180
+ name="mitmproxy_cert",
181
+ status="warn",
182
+ message="mitmproxy CA cert not found.",
183
+ fix=_cert_fix_instructions(cert_dir),
184
+ )
185
+ ]
186
+
187
+
188
+ def _check_browser(config: ProxyConfig) -> list[CheckResult]:
189
+ """Checks if browser is available.
190
+
191
+ Args:
192
+ config: Proxy configuration.
193
+
194
+ Returns:
195
+ List of CheckResult objects for browser availability.
196
+ """
197
+ binary = find_browser_binary(config.browser.prefer, config.browser.path)
198
+ if binary:
199
+ return [
200
+ CheckResult(
201
+ name="browser",
202
+ status="ok",
203
+ message=f"Browser found at {binary}",
204
+ )
205
+ ]
206
+ return [
207
+ CheckResult(
208
+ name="browser",
209
+ status="warn",
210
+ message="Browser not found.",
211
+ fix="Set 'browser.path' in config or install Chrome/Edge.",
212
+ )
213
+ ]
214
+
215
+
216
+ def _check_dist_path(config: ProxyConfig, component: str, project_root: Path) -> list[CheckResult]:
217
+ """Checks if component dist path exists.
218
+
219
+ Args:
220
+ config: Proxy configuration.
221
+ component: Component name.
222
+ project_root: Project root directory.
223
+
224
+ Returns:
225
+ List of CheckResult objects for dist path status.
226
+ """
227
+ dist_path = render_dist_path(config, component, project_root)
228
+ if dist_path.exists():
229
+ return [
230
+ CheckResult(
231
+ name="dist_path",
232
+ status="ok",
233
+ message=f"Dist path exists at {dist_path}",
234
+ )
235
+ ]
236
+ return [
237
+ CheckResult(
238
+ name="dist_path",
239
+ status="warn",
240
+ message=f"Dist path missing: {dist_path}",
241
+ fix="Run 'npm run build' or adjust 'bundle.dist_path' in config.",
242
+ )
243
+ ]
244
+
245
+
246
+ def _port_available(host: str, port: int) -> bool:
247
+ """Checks if a port is available for binding.
248
+
249
+ Args:
250
+ host: Hostname to bind to.
251
+ port: Port number to check.
252
+
253
+ Returns:
254
+ True if port is available, False otherwise.
255
+ """
256
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
257
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
258
+ try:
259
+ sock.bind((host, port))
260
+ return True
261
+ except OSError:
262
+ return False
263
+
264
+
265
+ def _cert_fix_instructions(cert_dir: Path) -> str:
266
+ """Generates platform-specific certificate installation instructions.
267
+
268
+ Args:
269
+ cert_dir: Directory containing mitmproxy certificate.
270
+
271
+ Returns:
272
+ Command string for installing the certificate.
273
+ """
274
+ cert_path = cert_dir / "mitmproxy-ca-cert.pem"
275
+ if os.name == "nt":
276
+ return f"Run: certutil -addstore -f Root {cert_path} (elevated)"
277
+ if sys.platform == "darwin":
278
+ return f"Run: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {cert_path}"
279
+ return f"Run: sudo cp {cert_path} /usr/local/share/ca-certificates/ && sudo update-ca-certificates"