@salesforce/afv-skills 1.44.0 → 1.45.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 (97) hide show
  1. package/package.json +1 -1
  2. package/skills/consumer-goods-rtr-datacloud-export-configure/SKILL.md +72 -0
  3. package/skills/consumer-goods-rtr-datacloud-export-configure/references/inputs-and-namespace.md +38 -0
  4. package/skills/consumer-goods-rtr-datacloud-export-configure/references/procedure.md +158 -0
  5. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/detect-namespace.js +86 -0
  6. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/render-apex.js +64 -0
  7. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/resolve-id-by-name.js +47 -0
  8. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/sf-rest.js +171 -0
  9. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/soql-escape.js +26 -0
  10. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/upsert-report-config.apex +51 -0
  11. package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/upsert-system-setting.apex +21 -0
  12. package/skills/consumer-goods-tpe-dashboard-configure/SKILL.md +74 -0
  13. package/skills/consumer-goods-tpe-dashboard-configure/references/phases-1-6.md +112 -0
  14. package/skills/consumer-goods-tpe-dashboard-configure/references/phases-7-12.md +157 -0
  15. package/skills/consumer-goods-tpe-dashboard-configure/scripts/find-failure-reason.js +132 -0
  16. package/skills/consumer-goods-tpe-dashboard-configure/scripts/poll-status.js +116 -0
  17. package/skills/consumer-goods-tpe-dashboard-configure/scripts/render-apex.js +64 -0
  18. package/skills/consumer-goods-tpe-dashboard-configure/scripts/run-data-transform.js +121 -0
  19. package/skills/consumer-goods-tpe-dashboard-configure/scripts/schedule-business-period-export.apex +27 -0
  20. package/skills/consumer-goods-tpe-dashboard-configure/scripts/sf-rest.js +171 -0
  21. package/skills/consumer-goods-tpe-dashboard-configure/scripts/soql-escape.js +25 -0
  22. package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/SKILL.md +141 -0
  23. package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/references/payload-shapes.md +447 -0
  24. package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/references/procedure.md +263 -0
  25. package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/scripts/clone-tpe-dashboards.js +537 -0
  26. package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/scripts/sf-rest.js +195 -0
  27. package/skills/consumer-goods-tpe-datakit-deploy/SKILL.md +157 -0
  28. package/skills/consumer-goods-tpe-datakit-deploy/scripts/detect-namespace.js +86 -0
  29. package/skills/consumer-goods-tpe-datakit-deploy/scripts/download-static-resource.js +151 -0
  30. package/skills/consumer-goods-tpe-datakit-deploy/scripts/extract-crm-field-permissions.js +115 -0
  31. package/skills/consumer-goods-tpe-datakit-deploy/scripts/sf-rest.js +109 -0
  32. package/skills/consumer-goods-tpe-datakit-deploy/scripts/update-field-permissions.js +433 -0
  33. package/skills/service-catalog-template-coordinate/SKILL.md +263 -0
  34. package/skills/service-catalog-template-coordinate/examples/output-templates.md +44 -0
  35. package/skills/service-catalog-template-coordinate/references/mcp-invocation.md +183 -0
  36. package/skills/service-catalog-template-coordinate/references/operations.md +230 -0
  37. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +243 -0
  38. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/cli-invocation.md +205 -0
  39. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/helper-contracts.md +236 -0
  40. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/permset-topology.md +132 -0
  41. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-activated-agents.mjs +106 -0
  42. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-agent-access-state.mjs +113 -0
  43. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-assignment-state.mjs +99 -0
  44. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-platform-permset-availability.mjs +155 -0
  45. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/gate-unified-catalog-tiers.mjs +100 -0
  46. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/rank-candidate-users.mjs +95 -0
  47. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/resolve-target-user.mjs +86 -0
  48. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +44 -23
  49. package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +33 -9
  50. package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +17 -18
  51. package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +3 -1
  52. package/skills/service-itsm-agentic-setup-configure/SKILL.md +20 -12
  53. package/skills/service-itsm-agentic-setup-configure/examples/output-templates.md +73 -5
  54. package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +8 -7
  55. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/cli-invocation.md +45 -33
  56. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/reactivation.md +8 -6
  57. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/workflow-detail.md +10 -10
  58. package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/classify-agent-existence.mjs +114 -56
  59. package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/classify-preflight.mjs +33 -17
  60. package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +9 -3
  61. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +8 -7
  62. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/cli-invocation.md +43 -32
  63. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/reactivation.md +6 -4
  64. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/workflow-detail.md +10 -10
  65. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-agent-existence.mjs +106 -55
  66. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-preflight.mjs +27 -13
  67. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +9 -3
  68. package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +159 -161
  69. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone-action.json +51 -0
  70. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone.json +1 -1
  71. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/predefined-incident-policy.json +120 -0
  72. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/milestone-patterns.md +28 -5
  73. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/output-templates.md +19 -1
  74. package/skills/service-itsm-agentic-setup-incident-sla-configure/references/mcp-invocation.md +350 -30
  75. package/skills/service-itsm-channels-coordinate/SKILL.md +80 -213
  76. package/skills/service-itsm-slack-itservice-configure/SKILL.md +363 -0
  77. package/skills/service-itsm-slack-itservice-configure/references/connect-agentforce-to-slack.md +159 -0
  78. package/skills/service-itsm-slack-itservice-configure/references/manage-slack-connection.md +88 -0
  79. package/skills/service-itsm-slack-itservice-configure/references/manage-user-access.md +117 -0
  80. package/skills/service-itsm-slack-itservice-configure/references/record-visibility.md +78 -0
  81. package/skills/service-itsm-slack-itservice-configure/references/site-membership-verification.md +126 -0
  82. package/skills/service-itsm-slack-itservice-configure/scripts/classify-user-access.mjs +167 -0
  83. package/skills/service-itsm-teams-configure/SKILL.md +50 -47
  84. package/skills/service-itsm-teams-configure/references/azure-credential-population.md +42 -28
  85. package/skills/service-itsm-teams-configure/references/gotchas.md +1 -2
  86. package/skills/service-itsm-teams-coordinate/SKILL.md +22 -18
  87. package/skills/service-itsm-teams-coordinate/examples/output-templates.md +12 -9
  88. package/skills/service-itsm-teams-itdesk-configure/SKILL.md +60 -44
  89. package/skills/service-itsm-teams-itservice-configure/SKILL.md +56 -70
  90. package/skills/service-catalog-template-deploy/SKILL.md +0 -310
  91. package/skills/service-catalog-template-deploy/references/cli-invocation.md +0 -258
  92. package/skills/service-catalog-template-deploy/scripts/activate-verify.mjs +0 -164
  93. package/skills/service-catalog-template-deploy/scripts/build-deploy-payload.mjs +0 -94
  94. package/skills/service-catalog-template-deploy/scripts/resolve-template.mjs +0 -331
  95. package/skills/service-catalog-template-search/SKILL.md +0 -212
  96. package/skills/service-catalog-template-search/references/cli-invocation.md +0 -128
  97. package/skills/service-catalog-template-search/scripts/classify-catalog.mjs +0 -205
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic driver for Customization Scenario 1 ("Use Custom KPIs with
4
+ * Standard Dashboards"). Encodes the exact clone recipe validated by hand
5
+ * against a live org (2026-08-17) — every strip/replace rule below
6
+ * was derived from real 400/422 responses, not guessed. See
7
+ * ../references/payload-shapes.md for the annotated trace of how each rule
8
+ * was discovered.
9
+ *
10
+ * What it does, always, for all 5 KPI slots (Promotion Measure 1-3, Tactic
11
+ * Measure 1-2), regardless of which slots have an override:
12
+ * 1. Resolve the installed TPE Analytics app + its asset ids (Extended
13
+ * SDM, both dashboards) — never hardcoded. Visualization ids are
14
+ * resolved per-dashboard at clone time (step 4), not here — every
15
+ * dashboard's own widget list is the source of truth for which
16
+ * visualizations exist, never a fixed count assumed up front.
17
+ * 2. GET the whole Extended SDM once; index the 5 base parameters +
18
+ * their matching calculated measurements by apiName (Tactic-side
19
+ * measurements are apiName `_clc`, not `_value` — never assume symmetry).
20
+ * 3. For each slot, POST a cloned parameter (`<base>_<suffix>`) with a new/
21
+ * updated default-value entry, then a cloned calculated measurement
22
+ * whose expression is repointed at the cloned parameter.
23
+ * 4. Clone EVERY visualization referenced by a "visualization"-type widget
24
+ * on either base dashboard that references at least one of the 5 slot
25
+ * measurements — confirmed live: on both TPE dashboards, virtually
26
+ * every chart (top/bottom performers, trend, category breakdowns, not
27
+ * just the one obviously-named "Performance_Measures" viz) binds to
28
+ * the same generic KPI-measure fields. Cloning only the one
29
+ * "designated" viz per dashboard (the original, wrong assumption) left
30
+ * every other chart still bound to the base measurement/parameter,
31
+ * silently out of sync with the customer's selection. Discovered by
32
+ * walking the base dashboard's widgets directly, never by a
33
+ * hardcoded/guessed visualization template name.
34
+ * 5. Clone the 2 dashboards: repoint parameter-widgets' parameterName and
35
+ * every visualization-widget's source to its Phase-4 clone — located
36
+ * dynamically by matching base apiNames inside the widget map, never by
37
+ * a hardcoded widget key (widget keys like "parameter_3" are
38
+ * install-specific, not stable across orgs).
39
+ *
40
+ * Never mutates a base artifact — every write here is a new POST of a
41
+ * suffixed copy the base model.
42
+ *
43
+ * CLI usage:
44
+ * node clone-tpe-dashboards.js --target-org <username> --suffix Cust \
45
+ * [--overrides '{
46
+ * "Promotion_Measure_1": {"measureCode":"VOLU","displayName":"Volume"},
47
+ * "Promotion_Measure_2": {"allowedValues":[{"displayName":"A","value":"AAAA"},{"displayName":"B","value":"BBBB"}],"defaultValue":"A"}
48
+ * }'] \
49
+ * [--max-vizs-per-dashboard N] [--dry-run]
50
+ *
51
+ * --overrides is a sparse JSON object keyed by slot
52
+ * (Promotion_Measure_1/2/3, Tactic_Measure_1/2). A slot not present in
53
+ * --overrides still gets a full clone in "append" mode (see below), using
54
+ * the base default measure code and "<base default display name> <suffix>".
55
+ *
56
+ * Two override modes per slot, chosen by which fields are present:
57
+ * - {measureCode, displayName} — APPEND mode (default): the clone's
58
+ * allowedValues = the base parameter's full menu + this one new/changed
59
+ * entry, set as the default selection. The base menu stays fully intact
60
+ * underneath. Any customer-supplied measureCode/displayName is used
61
+ * verbatim — it is not required to reuse or embed the suffix.
62
+ * - {allowedValues: [{displayName, value}, ...], defaultValue?} — REPLACE
63
+ * mode: the customer supplies the COMPLETE dropdown menu themselves; the
64
+ * base allowedValues are discarded, not merged. defaultValue must match
65
+ * one supplied entry's displayName (defaults to the first entry if
66
+ * omitted). Use this when the customer wants their own measure-code menu
67
+ * instead of the base 13/14/6-entry standard list.
68
+ *
69
+ * --max-vizs-per-dashboard N — debug/iteration aid only. Caps how many
70
+ * distinct visualizations get cloned per dashboard (e.g. 1 or 2) so a quick
71
+ * validation pass doesn't have to wait through all 9/7 real viz clones.
72
+ * Widgets beyond the cap are left pointing at their base visualization —
73
+ * the dashboard still builds, just without full viz coverage. Omit for a
74
+ * real/complete run — never use this for an actual customer deliverable.
75
+ *
76
+ * --dry-run prints the fully resolved plan (every payload that would be
77
+ * POSTed) and exits 0 without writing anything.
78
+ */
79
+
80
+ const { restRequest } = require('./sf-rest');
81
+
82
+ const TEMPLATE_SOURCE_ID = 'sfdc_internal__Trade_Promotion_Effectiveness';
83
+ const EXTENDED_MODEL_TEMPLATE_NAME_PREFIX = 'Extended_Trade_Promotion_Management';
84
+
85
+ const SLOTS = [
86
+ { key: 'Promotion_Measure_1', paramApi: 'Promotion_Measure_1_prm', dashboard: 'Promotion' },
87
+ { key: 'Promotion_Measure_2', paramApi: 'Promotion_Measure_2_prm', dashboard: 'Promotion' },
88
+ { key: 'Promotion_Measure_3', paramApi: 'Promotion_Measure_3_prm', dashboard: 'Promotion' },
89
+ { key: 'Tactic_Measure_1', paramApi: 'Tactic_Measure_1_prm', dashboard: 'Tactic' },
90
+ { key: 'Tactic_Measure_2', paramApi: 'Tactic_Measure_2_prm', dashboard: 'Tactic' },
91
+ ];
92
+
93
+ const DASHBOARDS = {
94
+ Promotion: { templateAssetSourceName: 'PromotionAnalysis' },
95
+ Tactic: { templateAssetSourceName: 'TacticAnalysis' },
96
+ };
97
+
98
+ function parseArgs(argv) {
99
+ const args = { dryRun: false };
100
+ for (let i = 0; i < argv.length; i += 1) {
101
+ const token = argv[i];
102
+ if (token === '--dry-run') { args.dryRun = true; continue; }
103
+ if (!token.startsWith('--')) continue;
104
+ args[token.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[i + 1];
105
+ i += 1;
106
+ }
107
+ return args;
108
+ }
109
+
110
+ function requireArg(args, name) {
111
+ if (!args[name]) {
112
+ console.error(`Missing required --${name}`);
113
+ process.exit(2);
114
+ }
115
+ return args[name];
116
+ }
117
+
118
+ async function rest(targetOrg, apiVersion, path, method, body) {
119
+ const result = await restRequest({
120
+ targetOrg,
121
+ path: `/services/data/${apiVersion}${path}`,
122
+ method,
123
+ body: body !== undefined ? JSON.stringify(body) : undefined,
124
+ });
125
+ if (!result.ok) {
126
+ throw new Error(`${method || 'GET'} ${path} -> HTTP ${result.status}: ${JSON.stringify(result.body)}`);
127
+ }
128
+ return result.body;
129
+ }
130
+
131
+ // GET-by-name, tolerating a 404 as "doesn't exist yet" (returns null) instead
132
+ // of throwing — confirmed live: /tableau/visualizations/<name> and
133
+ // /tableau/dashboards/<name> both resolve by name (not just by Id) and
134
+ // return {errorCode: "RESOURCE_NOT_FOUND"} with HTTP 404 when absent, the
135
+ // same by-name-GET shape already trusted for /ssot/semantic/models/<apiName>
136
+ // above. Any other non-2xx status is a real error and still throws.
137
+ async function getIfExists(targetOrg, apiVersion, path) {
138
+ const result = await restRequest({
139
+ targetOrg,
140
+ path: `/services/data/${apiVersion}${path}`,
141
+ method: 'GET',
142
+ });
143
+ if (result.ok) return result.body;
144
+ if (result.status === 404) return null;
145
+ throw new Error(`GET ${path} -> HTTP ${result.status}: ${JSON.stringify(result.body)}`);
146
+ }
147
+
148
+ // Keys that would pollute the clone's prototype if copied via out[k] = v on a
149
+ // plain {} — skipped unconditionally in both deep-copy helpers below,
150
+ // regardless of the caller's own strip list.
151
+ const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
152
+
153
+ // Recursively delete the given keys from every plain object in the tree.
154
+ function stripKeysDeep(value, keys) {
155
+ if (Array.isArray(value)) return value.map((v) => stripKeysDeep(v, keys));
156
+ if (value && typeof value === 'object') {
157
+ const out = {};
158
+ for (const [k, v] of Object.entries(value)) {
159
+ if (keys.includes(k) || UNSAFE_KEYS.includes(k)) continue;
160
+ out[k] = stripKeysDeep(v, keys);
161
+ }
162
+ return out;
163
+ }
164
+ return value;
165
+ }
166
+
167
+ // Recursively delete .label and .type from any object whose .type is
168
+ // "Visualization" or "SemanticModel" (source/dynamicTokens[].source refs) —
169
+ // confirmed live: these ref objects accept only .name on create. Never
170
+ // strip .type from a widget object itself (the polymorphic discriminator).
171
+ function stripSourceRefMetadataDeep(value) {
172
+ if (Array.isArray(value)) return value.map(stripSourceRefMetadataDeep);
173
+ if (value && typeof value === 'object') {
174
+ const out = {};
175
+ const isSourceRef = value.type === 'Visualization' || value.type === 'SemanticModel';
176
+ for (const [k, v] of Object.entries(value)) {
177
+ if (UNSAFE_KEYS.includes(k)) continue;
178
+ if (isSourceRef && (k === 'label' || k === 'type')) continue;
179
+ out[k] = stripSourceRefMetadataDeep(v);
180
+ }
181
+ return out;
182
+ }
183
+ return value;
184
+ }
185
+
186
+ function replaceApiNamesInJson(obj, replacements) {
187
+ let text = JSON.stringify(obj);
188
+ for (const [from, to] of replacements) {
189
+ text = text.split(from).join(to);
190
+ }
191
+ return JSON.parse(text);
192
+ }
193
+
194
+ async function resolveApp(targetOrg, apiVersion) {
195
+ const resp = await rest(targetOrg, apiVersion, `/app-framework/apps?templateSourceId=${TEMPLATE_SOURCE_ID}`, 'GET');
196
+ const apps = (resp.apps || resp || []).filter((a) =>
197
+ ['SuccessStatus', 'SuccessWithWarningsStatus'].includes(a.applicationStatus)
198
+ );
199
+ if (apps.length === 0) {
200
+ throw new Error(
201
+ 'No successfully installed TPE Analytics app found. Run the setup-tpe-dashboard skill first.'
202
+ );
203
+ }
204
+ if (apps.length > 1) {
205
+ const candidates = apps.map((a) => `${a.id} (${a.name || a.label}, ${a.createdDate})`).join('; ');
206
+ throw new Error(
207
+ `Multiple installed TPE Analytics apps found — refusing to guess which one to target: ${candidates}. Re-run with the intended app disambiguated (not yet a CLI flag; extend this script to accept --app-id if this occurs in practice).`
208
+ );
209
+ }
210
+ return apps[0];
211
+ }
212
+
213
+ async function resolveAssets(targetOrg, apiVersion, appId) {
214
+ const resp = await rest(targetOrg, apiVersion, `/app-framework/apps/${appId}/assets`, 'GET');
215
+ const assets = resp.assets || resp;
216
+ const byTemplateName = {};
217
+ for (const asset of assets) {
218
+ byTemplateName[asset.templateAssetSourceName] = asset;
219
+ }
220
+ const extendedModelAsset = Object.values(byTemplateName).find(
221
+ (a) => a.type === 'SemanticModel' && a.templateAssetSourceName.startsWith(EXTENDED_MODEL_TEMPLATE_NAME_PREFIX)
222
+ );
223
+ if (!extendedModelAsset) throw new Error('Extended TPM Analytics SemanticModel asset not found in app assets.');
224
+ return { assets, byTemplateName, extendedModelAsset };
225
+ }
226
+
227
+ // Two override modes, chosen per-slot by which fields are present:
228
+ // - no override, or {measureCode, displayName}: "append" mode (default) — the
229
+ // clone's allowedValues = base's full list + this one new/changed entry;
230
+ // the base menu stays fully intact, only the default selection changes.
231
+ // - {allowedValues: [{displayName, value}, ...], defaultValue?}: "replace"
232
+ // mode — the customer supplies the COMPLETE dropdown menu themselves; the
233
+ // base allowedValues are discarded entirely, not merged. defaultValue must
234
+ // match one of the supplied entries' displayName (defaults to the first
235
+ // entry if omitted).
236
+ function slotOverride(overrides, slotKey, baseDefaultDisplayName, baseDefaultCode, suffix, baseAllowedValues) {
237
+ const o = overrides[slotKey];
238
+ if (!o) {
239
+ return {
240
+ mode: 'append',
241
+ allowedValues: baseAllowedValues,
242
+ measureCode: baseDefaultCode,
243
+ displayName: `${baseDefaultDisplayName} ${suffix}`,
244
+ };
245
+ }
246
+ if (o.allowedValues) {
247
+ if (!Array.isArray(o.allowedValues) || o.allowedValues.length === 0) {
248
+ throw new Error(`Slot ${slotKey}: overrides.allowedValues must be a non-empty array of {displayName, value}.`);
249
+ }
250
+ const defaultDisplayName = o.defaultValue || o.allowedValues[0].displayName;
251
+ const matchingEntry = o.allowedValues.find((v) => v.displayName === defaultDisplayName);
252
+ if (!matchingEntry) {
253
+ throw new Error(
254
+ `Slot ${slotKey}: overrides.defaultValue "${defaultDisplayName}" doesn't match any displayName in overrides.allowedValues.`
255
+ );
256
+ }
257
+ return {
258
+ mode: 'replace',
259
+ allowedValues: o.allowedValues,
260
+ measureCode: matchingEntry.value,
261
+ displayName: defaultDisplayName,
262
+ };
263
+ }
264
+ return {
265
+ mode: 'append',
266
+ allowedValues: baseAllowedValues,
267
+ measureCode: o.measureCode || baseDefaultCode,
268
+ displayName: o.displayName || `${baseDefaultDisplayName} ${suffix}`,
269
+ };
270
+ }
271
+
272
+ async function main() {
273
+ const args = parseArgs(process.argv.slice(2));
274
+ const targetOrg = requireArg(args, 'targetOrg');
275
+ const suffix = requireArg(args, 'suffix');
276
+ const overrides = args.overrides ? JSON.parse(args.overrides) : {};
277
+ const dryRun = args.dryRun;
278
+ // Debug/iteration aid — cap how many distinct visualizations get cloned per
279
+ // dashboard so a full validation pass doesn't require waiting through all
280
+ // 9/7 real viz clones every time. Widgets beyond the cap are left pointing
281
+ // at their base visualization (same as a genuinely-unrelated widget) — the
282
+ // dashboard still builds successfully, just without full viz coverage.
283
+ // Omit for a real/complete run.
284
+ const maxVizsPerDashboard = args.maxVizsPerDashboard ? parseInt(args.maxVizsPerDashboard, 10) : Infinity;
285
+
286
+ const { execFileSync } = require('child_process');
287
+ // A color-forcing terminal (e.g. Warp's FORCE_COLOR=3) makes `sf` emit ANSI
288
+ // codes even with --json, which breaks JSON.parse — force color off for
289
+ // this invocation regardless of the calling shell's env.
290
+ const orgDisplay = JSON.parse(
291
+ execFileSync('sf', ['org', 'display', '--target-org', targetOrg, '--json'], {
292
+ encoding: 'utf8',
293
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
294
+ })
295
+ ).result;
296
+ if (orgDisplay.connectedStatus !== 'Connected') {
297
+ throw new Error(`Org ${targetOrg} is not connected (connectedStatus=${orgDisplay.connectedStatus}). Log in and retry.`);
298
+ }
299
+ // Pinned per standing instruction — always v67.0, not the org's own apiVersion.
300
+ const apiVersion = 'v67.0';
301
+ console.error(`[info] target-org=${targetOrg} apiVersion=${apiVersion} suffix=${suffix} dryRun=${dryRun}`);
302
+
303
+ const app = await resolveApp(targetOrg, apiVersion);
304
+ console.error(`[info] resolved app: ${app.id} (${app.name})`);
305
+
306
+ const { byTemplateName, extendedModelAsset } = await resolveAssets(targetOrg, apiVersion, app.id);
307
+ // The model must be addressed by its live apiName for the parameters/
308
+ // calculated-measurements sub-resource paths — resolve it from the model
309
+ // GET itself rather than trusting any asset-list field, since the asset
310
+ // list's naming of the apiName field isn't guaranteed stable.
311
+ const model = await rest(targetOrg, apiVersion, `/ssot/semantic/models/${extendedModelAsset.assetIdOrName}`, 'GET');
312
+ const resolvedModelApiName = model.apiName;
313
+ console.error(`[info] Extended SDM apiName: ${resolvedModelApiName}`);
314
+
315
+ const paramsByApi = {};
316
+ for (const p of model.semanticParameters) paramsByApi[p.apiName] = p;
317
+ const measurementsByParamApi = {};
318
+ const measurementsByApi = {};
319
+ for (const m of model.semanticCalculatedMeasurements) {
320
+ measurementsByApi[m.apiName] = m;
321
+ const match = m.expression && m.expression.match(/\[Parameters\]\.\[([A-Za-z0-9_]+)\]/);
322
+ if (match) measurementsByParamApi[match[1]] = m;
323
+ }
324
+
325
+ const plan = SLOTS.map((slot) => {
326
+ const baseParam = paramsByApi[slot.paramApi];
327
+ if (!baseParam) throw new Error(`Base parameter ${slot.paramApi} not found in Extended SDM.`);
328
+ const baseMeasurement = measurementsByParamApi[slot.paramApi];
329
+ if (!baseMeasurement) throw new Error(`No calculated measurement references parameter ${slot.paramApi}.`);
330
+ const baseDefaultEntry = baseParam.allowedValues.find((v) => v.displayName === baseParam.defaultValue);
331
+ if (!baseDefaultEntry) throw new Error(`Base parameter ${slot.paramApi}'s defaultValue doesn't match any allowedValues entry.`);
332
+
333
+ const resolved = slotOverride(
334
+ overrides, slot.key, baseDefaultEntry.displayName, baseDefaultEntry.value, suffix, baseParam.allowedValues
335
+ );
336
+ const newParamApi = `${slot.paramApi}_${suffix}`;
337
+ const newMeasurementApi = `${baseMeasurement.apiName}_${suffix}`;
338
+
339
+ return {
340
+ ...slot,
341
+ baseParamApi: slot.paramApi,
342
+ baseMeasurementApi: baseMeasurement.apiName,
343
+ newParamApi,
344
+ newMeasurementApi,
345
+ baseParamLabel: baseParam.label,
346
+ newParamLabel: `${baseParam.label} ${suffix}`,
347
+ resolved,
348
+ newParamBody: {
349
+ apiName: newParamApi,
350
+ label: `${baseParam.label} ${suffix}`,
351
+ type: baseParam.type,
352
+ dataType: baseParam.dataType,
353
+ // APPEND mode upserts by measureCode (allowedValues[].value is unique
354
+ // in the base menu) — if the customer's chosen code already exists
355
+ // (e.g. picking an existing code with a new displayName), replace
356
+ // that entry's displayName in place rather than adding a duplicate
357
+ // value with two different labels.
358
+ allowedValues: resolved.mode === 'replace'
359
+ ? resolved.allowedValues
360
+ : [
361
+ ...baseParam.allowedValues.filter((v) => v.value !== resolved.measureCode),
362
+ { displayName: resolved.displayName, value: resolved.measureCode },
363
+ ],
364
+ defaultValue: resolved.displayName,
365
+ },
366
+ newMeasurementBody: {
367
+ apiName: newMeasurementApi,
368
+ label: `${baseMeasurement.label} ${suffix}`,
369
+ dataType: baseMeasurement.dataType,
370
+ aggregationType: baseMeasurement.aggregationType,
371
+ totalAggregationType: baseMeasurement.totalAggregationType,
372
+ level: baseMeasurement.level,
373
+ isQueryable: baseMeasurement.isQueryable,
374
+ isVisible: baseMeasurement.isVisible,
375
+ directionality: baseMeasurement.directionality,
376
+ sentiment: baseMeasurement.sentiment,
377
+ shouldTreatNullsAsZeros: baseMeasurement.shouldTreatNullsAsZeros,
378
+ expression: baseMeasurement.expression.split(`[${slot.paramApi}]`).join(`[${newParamApi}]`),
379
+ },
380
+ };
381
+ });
382
+
383
+ console.error('[plan] resolved 5-slot plan:');
384
+ for (const p of plan) {
385
+ console.error(
386
+ ` ${p.key}: ${p.baseParamApi} -> ${p.newParamApi} | ${p.baseMeasurementApi} -> ${p.newMeasurementApi} | mode=${p.resolved.mode} measureCode=${p.resolved.measureCode} displayName="${p.resolved.displayName}" allowedValuesCount=${p.resolved.allowedValues.length}`
387
+ );
388
+ }
389
+
390
+ if (dryRun) {
391
+ console.log(JSON.stringify({ dryRun: true, plan }, null, 2));
392
+ return;
393
+ }
394
+
395
+ const created = { parameters: {}, measurements: {}, visualizations: {}, dashboards: {} };
396
+
397
+ for (const p of plan) {
398
+ // Resumable: a prior run of this same suffix may have already created
399
+ // this slot's parameter/measurement (e.g. an interrupted run). The model
400
+ // was freshly re-fetched above, so an existing clone shows up here —
401
+ // reuse it rather than re-POSTing into apiName-uniqueness
402
+ // CREATE_SEMANTIC_ENTITY_FAILED. Never re-derive an existing artifact's
403
+ // fields from the plan; trust the live model as the source of truth.
404
+ const existingParam = paramsByApi[p.newParamApi];
405
+ const paramResp = existingParam
406
+ ? existingParam
407
+ : await rest(targetOrg, apiVersion, `/ssot/semantic/models/${resolvedModelApiName}/parameters`, 'POST', p.newParamBody);
408
+ created.parameters[p.key] = paramResp;
409
+ console.error(`[${existingParam ? 'exists' : 'created'}] parameter ${p.newParamApi} (id ${paramResp.id})`);
410
+
411
+ const existingMeasurement = measurementsByApi[p.newMeasurementApi];
412
+ const measResp = existingMeasurement
413
+ ? existingMeasurement
414
+ : await rest(targetOrg, apiVersion, `/ssot/semantic/models/${resolvedModelApiName}/calculated-measurements`, 'POST', p.newMeasurementBody);
415
+ created.measurements[p.key] = measResp;
416
+ console.error(`[${existingMeasurement ? 'exists' : 'created'}] calculated measurement ${p.newMeasurementApi} (id ${measResp.id})`);
417
+ }
418
+
419
+ for (const [dashKey, dashCfg] of Object.entries(DASHBOARDS)) {
420
+ const dashAsset = byTemplateName[dashCfg.templateAssetSourceName];
421
+ if (!dashAsset) throw new Error(`Dashboard asset ${dashCfg.templateAssetSourceName} not found.`);
422
+ const baseDash = await rest(targetOrg, apiVersion, `/tableau/dashboards/${dashAsset.assetIdOrName}`, 'GET');
423
+
424
+ const slotsForDash = plan.filter((p) => p.dashboard === dashKey);
425
+ const measurementReplacements = slotsForDash.map((p) => [p.baseMeasurementApi, p.newMeasurementApi]);
426
+ const paramReplacements = slotsForDash.map((p) => [p.baseParamApi, p.newParamApi]);
427
+ // Cosmetic fields[].label text embeds the PARAMETER'S LABEL, not its apiName
428
+ // (e.g. "<[Parameters].[Promotion Measure 1]>") — confirmed live: fieldName
429
+ // is correctly repointed by measurementReplacements, but this display string
430
+ // is untouched unless we also replace the label text inside the
431
+ // "[Parameters].[<label>]" pattern specifically (not a bare label replace,
432
+ // which risks clobbering unrelated text that happens to contain the label).
433
+ // Some cosmetic label strings store the surrounding angle brackets as
434
+ // literal `<`/`>`, others as HTML entities (`&lt;`/`&gt;`) depending on
435
+ // where the platform rendered them from — confirmed live: an
436
+ // entity-escaped occurrence left untouched by a plain-bracket-only
437
+ // replacement re-serializes as `&lt;[Parameters].[<label> Cust]&gt;`
438
+ // instead of decoding to `<...>`. Handle the entity-escaped form first
439
+ // (converting it to plain `<...>` in the same pass) before the
440
+ // plain-bracket fallback for any remaining unescaped occurrences.
441
+ const labelReplacements = slotsForDash.flatMap((p) => [
442
+ [`&lt;[Parameters].[${p.baseParamLabel}]&gt;`, `<[Parameters].[${p.newParamLabel}]>`],
443
+ [`[Parameters].[${p.baseParamLabel}]`, `[Parameters].[${p.newParamLabel}]`],
444
+ ]);
445
+ const allReplacements = [...paramReplacements, ...measurementReplacements, ...labelReplacements];
446
+
447
+ // Discover every "visualization"-type widget's source viz by walking the
448
+ // base dashboard directly (never a hardcoded/guessed viz template name)
449
+ // and clone each one that actually references at least one of this
450
+ // dashboard's slot measurements — confirmed live: virtually every chart
451
+ // on both TPE dashboards binds to the same generic KPI-measure fields,
452
+ // not just one "designated" viz. A viz genuinely unrelated to these
453
+ // slots (no measurement apiName present) is left pointing at base.
454
+ const vizWidgets = Object.values(baseDash.widgets).filter((w) => w.type === 'visualization');
455
+ const clonedVizNameByBase = {};
456
+ for (const widget of vizWidgets) {
457
+ const baseVizName = widget.source.name;
458
+ if (clonedVizNameByBase[baseVizName]) continue; // already cloned (widget reuses same viz)
459
+ if (Object.keys(clonedVizNameByBase).length >= maxVizsPerDashboard) {
460
+ console.error(`[debug] --max-vizs-per-dashboard=${maxVizsPerDashboard} reached for ${dashKey}; leaving ${baseVizName} on base`);
461
+ continue;
462
+ }
463
+ const baseViz = await rest(targetOrg, apiVersion, `/tableau/visualizations/${widget.source.id}`, 'GET');
464
+ const vizJson = JSON.stringify(baseViz);
465
+ const relevantMeasurementReplacements = measurementReplacements.filter(([from]) => vizJson.includes(from));
466
+ if (relevantMeasurementReplacements.length === 0) continue; // genuinely unrelated to these slots
467
+ const relevantLabelReplacements = labelReplacements.filter(([from]) => vizJson.includes(from));
468
+ const relevantReplacements = [...relevantMeasurementReplacements, ...relevantLabelReplacements];
469
+
470
+ // Unlike the dashboard body below, the visualization's own dataSource
471
+ // ref (type: "SemanticModel") must keep its .type — confirmed live:
472
+ // stripping it here (as stripSourceRefMetadataDeep would) causes
473
+ // INVALID_INPUT "Value required for [type]." stripSourceRefMetadataDeep
474
+ // is only valid for dashboard widget source/dynamicTokens refs.
475
+ let vizBody = stripKeysDeep(baseViz, [
476
+ 'id', 'createdBy', 'createdDate', 'lastModifiedBy', 'lastModifiedDate',
477
+ 'permissions', 'sourceVersion', 'templateSource', 'url', 'status',
478
+ ]);
479
+ if (vizBody.view) delete vizBody.view.isOriginal;
480
+ vizBody = replaceApiNamesInJson(vizBody, relevantReplacements);
481
+ vizBody.name = `${baseViz.name}_${suffix}`;
482
+ vizBody.label = `${baseViz.label} ${suffix}`;
483
+
484
+ // Resumable, same as the parameter/measurement loop above: a prior
485
+ // interrupted run may have already created this clone. GET-by-name
486
+ // first rather than re-POSTing into a name-uniqueness failure — never
487
+ // re-derive an existing clone's body from the current plan; trust
488
+ // whatever is already live in the org.
489
+ const existingViz = await getIfExists(targetOrg, apiVersion, `/tableau/visualizations/${vizBody.name}`);
490
+ const vizResp = existingViz || await rest(targetOrg, apiVersion, '/tableau/visualizations', 'POST', vizBody);
491
+ clonedVizNameByBase[baseVizName] = vizResp.name;
492
+ created.visualizations[`${dashKey}:${baseVizName}`] = vizResp;
493
+ console.error(`[${existingViz ? 'exists' : 'created'}] visualization ${vizBody.name} (id ${vizResp.id})`);
494
+ }
495
+
496
+ let dashBody = { ...baseDash };
497
+ delete dashBody.customViews;
498
+ dashBody = stripKeysDeep(dashBody, [
499
+ 'id', 'createdBy', 'createdDate', 'lastModifiedBy', 'lastModifiedDate',
500
+ 'permissions', 'sourceVersion', 'templateSource', 'url', 'status',
501
+ ]);
502
+ dashBody = stripSourceRefMetadataDeep(dashBody);
503
+ dashBody = replaceApiNamesInJson(dashBody, allReplacements);
504
+ dashBody.name = `${baseDash.name}_${suffix}`;
505
+ dashBody.label = `${baseDash.label} ${suffix}`;
506
+
507
+ // Repoint every visualization widget at its clone (by base name) —
508
+ // widgets whose viz had no relevant measurement are left untouched,
509
+ // still pointing at the base visualization.
510
+ for (const widget of Object.values(dashBody.widgets)) {
511
+ if (widget.type === 'visualization' && clonedVizNameByBase[widget.source.name]) {
512
+ widget.source.name = clonedVizNameByBase[widget.source.name];
513
+ }
514
+ }
515
+
516
+ // Resumable, same rationale as the visualization loop above: GET-by-name
517
+ // before POSTing so a re-run after a prior partial failure reuses an
518
+ // already-created dashboard clone instead of hitting a name-uniqueness
519
+ // error.
520
+ const existingDash = await getIfExists(targetOrg, apiVersion, `/tableau/dashboards/${dashBody.name}`);
521
+ const dashResp = existingDash || await rest(targetOrg, apiVersion, '/tableau/dashboards', 'POST', dashBody);
522
+ created.dashboards[dashKey] = dashResp;
523
+ console.error(`[${existingDash ? 'exists' : 'created'}] dashboard ${dashBody.name} (id ${dashResp.id})`);
524
+ }
525
+
526
+ console.log(JSON.stringify({
527
+ parameters: Object.fromEntries(Object.entries(created.parameters).map(([k, v]) => [k, { apiName: v.apiName, id: v.id }])),
528
+ measurements: Object.fromEntries(Object.entries(created.measurements).map(([k, v]) => [k, { apiName: v.apiName, id: v.id }])),
529
+ visualizations: Object.fromEntries(Object.entries(created.visualizations).map(([k, v]) => [k, { name: v.name, id: v.id }])),
530
+ dashboards: Object.fromEntries(Object.entries(created.dashboards).map(([k, v]) => [k, { name: v.name, id: v.id }])),
531
+ }, null, 2));
532
+ }
533
+
534
+ main().catch((err) => {
535
+ console.error(`[error] ${err.message}`);
536
+ process.exit(1);
537
+ });