@salesforce/afv-skills 1.53.0 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/skills/dx-org-analyze/README.md +310 -0
  3. package/skills/dx-org-analyze/SKILL.md +261 -0
  4. package/skills/dx-org-analyze/references/collection-details.md +88 -0
  5. package/skills/dx-org-analyze/references/report-format.md +67 -0
  6. package/skills/dx-org-analyze/scripts/collect_org_data.py +823 -0
  7. package/skills/dx-org-analyze/scripts/compute_diff.py +1103 -0
  8. package/skills/dx-org-analyze/scripts/introspect_org.py +436 -0
  9. package/skills/dx-org-analyze/tests/README.md +46 -0
  10. package/skills/dx-org-analyze/tests/fixtures/org_a.json +76 -0
  11. package/skills/dx-org-analyze/tests/fixtures/org_b.json +70 -0
  12. package/skills/dx-org-analyze/tests/test_compute_diff.sh +284 -0
  13. package/skills/dx-org-analyze/tests/test_consistency.sh +428 -0
  14. package/skills/experience-design-validate/SKILL.md +163 -0
  15. package/skills/experience-design-validate/references/ai.md +54 -0
  16. package/skills/experience-design-validate/references/components.md +61 -0
  17. package/skills/experience-design-validate/references/craft.md +158 -0
  18. package/skills/experience-design-validate/references/data.md +59 -0
  19. package/skills/experience-design-validate/references/forms-flows.md +57 -0
  20. package/skills/experience-design-validate/references/interaction.md +53 -0
  21. package/skills/experience-design-validate/references/navigation.md +51 -0
  22. package/skills/experience-design-validate/references/performance.md +56 -0
  23. package/skills/experience-design-validate/references/records.md +60 -0
  24. package/skills/experience-design-validate/references/responsive.md +48 -0
  25. package/skills/experience-design-validate/references/scoring-rubric.md +257 -0
  26. package/skills/experience-design-validate/references/state.md +53 -0
  27. package/skills/experience-design-validate/references/trust.md +47 -0
  28. package/skills/experience-design-validate/references/usability.md +35 -0
  29. package/skills/experience-design-validate/references/visual-system.md +183 -0
  30. package/skills/service-agentforce-contact-center-coordinate/SKILL.md +170 -0
  31. package/skills/service-agentforce-contact-center-coordinate/assets/escalation-flow.flow-meta.xml +72 -0
  32. package/skills/service-agentforce-contact-center-coordinate/assets/omni-flow.flow-meta.xml +85 -0
  33. package/skills/service-agentforce-contact-center-coordinate/assets/report-template.md +52 -0
  34. package/skills/service-agentforce-contact-center-coordinate/references/agentforce-prerequisite.md +31 -0
  35. package/skills/service-agentforce-contact-center-coordinate/references/messaging_channel.md +76 -0
  36. package/skills/service-agentforce-contact-center-coordinate/references/number_management_api.md +78 -0
  37. package/skills/service-agentforce-contact-center-coordinate/references/omni-flow-routing.md +74 -0
  38. package/skills/service-agentforce-contact-center-coordinate/references/setup_summary.md +36 -0
  39. package/skills/service-agentforce-contact-center-coordinate/references/verification_and_errors.md +42 -0
  40. package/skills/service-agentforce-contact-center-coordinate/scripts/check-agentforce-prereq.sh +54 -0
  41. package/skills/service-agentforce-contact-center-coordinate/scripts/create-routing-flows.sh +49 -0
  42. package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-agent.sh +73 -0
  43. package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-channel.sh +66 -0
  44. package/skills/service-agentforce-contact-center-coordinate/scripts/fetch-numbers.sh +21 -0
  45. package/skills/service-agentforce-contact-center-coordinate/scripts/lib.sh +46 -0
  46. package/skills/service-agentforce-contact-center-coordinate/scripts/prepare-agent-workdir.sh +21 -0
  47. package/skills/service-agentforce-contact-center-coordinate/scripts/procure-number.sh +24 -0
  48. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-acc-queue.sh +27 -0
  49. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-channel-line.sh +37 -0
  50. package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-flow-definition.sh +33 -0
  51. package/skills/service-agentforce-contact-center-coordinate/scripts/verify-number-live.sh +63 -0
@@ -0,0 +1,823 @@
1
+ #!/usr/bin/env python3
2
+ """Collect metadata, permissions, and org values from a single Salesforce org.
3
+
4
+ Outputs a JSON file that can be fed into compute_diff.py.
5
+
6
+ Data access hierarchy (strict priority order):
7
+ 1. SF CLI commands (sf org list metadata-types, sf data query, sf limits api display)
8
+ 2. Direct REST/Tooling API calls (last resort, only when SF CLI has no equivalent)
9
+
10
+ Authentication:
11
+ All auth goes through SF CLI. The script obtains access tokens exclusively
12
+ via `sf org display --json`. Never accepts raw credentials or tokens.
13
+
14
+ Usage:
15
+ collect_org_data.py --org-alias ALIAS --output DIR
16
+ collect_org_data.py --org-alias ALIAS --output DIR --skip-deep-data
17
+
18
+ Exit codes:
19
+ 0 = success
20
+ 1 = fatal error (bad args, network failure, sf CLI not available)
21
+ 2 = session expired (caller should re-authenticate and retry)
22
+ """
23
+
24
+ import argparse
25
+ import hashlib
26
+ import json
27
+ import subprocess
28
+ import sys
29
+ import urllib.parse
30
+ import urllib.request
31
+ from concurrent.futures import ThreadPoolExecutor, as_completed
32
+ from datetime import datetime, timezone
33
+
34
+ API_VERSION = "62.0"
35
+
36
+
37
+ def log(msg):
38
+ print(msg, file=sys.stderr)
39
+
40
+
41
+ def run_sf_command(args_list):
42
+ """Run an sf CLI command and return parsed JSON output."""
43
+ cmd = ["sf"] + args_list + ["--json"]
44
+ try:
45
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
46
+ except FileNotFoundError:
47
+ log("Error: sf CLI not found. Install via: npm install -g @salesforce/cli")
48
+ sys.exit(1)
49
+ except subprocess.TimeoutExpired:
50
+ log(f"Error: Command timed out: {' '.join(cmd)}")
51
+ return None
52
+
53
+ if result.returncode != 0:
54
+ try:
55
+ err = json.loads(result.stdout)
56
+ msg = err.get("message", result.stderr[:200])
57
+ except (json.JSONDecodeError, ValueError):
58
+ msg = result.stderr[:200] or result.stdout[:200]
59
+ session_markers = ["expired", "invalid_session_id", "invalid_grant",
60
+ "session expired", "expired access", "expired refresh"]
61
+ if any(m in msg.lower() for m in session_markers):
62
+ log(f"Session expired: {msg}")
63
+ sys.exit(2)
64
+ log(f" sf command failed: {msg}")
65
+ return None
66
+
67
+ try:
68
+ return json.loads(result.stdout)
69
+ except json.JSONDecodeError:
70
+ log(f" Could not parse sf output as JSON")
71
+ return None
72
+
73
+
74
+ def rest_request(base_url, access_token, path):
75
+ """Make a REST API request using the access token."""
76
+ url = f"{base_url}{path}"
77
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=30) as resp:
80
+ return json.loads(resp.read())
81
+ except urllib.error.HTTPError as e:
82
+ body = e.read().decode("utf-8", errors="replace")
83
+ if "INVALID_SESSION_ID" in body or "SESSION_EXPIRED" in body:
84
+ log("Session expired.")
85
+ sys.exit(2)
86
+ return {"error": f"HTTP {e.code}: {body[:200]}"}
87
+ except Exception as e:
88
+ return {"error": str(e)}
89
+
90
+
91
+ def _query_all_pages(base_url, access_token, initial_path):
92
+ """Follow nextRecordsUrl to collect all records (REST API returns max 2000 per page)."""
93
+ all_records = []
94
+ path = initial_path
95
+ while True:
96
+ r = rest_request(base_url, access_token, path)
97
+ if not isinstance(r, dict) or "error" in r:
98
+ if not all_records:
99
+ return r
100
+ log(f" Warning: pagination interrupted after {len(all_records)} records")
101
+ return {"records": all_records, "totalSize": len(all_records), "done": False}
102
+ all_records.extend(r.get("records", []))
103
+ if r.get("done", True) or not r.get("nextRecordsUrl"):
104
+ break
105
+ path = r["nextRecordsUrl"]
106
+ return {"records": all_records, "totalSize": len(all_records), "done": True}
107
+
108
+
109
+ def tooling_query(base_url, access_token, soql):
110
+ """Run a Tooling API SOQL query with automatic pagination."""
111
+ path = f"/services/data/v{API_VERSION}/tooling/query/?q={urllib.parse.quote(soql)}"
112
+ return _query_all_pages(base_url, access_token, path)
113
+
114
+
115
+ def rest_query(base_url, access_token, soql):
116
+ """Run a standard REST API SOQL query with automatic pagination."""
117
+ path = f"/services/data/v{API_VERSION}/query/?q={urllib.parse.quote(soql)}"
118
+ return _query_all_pages(base_url, access_token, path)
119
+
120
+
121
+ # ── Step: Get credentials from SF CLI ────────────────────────────────
122
+
123
+ def get_credentials(org_alias):
124
+ """Get base URL and access token from sf CLI."""
125
+ log(f"Getting credentials for org: {org_alias}")
126
+ result = run_sf_command(["org", "display", "--target-org", org_alias])
127
+ if not result:
128
+ log(f"Error: Could not get credentials for {org_alias}")
129
+ sys.exit(1)
130
+
131
+ data = result.get("result", {})
132
+ base_url = data.get("instanceUrl", "").rstrip("/")
133
+ access_token = data.get("accessToken", "")
134
+
135
+ if not base_url or not access_token:
136
+ log(f"Error: Missing instanceUrl or accessToken for {org_alias}")
137
+ sys.exit(1)
138
+
139
+ log(f" Instance: {base_url}")
140
+ return base_url, access_token
141
+
142
+
143
+ # ── Step: Discover metadata types via SF CLI ─────────────────────────
144
+
145
+ def discover_metadata_types_cli(org_alias):
146
+ """Discover metadata types using sf org list metadata-types."""
147
+ log("Discovering metadata types via sf CLI ...")
148
+ result = run_sf_command(["org", "list", "metadata-types", "--target-org", org_alias,
149
+ "--api-version", API_VERSION])
150
+ if not result:
151
+ log(" sf org list metadata-types failed, will use Tooling API fallback.")
152
+ return None
153
+
154
+ metadata_objects = result.get("result", {}).get("metadataObjects", [])
155
+ types = sorted(set(obj.get("xmlName", "") for obj in metadata_objects if obj.get("xmlName")))
156
+ log(f" Discovered {len(types)} metadata types.")
157
+ return types
158
+
159
+
160
+ # ── Step: Collect metadata components via SF CLI ─────────────────────
161
+
162
+ def collect_metadata_cli(org_alias, all_types):
163
+ """Collect metadata components using sf org list metadata per type (parallelized)."""
164
+ log(f"Collecting metadata via sf CLI ({len(all_types)} types) ...")
165
+ results = {}
166
+ errors = []
167
+
168
+ def fetch_type(mtype):
169
+ r = run_sf_command(["org", "list", "metadata", "--target-org", org_alias,
170
+ "--metadata-type", mtype, "--api-version", API_VERSION])
171
+ return mtype, r
172
+
173
+ completed = 0
174
+ with ThreadPoolExecutor(max_workers=12) as pool:
175
+ futures = {pool.submit(fetch_type, t): t for t in all_types}
176
+ for future in as_completed(futures):
177
+ mtype, r = future.result()
178
+ completed += 1
179
+
180
+ if r is None:
181
+ errors.append(f"list metadata failed for {mtype}")
182
+ continue
183
+
184
+ components = r.get("result", [])
185
+ if components:
186
+ entries = []
187
+ for c in components:
188
+ full_name = urllib.parse.unquote(c.get("fullName", ""))
189
+ namespace = c.get("namespacePrefix") or None
190
+ if full_name:
191
+ entries.append({"fullName": full_name, "namespace": namespace})
192
+ if entries:
193
+ results[mtype] = entries
194
+
195
+ if completed % 30 == 0:
196
+ log(f" ... {completed}/{len(all_types)} types")
197
+
198
+ log(f" {len(results)} types with components, {len(errors)} errors.")
199
+ return results, errors
200
+
201
+
202
+ # ── Step: Collect metadata via Tooling API (fallback) ────────────────
203
+
204
+ def collect_metadata_tooling(base_url, access_token):
205
+ """Fallback: collect metadata via Tooling API SOQL queries."""
206
+ log("Collecting metadata via Tooling API fallback ...")
207
+ queries = {
208
+ "ApexClass": "SELECT Name, ApiVersion, LengthWithoutComments, Status FROM ApexClass WHERE NamespacePrefix = null",
209
+ "ApexTrigger": "SELECT Name, ApiVersion, Status FROM ApexTrigger WHERE NamespacePrefix = null",
210
+ "ApexPage": "SELECT Name, ApiVersion FROM ApexPage WHERE NamespacePrefix = null",
211
+ "ApexComponent": "SELECT Name, ApiVersion FROM ApexComponent WHERE NamespacePrefix = null",
212
+ "CustomObject": "SELECT DeveloperName FROM CustomObject WHERE NamespacePrefix = null",
213
+ "CustomTab": "SELECT DeveloperName FROM CustomTab WHERE NamespacePrefix = null",
214
+ "CustomApplication": "SELECT DeveloperName, Label FROM CustomApplication WHERE NamespacePrefix = null",
215
+ "CustomLabel": "SELECT Name, Value FROM ExternalString WHERE NamespacePrefix = null",
216
+ "LightningComponentBundle": "SELECT DeveloperName, ApiVersion FROM LightningComponentBundle WHERE NamespacePrefix = null",
217
+ "AuraDefinitionBundle": "SELECT DeveloperName, ApiVersion FROM AuraDefinitionBundle WHERE NamespacePrefix = null",
218
+ "Flow": "SELECT MasterLabel, ProcessType, Status FROM Flow",
219
+ "ValidationRule": "SELECT ValidationName FROM ValidationRule WHERE NamespacePrefix = null",
220
+ "Layout": "SELECT Name FROM Layout WHERE NamespacePrefix = null",
221
+ "Profile": "SELECT Name FROM Profile",
222
+ "PermissionSet": "SELECT Name FROM PermissionSet WHERE IsOwnedByProfile = false AND NamespacePrefix = null",
223
+ "ConnectedApplication": "SELECT DeveloperName FROM ConnectedApplication",
224
+ "StaticResource": "SELECT Name FROM StaticResource WHERE NamespacePrefix = null",
225
+ "FlexiPage": "SELECT DeveloperName FROM FlexiPage WHERE NamespacePrefix = null",
226
+ "QuickAction": "SELECT DeveloperName FROM QuickActionDefinition WHERE NamespacePrefix = null",
227
+ "CustomPermission": "SELECT DeveloperName FROM CustomPermission",
228
+ "PermissionSetGroup": "SELECT DeveloperName FROM PermissionSetGroup WHERE NamespacePrefix = null",
229
+ "NamedCredential": "SELECT DeveloperName FROM NamedCredential WHERE NamespacePrefix = null",
230
+ "DuplicateRule": "SELECT DeveloperName FROM DuplicateRule WHERE NamespacePrefix = null",
231
+ "MatchingRule": "SELECT DeveloperName FROM MatchingRule WHERE NamespacePrefix = null",
232
+ "EmailTemplate": "SELECT DeveloperName FROM EmailTemplate WHERE NamespacePrefix = null",
233
+ "Report": "SELECT DeveloperName FROM Report WHERE NamespacePrefix = null",
234
+ "Dashboard": "SELECT DeveloperName FROM Dashboard WHERE NamespacePrefix = null",
235
+ }
236
+
237
+ results = {}
238
+ errors = []
239
+ for label, soql in queries.items():
240
+ r = tooling_query(base_url, access_token, soql)
241
+ if not isinstance(r, dict):
242
+ errors.append(f"Tooling query failed for {label}: unexpected response")
243
+ continue
244
+ if "error" in r:
245
+ errors.append(f"Tooling query failed for {label}: {str(r['error'])[:80]}")
246
+ continue
247
+ records = r.get("records", [])
248
+ names = []
249
+ for rec in records:
250
+ name = (rec.get("Name") or rec.get("DeveloperName") or
251
+ rec.get("MasterLabel") or rec.get("ValidationName") or "")
252
+ if name:
253
+ names.append({"fullName": name, "namespace": None})
254
+ if names:
255
+ results[label] = names
256
+
257
+ log(f" {len(results)} types with components, {len(errors)} errors.")
258
+ return results, errors
259
+
260
+
261
+ # ── Step: Collect org settings ────────────────────────────────────────
262
+
263
+ def sf_data_query(org_alias, soql, use_tooling=False):
264
+ """Run a SOQL query via SF CLI (sf data query). Preferred over direct API.
265
+
266
+ Returns the result dict with 'records'. If the query is truncated (done=false),
267
+ returns None so callers fall through to the paginating REST query.
268
+ """
269
+ args = ["data", "query", "--query", soql, "--target-org", org_alias,
270
+ "--result-format", "json"]
271
+ if use_tooling:
272
+ args.append("--use-tooling-api")
273
+ result = run_sf_command(args)
274
+ if result and "result" in result:
275
+ r = result["result"]
276
+ if isinstance(r, dict) and r.get("done") is False:
277
+ return None
278
+ return r
279
+ return result
280
+
281
+
282
+ def collect_org_settings(org_alias, base_url, access_token):
283
+ """Collect org settings. Prefers SF CLI, falls back to direct API.
284
+
285
+ Tries multiple sources in order:
286
+ 1. SF CLI: sf data query --use-tooling-api (OrganizationSettingsDetail)
287
+ 2. SF CLI: sf data query --use-tooling-api (SecurityHealthCheckRisks)
288
+ 3. Direct Tooling API (last resort if SF CLI query fails)
289
+ """
290
+ log("Collecting org settings via SF CLI ...")
291
+ permissions = {}
292
+
293
+ # Try OrganizationSettingsDetail via SF CLI first
294
+ soql = "SELECT DurableId, SettingName, IsEnabled FROM OrganizationSettingsDetail"
295
+ r = sf_data_query(org_alias, soql, use_tooling=True)
296
+
297
+ if isinstance(r, dict) and "records" in r:
298
+ for rec in r.get("records", []):
299
+ name = rec.get("SettingName", "")
300
+ enabled = rec.get("IsEnabled", False)
301
+ if name:
302
+ permissions[name] = {"value": str(enabled).lower()}
303
+ log(f" {len(permissions)} org settings collected via SF CLI (OrganizationSettingsDetail).")
304
+ return permissions
305
+
306
+ log(" SF CLI OrganizationSettingsDetail query failed, trying SecurityHealthCheckRisks ...")
307
+
308
+ # Fallback: SecurityHealthCheckRisks via SF CLI
309
+ soql_shcr = ("SELECT DurableId, Setting, SettingGroup, OrgValue, StandardValue "
310
+ "FROM SecurityHealthCheckRisks")
311
+ r2 = sf_data_query(org_alias, soql_shcr, use_tooling=True)
312
+
313
+ if isinstance(r2, dict) and "records" in r2:
314
+ for rec in r2.get("records", []):
315
+ durable_id = rec.get("DurableId", "")
316
+ org_value = rec.get("OrgValue", "")
317
+ if durable_id:
318
+ permissions[durable_id] = {"value": org_value}
319
+ log(f" {len(permissions)} org settings collected via SF CLI (SecurityHealthCheckRisks).")
320
+ return permissions
321
+
322
+ # Last resort: direct Tooling API
323
+ log(" SF CLI queries failed, falling back to direct Tooling API ...")
324
+ soql = "SELECT DurableId, SettingName, IsEnabled FROM OrganizationSettingsDetail"
325
+ r3 = tooling_query(base_url, access_token, soql)
326
+
327
+ if isinstance(r3, dict) and "records" in r3:
328
+ for rec in r3.get("records", []):
329
+ name = rec.get("SettingName", "")
330
+ enabled = rec.get("IsEnabled", False)
331
+ if name:
332
+ permissions[name] = {"value": str(enabled).lower()}
333
+ log(f" {len(permissions)} org settings collected via direct Tooling API (fallback).")
334
+ return permissions
335
+
336
+ log(" All org settings sources unavailable. Skipping.")
337
+ return permissions
338
+
339
+
340
+ # ── Step: Collect org limits ──────────────────────────────────────────
341
+
342
+ def collect_org_limits(org_alias, base_url, access_token):
343
+ """Collect org limits. Prefers SF CLI, falls back to direct REST API."""
344
+ log("Collecting org limits via SF CLI ...")
345
+ org_values = {}
346
+
347
+ # Prefer SF CLI: sf limits api display
348
+ result = run_sf_command(["limits", "api", "display", "--target-org", org_alias])
349
+ if result and "result" in result:
350
+ for limit in result["result"]:
351
+ name = limit.get("name", "")
352
+ max_val = limit.get("max", 0)
353
+ if name:
354
+ org_values[name] = str(max_val)
355
+ if org_values:
356
+ log(f" {len(org_values)} org limits collected via SF CLI.")
357
+ return org_values
358
+
359
+ # Fallback: direct REST /limits/ endpoint
360
+ log(" SF CLI limits command failed, falling back to REST /limits/ ...")
361
+ path = f"/services/data/v{API_VERSION}/limits/"
362
+ r = rest_request(base_url, access_token, path)
363
+
364
+ if isinstance(r, dict) and "error" not in r:
365
+ for limit_name, limit_data in r.items():
366
+ if isinstance(limit_data, dict):
367
+ max_val = limit_data.get("Max", 0)
368
+ org_values[limit_name] = str(max_val)
369
+ else:
370
+ org_values[limit_name] = str(limit_data)
371
+ log(f" {len(org_values)} org limits collected via REST API (fallback).")
372
+ else:
373
+ err = r.get("error", "unknown") if isinstance(r, dict) else str(r)[:100]
374
+ log(f" REST /limits/ also unavailable: {err}")
375
+
376
+ return org_values
377
+
378
+
379
+ # ── Step: Org identity ───────────────────────────────────────────────
380
+
381
+ def query_org_identity(org_alias, base_url, access_token):
382
+ """Query org identity. Prefers SF CLI, falls back to direct API."""
383
+ soql = ("SELECT Id, Name, OrganizationType, IsSandbox, TrialExpirationDate, "
384
+ "LanguageLocaleKey, DefaultLocaleSidKey, TimeZoneSidKey FROM Organization")
385
+
386
+ # Prefer SF CLI
387
+ r = sf_data_query(org_alias, soql)
388
+ if isinstance(r, dict) and "records" in r and r["records"]:
389
+ return r["records"][0]
390
+
391
+ # Fallback: direct REST API
392
+ r = rest_query(base_url, access_token, soql)
393
+ if isinstance(r, dict) and "records" in r and r["records"]:
394
+ return r["records"][0]
395
+
396
+ return {}
397
+
398
+
399
+ # ── Step: Installed packages ─────────────────────────────────────────
400
+
401
+ def collect_installed_packages(org_alias, base_url, access_token):
402
+ """Collect installed packages. Prefers SF CLI, falls back to direct Tooling API."""
403
+ log("Collecting installed packages via SF CLI ...")
404
+ soql = ("SELECT SubscriberPackage.Name, SubscriberPackage.NamespacePrefix, "
405
+ "SubscriberPackageVersion.MajorVersion, SubscriberPackageVersion.MinorVersion "
406
+ "FROM InstalledSubscriberPackage")
407
+
408
+ # Prefer SF CLI with Tooling API
409
+ r = sf_data_query(org_alias, soql, use_tooling=True)
410
+
411
+ # Fallback: direct Tooling API
412
+ if not isinstance(r, dict) or "records" not in r:
413
+ log(" SF CLI package query failed, falling back to direct Tooling API ...")
414
+ r = tooling_query(base_url, access_token, soql)
415
+
416
+ if not isinstance(r, dict) or "error" in r:
417
+ err = r.get("error", "unknown") if isinstance(r, dict) else str(r)
418
+ log(f" Could not query packages: {err}")
419
+ return []
420
+
421
+ packages = []
422
+ for rec in r.get("records", []):
423
+ sp = rec.get("SubscriberPackage") or {}
424
+ sv = rec.get("SubscriberPackageVersion") or {}
425
+ packages.append({
426
+ "name": sp.get("Name", ""),
427
+ "namespace": sp.get("NamespacePrefix", ""),
428
+ "version": f"{sv.get('MajorVersion', '?')}.{sv.get('MinorVersion', '?')}",
429
+ })
430
+
431
+ log(f" {len(packages)} installed packages.")
432
+ return packages
433
+
434
+
435
+ def collect_installed_components(org_alias, base_url, access_token):
436
+ """Identify components installed by packages via ManageableState.
437
+
438
+ Non-namespaced (2GP unlocked) packages have no namespace prefix, so
439
+ their components are invisible to the namespace-based grouping in the
440
+ diff report. Querying ManageableState is the only reliable signal.
441
+ """
442
+ log("Collecting installed components (ManageableState) ...")
443
+ ns_filter = "AND NamespacePrefix = null"
444
+ queries = {
445
+ "CustomObject": f"SELECT DeveloperName, ManageableState FROM CustomObject WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
446
+ "CustomField": f"SELECT DeveloperName, EntityDefinition.DeveloperName, ManageableState FROM CustomField WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
447
+ "ApexClass": f"SELECT Name, ManageableState FROM ApexClass WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
448
+ "ApexTrigger": f"SELECT Name, ManageableState FROM ApexTrigger WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
449
+ "ApexPage": f"SELECT Name, ManageableState FROM ApexPage WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
450
+ "ApexComponent": f"SELECT Name, ManageableState FROM ApexComponent WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
451
+ "Flow": f"SELECT MasterLabel, ManageableState FROM Flow WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
452
+ "Layout": f"SELECT Name, ManageableState FROM Layout WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
453
+ "ValidationRule": f"SELECT ValidationName, ManageableState FROM ValidationRule WHERE ManageableState IN ('installed', 'installedEditable') {ns_filter}",
454
+ }
455
+
456
+ installed = {}
457
+ for mtype, soql in queries.items():
458
+ r = sf_data_query(org_alias, soql, use_tooling=True)
459
+ if not isinstance(r, dict) or "records" not in r:
460
+ r = tooling_query(base_url, access_token, soql)
461
+ if not isinstance(r, dict) or "error" in r or "records" not in r:
462
+ continue
463
+ items = []
464
+ for rec in r.get("records", []):
465
+ name = (rec.get("Name") or rec.get("DeveloperName")
466
+ or rec.get("MasterLabel") or rec.get("ValidationName") or "")
467
+ if not name:
468
+ continue
469
+ entity = (rec.get("EntityDefinition") or {}).get("DeveloperName")
470
+ entry = {"name": name, "manageableState": rec.get("ManageableState", "")}
471
+ if entity:
472
+ entry["object"] = entity
473
+ items.append(entry)
474
+ if items:
475
+ installed[mtype] = items
476
+ log(f" {mtype}: {len(items)} installed components")
477
+
478
+ total = sum(len(v) for v in installed.values())
479
+ log(f" {total} total installed components.")
480
+ return installed
481
+
482
+
483
+ # ── Step: Licenses ───────────────────────────────────────────────────
484
+
485
+ def collect_licenses(org_alias, base_url, access_token):
486
+ """Collect licenses. Prefers SF CLI, falls back to direct API."""
487
+ log("Collecting licenses via SF CLI ...")
488
+ licenses = {"user": [], "permission_set": [], "package": []}
489
+
490
+ queries = {
491
+ "user": "SELECT Name, TotalLicenses, UsedLicenses, Status FROM UserLicense",
492
+ "permission_set": "SELECT DeveloperName, TotalLicenses, UsedLicenses FROM PermissionSetLicense",
493
+ "package": "SELECT NamespacePrefix, AllowedLicenses, UsedLicenses, Status FROM PackageLicense",
494
+ }
495
+
496
+ for key, soql in queries.items():
497
+ # Prefer SF CLI
498
+ r = sf_data_query(org_alias, soql)
499
+ # Fallback: direct REST API (these are standard objects, not Tooling)
500
+ if not isinstance(r, dict) or "records" not in r:
501
+ r = rest_query(base_url, access_token, soql)
502
+ if isinstance(r, dict) and "records" in r:
503
+ for rec in r["records"]:
504
+ rec.pop("attributes", None)
505
+ licenses[key].append(rec)
506
+
507
+ log(f" {len(licenses['user'])} user, {len(licenses['permission_set'])} permission set, {len(licenses['package'])} package licenses.")
508
+ return licenses
509
+
510
+
511
+ # ── Step: System permissions (PermissionsXxx on PermissionSet) ────────
512
+
513
+ def collect_system_permissions(org_alias, base_url, access_token):
514
+ """Collect system permissions (PermissionsXxx fields) from PermissionSet.
515
+
516
+ Steps:
517
+ 1. Describe PermissionSet to discover all Permissions* boolean fields
518
+ 2. Query PermissionSet records with those fields
519
+ """
520
+ log("Collecting system permissions ...")
521
+
522
+ # Step 1: Discover PermissionsXxx fields via describe
523
+ perm_fields = _discover_permission_fields(org_alias, base_url, access_token)
524
+ if not perm_fields:
525
+ log(" Could not discover PermissionsXxx fields. Skipping system permissions.")
526
+ return {"fields_discovered": [], "permission_sets": []}
527
+
528
+ log(f" Discovered {len(perm_fields)} PermissionsXxx fields.")
529
+
530
+ # Step 2: Query PermissionSet records with those fields
531
+ # PermissionsXxx names average ~25 chars; 150 fields ≈ 3750 chars, well under SOQL limits
532
+ chunk_size = 150
533
+ base_fields = ["Id", "Name", "Label", "IsOwnedByProfile", "IsCustom"]
534
+ all_records = {}
535
+
536
+ for i in range(0, len(perm_fields), chunk_size):
537
+ chunk = perm_fields[i:i + chunk_size]
538
+ fields_csv = ", ".join(base_fields + chunk) if i == 0 else ", ".join(["Id"] + chunk)
539
+ soql = f"SELECT {fields_csv} FROM PermissionSet WHERE IsCustom = true OR IsOwnedByProfile = true"
540
+
541
+ r = sf_data_query(org_alias, soql, use_tooling=True)
542
+ if not isinstance(r, dict) or "records" not in r:
543
+ r = tooling_query(base_url, access_token, soql)
544
+ if not isinstance(r, dict) or "records" not in r:
545
+ log(f" System permissions query failed for chunk {i // chunk_size + 1}.")
546
+ continue
547
+
548
+ for rec in r.get("records", []):
549
+ rec_id = rec.get("Id", "")
550
+ if rec_id not in all_records:
551
+ all_records[rec_id] = {}
552
+ for field in (base_fields + chunk if i == 0 else ["Id"] + chunk):
553
+ if field in rec:
554
+ all_records[rec_id][field] = rec[field]
555
+
556
+ # Build output — skip auto-generated sets with ID-based names
557
+ permission_sets = []
558
+ for rec_id, rec in all_records.items():
559
+ name = rec.get("Name", "")
560
+ label = rec.get("Label", "")
561
+ display = label or name
562
+ if not display:
563
+ continue
564
+ if display.startswith(("X00e", "00e", "X00E", "00E")):
565
+ continue
566
+ if name.startswith("X00e") or name.startswith("00e"):
567
+ continue
568
+ perms = {}
569
+ for f in perm_fields:
570
+ val = rec.get(f)
571
+ if val is True:
572
+ perms[f] = True
573
+ permission_sets.append({
574
+ "name": name,
575
+ "label": label or name,
576
+ "is_profile": rec.get("IsOwnedByProfile", False),
577
+ "permissions": perms,
578
+ })
579
+
580
+ log(f" {len(permission_sets)} permission sets collected with system permissions.")
581
+ return {"fields_discovered": perm_fields, "permission_sets": permission_sets}
582
+
583
+
584
+ def _discover_permission_fields(org_alias, base_url, access_token):
585
+ """Discover PermissionsXxx fields by describing the PermissionSet object."""
586
+ # Try SF CLI describe first
587
+ try:
588
+ result = subprocess.run(
589
+ ["sf", "sobject", "describe", "--sobject", "PermissionSet",
590
+ "--target-org", org_alias, "--json"],
591
+ capture_output=True, text=True, timeout=60
592
+ )
593
+ if result.returncode == 0:
594
+ data = json.loads(result.stdout)
595
+ fields = data.get("result", {}).get("fields", [])
596
+ return sorted(
597
+ f["name"] for f in fields
598
+ if f["name"].startswith("Permissions") and f.get("type") == "boolean"
599
+ )
600
+ except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError, KeyError):
601
+ pass
602
+
603
+ # Fallback: REST describe
604
+ path = f"/services/data/v{API_VERSION}/sobjects/PermissionSet/describe/"
605
+ r = rest_request(base_url, access_token, path)
606
+ if isinstance(r, dict) and "fields" in r:
607
+ return sorted(
608
+ f["name"] for f in r["fields"]
609
+ if f["name"].startswith("Permissions") and f.get("type") == "boolean"
610
+ )
611
+
612
+ return []
613
+
614
+
615
+ # ── Step: Deep data (Tooling API content-level queries) ──────────────
616
+
617
+ def collect_deep_data(org_alias, base_url, access_token):
618
+ """Collect deep data. Prefers SF CLI, falls back to direct Tooling API."""
619
+ log("Collecting deep data ...")
620
+ deep = {}
621
+ errors = []
622
+
623
+ queries = {
624
+ "apex_classes": (
625
+ "SELECT Name, ApiVersion, LengthWithoutComments, Status, Body FROM ApexClass WHERE NamespacePrefix = null",
626
+ lambda r: {"name": r.get("Name", ""), "apiVersion": r.get("ApiVersion"),
627
+ "loc": r.get("LengthWithoutComments", 0), "status": r.get("Status"),
628
+ "bodyHash": hashlib.sha256(r.get("Body", "").encode()).hexdigest() if r.get("Body") else None}
629
+ ),
630
+ "apex_triggers": (
631
+ "SELECT Name, ApiVersion, Status, Body FROM ApexTrigger WHERE NamespacePrefix = null",
632
+ lambda r: {"name": r.get("Name", ""), "apiVersion": r.get("ApiVersion"),
633
+ "status": r.get("Status"),
634
+ "bodyHash": hashlib.sha256(r.get("Body", "").encode()).hexdigest() if r.get("Body") else None}
635
+ ),
636
+ "custom_fields": (
637
+ "SELECT TableEnumOrId, DeveloperName, Length, Precision, Scale FROM CustomField WHERE NamespacePrefix = null",
638
+ lambda r: {"object": r.get("TableEnumOrId", ""), "name": r.get("DeveloperName", ""),
639
+ "length": r.get("Length"),
640
+ "precision": r.get("Precision"), "scale": r.get("Scale")}
641
+ ),
642
+ "validation_rules": (
643
+ "SELECT ValidationName, EntityDefinition.DeveloperName, Active, ErrorMessage FROM ValidationRule WHERE NamespacePrefix = null",
644
+ lambda r: {"name": r.get("ValidationName", ""),
645
+ "object": (r.get("EntityDefinition") or {}).get("DeveloperName", ""),
646
+ "active": r.get("Active", False),
647
+ "errorMessage": r.get("ErrorMessage", "")}
648
+ ),
649
+ "flows": (
650
+ "SELECT MasterLabel, ApiVersion, ProcessType, Status FROM Flow",
651
+ lambda r: {"name": r.get("MasterLabel", ""),
652
+ "apiVersion": r.get("ApiVersion"),
653
+ "processType": r.get("ProcessType", ""),
654
+ "status": r.get("Status", "")}
655
+ ),
656
+ "named_credentials": (
657
+ "SELECT DeveloperName, Endpoint FROM NamedCredential WHERE NamespacePrefix = null",
658
+ lambda r: {"name": r.get("DeveloperName", ""), "endpoint": r.get("Endpoint", "")}
659
+ ),
660
+ "connected_apps": (
661
+ "SELECT DeveloperName FROM ConnectedApplication",
662
+ lambda r: {"name": r.get("DeveloperName", "")}
663
+ ),
664
+ "custom_metadata_records": (
665
+ "SELECT DeveloperName, NamespacePrefix FROM CustomObject WHERE DeveloperName LIKE '%mdt'",
666
+ lambda r: {"name": r.get("DeveloperName", ""), "namespace": r.get("NamespacePrefix", "")}
667
+ ),
668
+ }
669
+
670
+ def fetch_deep(key, soql, transform):
671
+ r = sf_data_query(org_alias, soql, use_tooling=True)
672
+ if not isinstance(r, dict) or "records" not in r:
673
+ r = tooling_query(base_url, access_token, soql)
674
+ if not isinstance(r, dict) or "error" in r:
675
+ err_msg = r.get("error", "unknown") if isinstance(r, dict) else str(r)[:80]
676
+ return key, [], f"Deep data query failed for {key}: {err_msg}"
677
+ records = r.get("records", [])
678
+ try:
679
+ data = [transform(rec) for rec in records]
680
+ except Exception as e:
681
+ return key, [], f"Deep data transform failed for {key}: {e}"
682
+ return key, data, None
683
+
684
+ with ThreadPoolExecutor(max_workers=8) as pool:
685
+ futures = {pool.submit(fetch_deep, k, soql, tr): k for k, (soql, tr) in queries.items()}
686
+ for future in as_completed(futures):
687
+ key, data, err = future.result()
688
+ if err:
689
+ errors.append(err)
690
+ deep[key] = []
691
+ else:
692
+ deep[key] = data
693
+ if data:
694
+ log(f" {key}: {len(data)} records")
695
+
696
+ log(f" Deep data: {sum(len(v) for v in deep.values())} total records, {len(errors)} errors")
697
+ return deep, errors
698
+
699
+
700
+ # ── Main ─────────────────────────────────────────────────────────────
701
+
702
+ def main():
703
+ parser = argparse.ArgumentParser(description="Collect Salesforce org data for comparison.")
704
+ parser.add_argument("--org-alias", required=True,
705
+ help="SF CLI alias or username for the target org")
706
+ parser.add_argument("--output", required=True, help="Output directory (split category files + combined.json)")
707
+ parser.add_argument("--skip-deep-data", action="store_true",
708
+ help="Skip Tooling API body queries (faster, less detail)")
709
+ args = parser.parse_args()
710
+
711
+ errors = []
712
+
713
+ # Get credentials from SF CLI
714
+ base_url, access_token = get_credentials(args.org_alias)
715
+
716
+ # Org identity (SF CLI first, then direct API fallback)
717
+ identity = query_org_identity(args.org_alias, base_url, access_token)
718
+ org_id = identity.get("Id", "unknown")
719
+ log(f"Org: {identity.get('Name', '?')} ({identity.get('OrganizationType', '?')}) — {org_id}")
720
+
721
+ # Metadata types + components (SF CLI first, Tooling API fallback)
722
+ types = discover_metadata_types_cli(args.org_alias)
723
+ if types:
724
+ components, meta_errors = collect_metadata_cli(args.org_alias, types)
725
+ errors.extend(meta_errors)
726
+ else:
727
+ types = []
728
+ components, meta_errors = collect_metadata_tooling(base_url, access_token)
729
+ errors.extend(meta_errors)
730
+
731
+ # Org settings (SF CLI first, direct Tooling API fallback)
732
+ permissions = collect_org_settings(args.org_alias, base_url, access_token)
733
+
734
+ # Org limits (SF CLI first, REST /limits/ fallback)
735
+ org_values = collect_org_limits(args.org_alias, base_url, access_token)
736
+
737
+ # Installed packages (SF CLI first, direct Tooling API fallback)
738
+ packages = collect_installed_packages(args.org_alias, base_url, access_token)
739
+
740
+ # Installed components (ManageableState query for non-namespaced packages)
741
+ installed_components = collect_installed_components(args.org_alias, base_url, access_token)
742
+
743
+ # Licenses (SF CLI first, direct API fallback)
744
+ licenses = collect_licenses(args.org_alias, base_url, access_token)
745
+
746
+ # System permissions (PermissionsXxx on PermissionSet)
747
+ system_permissions = collect_system_permissions(args.org_alias, base_url, access_token)
748
+
749
+ # Deep data (SF CLI first, direct Tooling API fallback)
750
+ deep_data = {}
751
+ if not args.skip_deep_data:
752
+ deep_data, deep_errors = collect_deep_data(args.org_alias, base_url, access_token)
753
+ errors.extend(deep_errors)
754
+
755
+ # Assemble output — split into category files for parallel agent access
756
+ collected_at = datetime.now(timezone.utc).isoformat()
757
+ identity_data = {
758
+ "org_id": org_id,
759
+ "org_name": identity.get("Name", "unknown"),
760
+ "org_edition": identity.get("OrganizationType", "unknown"),
761
+ "base_url": base_url,
762
+ "collected_at": collected_at,
763
+ "api_version": API_VERSION,
764
+ "errors": errors,
765
+ }
766
+
767
+ categories = {
768
+ "identity": identity_data,
769
+ "metadata": {"metadata_types": types, "metadata_components": components},
770
+ "permissions": {"permissions": permissions},
771
+ "org_values": {"org_values": org_values},
772
+ "packages": {"installed_packages": packages, "installed_components": installed_components},
773
+ "licenses": {"licenses": licenses},
774
+ "system_permissions": {"system_permissions": system_permissions},
775
+ "deep_data": {"deep_data": deep_data},
776
+ }
777
+
778
+ import os
779
+ out_dir = args.output
780
+ os.makedirs(out_dir, exist_ok=True)
781
+
782
+ manifest = {"format": "split", "collected_at": collected_at, "files": {}}
783
+ for cat, data in categories.items():
784
+ fname = f"{cat}.json"
785
+ fpath = os.path.join(out_dir, fname)
786
+ with open(fpath, "w") as f:
787
+ json.dump(data, f, indent=2, default=str)
788
+ manifest["files"][cat] = fname
789
+
790
+ with open(os.path.join(out_dir, "manifest.json"), "w") as f:
791
+ json.dump(manifest, f, indent=2)
792
+
793
+ # Also write combined file for backward compatibility
794
+ combined = dict(identity_data)
795
+ combined["metadata_types"] = types
796
+ combined["metadata_components"] = components
797
+ combined["permissions"] = permissions
798
+ combined["org_values"] = org_values
799
+ combined["installed_packages"] = packages
800
+ combined["installed_components"] = installed_components
801
+ combined["licenses"] = licenses
802
+ combined["system_permissions"] = system_permissions
803
+ combined["deep_data"] = deep_data
804
+ with open(os.path.join(out_dir, "combined.json"), "w") as f:
805
+ json.dump(combined, f, indent=2, default=str)
806
+
807
+ log(f"\nWrote {out_dir}/")
808
+ log(f" Files: {', '.join(sorted(manifest['files'].keys()))}, combined.json")
809
+ log(f" Metadata types: {len(types)} discovered, {len(components)} with components")
810
+ log(f" Org settings: {len(permissions)}")
811
+ log(f" Org limits: {len(org_values)}")
812
+ log(f" Installed packages: {len(packages)}")
813
+ log(f" Installed components: {sum(len(v) for v in installed_components.values())}")
814
+ log(f" Licenses: {sum(len(v) for v in licenses.values())}")
815
+ log(f" System permissions: {len(system_permissions.get('permission_sets', []))} permission sets, "
816
+ f"{len(system_permissions.get('fields_discovered', []))} fields")
817
+ if errors:
818
+ log(f" Warnings: {len(errors)}")
819
+ sys.exit(0)
820
+
821
+
822
+ if __name__ == "__main__":
823
+ main()