@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.
- package/package.json +1 -1
- package/skills/consumer-goods-rtr-datacloud-export-configure/SKILL.md +72 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/references/inputs-and-namespace.md +38 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/references/procedure.md +158 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/detect-namespace.js +86 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/render-apex.js +64 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/resolve-id-by-name.js +47 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/sf-rest.js +171 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/soql-escape.js +26 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/upsert-report-config.apex +51 -0
- package/skills/consumer-goods-rtr-datacloud-export-configure/scripts/upsert-system-setting.apex +21 -0
- package/skills/consumer-goods-tpe-dashboard-configure/SKILL.md +74 -0
- package/skills/consumer-goods-tpe-dashboard-configure/references/phases-1-6.md +112 -0
- package/skills/consumer-goods-tpe-dashboard-configure/references/phases-7-12.md +157 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/find-failure-reason.js +132 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/poll-status.js +116 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/render-apex.js +64 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/run-data-transform.js +121 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/schedule-business-period-export.apex +27 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/sf-rest.js +171 -0
- package/skills/consumer-goods-tpe-dashboard-configure/scripts/soql-escape.js +25 -0
- package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/SKILL.md +141 -0
- package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/references/payload-shapes.md +447 -0
- package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/references/procedure.md +263 -0
- package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/scripts/clone-tpe-dashboards.js +537 -0
- package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/scripts/sf-rest.js +195 -0
- package/skills/consumer-goods-tpe-datakit-deploy/SKILL.md +157 -0
- package/skills/consumer-goods-tpe-datakit-deploy/scripts/detect-namespace.js +86 -0
- package/skills/consumer-goods-tpe-datakit-deploy/scripts/download-static-resource.js +151 -0
- package/skills/consumer-goods-tpe-datakit-deploy/scripts/extract-crm-field-permissions.js +115 -0
- package/skills/consumer-goods-tpe-datakit-deploy/scripts/sf-rest.js +109 -0
- package/skills/consumer-goods-tpe-datakit-deploy/scripts/update-field-permissions.js +433 -0
- package/skills/service-catalog-template-coordinate/SKILL.md +263 -0
- package/skills/service-catalog-template-coordinate/examples/output-templates.md +44 -0
- package/skills/service-catalog-template-coordinate/references/mcp-invocation.md +183 -0
- package/skills/service-catalog-template-coordinate/references/operations.md +230 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +243 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/cli-invocation.md +205 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/helper-contracts.md +236 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/references/permset-topology.md +132 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-activated-agents.mjs +106 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-agent-access-state.mjs +113 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-assignment-state.mjs +99 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/classify-platform-permset-availability.mjs +155 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/gate-unified-catalog-tiers.mjs +100 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/rank-candidate-users.mjs +95 -0
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/scripts/resolve-target-user.mjs +86 -0
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +44 -23
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +33 -9
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +17 -18
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +3 -1
- package/skills/service-itsm-agentic-setup-configure/SKILL.md +20 -12
- package/skills/service-itsm-agentic-setup-configure/examples/output-templates.md +73 -5
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +8 -7
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/cli-invocation.md +45 -33
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/reactivation.md +8 -6
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/workflow-detail.md +10 -10
- package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/classify-agent-existence.mjs +114 -56
- package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/classify-preflight.mjs +33 -17
- package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +9 -3
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +8 -7
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/cli-invocation.md +43 -32
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/reactivation.md +6 -4
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/workflow-detail.md +10 -10
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-agent-existence.mjs +106 -55
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-preflight.mjs +27 -13
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +9 -3
- package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +159 -161
- package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone-action.json +51 -0
- package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone.json +1 -1
- package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/predefined-incident-policy.json +120 -0
- package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/milestone-patterns.md +28 -5
- package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/output-templates.md +19 -1
- package/skills/service-itsm-agentic-setup-incident-sla-configure/references/mcp-invocation.md +350 -30
- package/skills/service-itsm-channels-coordinate/SKILL.md +80 -213
- package/skills/service-itsm-slack-itservice-configure/SKILL.md +363 -0
- package/skills/service-itsm-slack-itservice-configure/references/connect-agentforce-to-slack.md +159 -0
- package/skills/service-itsm-slack-itservice-configure/references/manage-slack-connection.md +88 -0
- package/skills/service-itsm-slack-itservice-configure/references/manage-user-access.md +117 -0
- package/skills/service-itsm-slack-itservice-configure/references/record-visibility.md +78 -0
- package/skills/service-itsm-slack-itservice-configure/references/site-membership-verification.md +126 -0
- package/skills/service-itsm-slack-itservice-configure/scripts/classify-user-access.mjs +167 -0
- package/skills/service-itsm-teams-configure/SKILL.md +50 -47
- package/skills/service-itsm-teams-configure/references/azure-credential-population.md +42 -28
- package/skills/service-itsm-teams-configure/references/gotchas.md +1 -2
- package/skills/service-itsm-teams-coordinate/SKILL.md +22 -18
- package/skills/service-itsm-teams-coordinate/examples/output-templates.md +12 -9
- package/skills/service-itsm-teams-itdesk-configure/SKILL.md +60 -44
- package/skills/service-itsm-teams-itservice-configure/SKILL.md +56 -70
- package/skills/service-catalog-template-deploy/SKILL.md +0 -310
- package/skills/service-catalog-template-deploy/references/cli-invocation.md +0 -258
- package/skills/service-catalog-template-deploy/scripts/activate-verify.mjs +0 -164
- package/skills/service-catalog-template-deploy/scripts/build-deploy-payload.mjs +0 -94
- package/skills/service-catalog-template-deploy/scripts/resolve-template.mjs +0 -331
- package/skills/service-catalog-template-search/SKILL.md +0 -212
- package/skills/service-catalog-template-search/references/cli-invocation.md +0 -128
- package/skills/service-catalog-template-search/scripts/classify-catalog.mjs +0 -205
package/skills/consumer-goods-tpe-dashboard-custom-kpi-configure/scripts/clone-tpe-dashboards.js
ADDED
|
@@ -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 (`<`/`>`) 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 `<[Parameters].[<label> Cust]>`
|
|
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
|
+
[`<[Parameters].[${p.baseParamLabel}]>`, `<[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
|
+
});
|