@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.
- package/package.json +1 -1
- package/skills/dx-org-analyze/README.md +310 -0
- package/skills/dx-org-analyze/SKILL.md +261 -0
- package/skills/dx-org-analyze/references/collection-details.md +88 -0
- package/skills/dx-org-analyze/references/report-format.md +67 -0
- package/skills/dx-org-analyze/scripts/collect_org_data.py +823 -0
- package/skills/dx-org-analyze/scripts/compute_diff.py +1103 -0
- package/skills/dx-org-analyze/scripts/introspect_org.py +436 -0
- package/skills/dx-org-analyze/tests/README.md +46 -0
- package/skills/dx-org-analyze/tests/fixtures/org_a.json +76 -0
- package/skills/dx-org-analyze/tests/fixtures/org_b.json +70 -0
- package/skills/dx-org-analyze/tests/test_compute_diff.sh +284 -0
- package/skills/dx-org-analyze/tests/test_consistency.sh +428 -0
- package/skills/experience-design-validate/SKILL.md +163 -0
- package/skills/experience-design-validate/references/ai.md +54 -0
- package/skills/experience-design-validate/references/components.md +61 -0
- package/skills/experience-design-validate/references/craft.md +158 -0
- package/skills/experience-design-validate/references/data.md +59 -0
- package/skills/experience-design-validate/references/forms-flows.md +57 -0
- package/skills/experience-design-validate/references/interaction.md +53 -0
- package/skills/experience-design-validate/references/navigation.md +51 -0
- package/skills/experience-design-validate/references/performance.md +56 -0
- package/skills/experience-design-validate/references/records.md +60 -0
- package/skills/experience-design-validate/references/responsive.md +48 -0
- package/skills/experience-design-validate/references/scoring-rubric.md +257 -0
- package/skills/experience-design-validate/references/state.md +53 -0
- package/skills/experience-design-validate/references/trust.md +47 -0
- package/skills/experience-design-validate/references/usability.md +35 -0
- package/skills/experience-design-validate/references/visual-system.md +183 -0
- package/skills/service-agentforce-contact-center-coordinate/SKILL.md +170 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/escalation-flow.flow-meta.xml +72 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/omni-flow.flow-meta.xml +85 -0
- package/skills/service-agentforce-contact-center-coordinate/assets/report-template.md +52 -0
- package/skills/service-agentforce-contact-center-coordinate/references/agentforce-prerequisite.md +31 -0
- package/skills/service-agentforce-contact-center-coordinate/references/messaging_channel.md +76 -0
- package/skills/service-agentforce-contact-center-coordinate/references/number_management_api.md +78 -0
- package/skills/service-agentforce-contact-center-coordinate/references/omni-flow-routing.md +74 -0
- package/skills/service-agentforce-contact-center-coordinate/references/setup_summary.md +36 -0
- package/skills/service-agentforce-contact-center-coordinate/references/verification_and_errors.md +42 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/check-agentforce-prereq.sh +54 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-routing-flows.sh +49 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-agent.sh +73 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/create-voice-channel.sh +66 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/fetch-numbers.sh +21 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/lib.sh +46 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/prepare-agent-workdir.sh +21 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/procure-number.sh +24 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-acc-queue.sh +27 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-channel-line.sh +37 -0
- package/skills/service-agentforce-contact-center-coordinate/scripts/resolve-flow-definition.sh +33 -0
- 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()
|