@salesforce/afv-skills 1.43.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/experience-cms-content-generate/SKILL.md +2 -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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Extracts the exact {sobject, fields[]} list the TPM Accruals Data Kit
|
|
4
|
+
* reads from Salesforce, straight out of the downloaded package's
|
|
5
|
+
* `dataSourceObjects` metadata — so the field-permissions grant step never
|
|
6
|
+
* has to guess or hardcode which objects/fields matter. Scans every
|
|
7
|
+
* `*.dataSourceObject-meta.xml` in the given directory, keeps only the ones
|
|
8
|
+
* with `<dataSource>Salesforce_Home</dataSource>` (the CRM streams; every
|
|
9
|
+
* other `dataSource` value in that folder is a Data Cloud-side or
|
|
10
|
+
* already-computed object, not a CRM read), and for each one collects
|
|
11
|
+
* `<externalName>` from every `<dataSourceFields>` block EXCEPT the
|
|
12
|
+
* `isFormula=true` ones (`DataSource`, `DataSourceObject` — computed
|
|
13
|
+
* literals baked into the package, never real Salesforce fields).
|
|
14
|
+
* `<externalName>` values are already namespace-resolved for the org this
|
|
15
|
+
* package was downloaded from — never re-prefix them.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately regex-based, not a full XML parser — this metadata format is
|
|
18
|
+
* simple/flat enough (no nested same-name tags, no CDATA) and this keeps the
|
|
19
|
+
* skill dependency-free per this repo's self-contained-skills rule.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node extract-crm-field-permissions.js --dir <path-to-dataSourceObjects>
|
|
23
|
+
*
|
|
24
|
+
* Prints one line of JSON to stdout: [{"sobject":"Account","fields":[...]}, ...]
|
|
25
|
+
* Exit codes: 0 = success (including a directory with zero Salesforce_Home
|
|
26
|
+
* files — an empty array is printed), 1 = usage/read error.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const args = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
35
|
+
const token = argv[i];
|
|
36
|
+
if (!token.startsWith('--')) continue;
|
|
37
|
+
args[token.slice(2)] = argv[i + 1];
|
|
38
|
+
i += 1;
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireArg(args, name) {
|
|
44
|
+
if (!args[name]) {
|
|
45
|
+
console.error(`Missing required --${name}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return args[name];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unescapeXml(value) {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/'/g, "'")
|
|
54
|
+
.replace(/"/g, '"')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/&/g, '&');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractTag(xml, tagName) {
|
|
61
|
+
const match = new RegExp(`<${tagName}>([^<]*)</${tagName}>`).exec(xml);
|
|
62
|
+
return match ? unescapeXml(match[1]) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseDataSourceObjectXml(xml) {
|
|
66
|
+
const dataSource = extractTag(xml, 'dataSource');
|
|
67
|
+
const sobject = extractTag(xml, 'externalRecordIdentifier');
|
|
68
|
+
const fields = [];
|
|
69
|
+
const fieldBlockRe = /<dataSourceFields>([\s\S]*?)<\/dataSourceFields>/g;
|
|
70
|
+
let blockMatch;
|
|
71
|
+
while ((blockMatch = fieldBlockRe.exec(xml))) {
|
|
72
|
+
const block = blockMatch[1];
|
|
73
|
+
const isFormula = extractTag(block, 'isFormula') === 'true';
|
|
74
|
+
const externalName = extractTag(block, 'externalName');
|
|
75
|
+
if (!isFormula && externalName) {
|
|
76
|
+
fields.push(externalName);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { dataSource, sobject, fields };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function main() {
|
|
83
|
+
const args = parseArgs(process.argv.slice(2));
|
|
84
|
+
const dir = requireArg(args, 'dir');
|
|
85
|
+
|
|
86
|
+
let files;
|
|
87
|
+
try {
|
|
88
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.dataSourceObject-meta.xml'));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`extract-crm-field-permissions: could not read --dir "${dir}" — ${error.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const bySobject = new Map();
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const xml = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
97
|
+
const { dataSource, sobject, fields } = parseDataSourceObjectXml(xml);
|
|
98
|
+
if (dataSource !== 'Salesforce_Home' || !sobject || !fields.length) continue;
|
|
99
|
+
const existing = bySobject.get(sobject) || new Set();
|
|
100
|
+
fields.forEach((f) => existing.add(f));
|
|
101
|
+
bySobject.set(sobject, existing);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = Array.from(bySobject.entries()).map(([sobject, fields]) => ({
|
|
105
|
+
sobject,
|
|
106
|
+
fields: Array.from(fields).sort(),
|
|
107
|
+
}));
|
|
108
|
+
process.stdout.write(JSON.stringify(result));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (require.main === module) {
|
|
112
|
+
main();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { parseDataSourceObjectXml };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `sf` CLI helpers for this skill's scripts: running `sf ... --json`
|
|
3
|
+
* commands and resolving an org's access token.
|
|
4
|
+
*
|
|
5
|
+
* `sf org display --json`'s `result.accessToken` stopped being a real token
|
|
6
|
+
* as of CLI v2.136.8 — that field becomes a redacted hint string pointing at
|
|
7
|
+
* `sf org auth show-access-token` instead. `getAccessToken()` is version-gated
|
|
8
|
+
* to handle both cases. The token is never printed.
|
|
9
|
+
*
|
|
10
|
+
* Also runnable directly for two subcommands that print only safe,
|
|
11
|
+
* non-secret fields from `sf org display --json` — that command's `result`
|
|
12
|
+
* includes `accessToken`, so nothing in this skill should run it directly
|
|
13
|
+
* and surface the raw output; these strip it before printing:
|
|
14
|
+
* node sf-rest.js org-status --target-org <username>
|
|
15
|
+
* -> {"username","alias","connectedStatus","orgId"} ; exit 1 if not Connected
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const ACCESS_TOKEN_VIA_SHOW_COMMAND_SINCE = [2, 136, 8];
|
|
21
|
+
|
|
22
|
+
function sfJson(cliArgs) {
|
|
23
|
+
const raw = execFileSync('sf', cliArgs, { encoding: 'utf8' });
|
|
24
|
+
return JSON.parse(raw).result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getCliVersion() {
|
|
28
|
+
// Unlike every other `sf ... --json` command, `sf version --json` returns
|
|
29
|
+
// its fields at the top level — no `{status, result}` envelope — so this
|
|
30
|
+
// can't go through `sfJson`.
|
|
31
|
+
const raw = execFileSync('sf', ['version', '--json'], { encoding: 'utf8' });
|
|
32
|
+
const { cliVersion } = JSON.parse(raw);
|
|
33
|
+
const match = cliVersion.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
34
|
+
// A pre-release/suffixed version string (e.g. a nightly build) that
|
|
35
|
+
// doesn't match this pattern is treated as "new enough" below, via
|
|
36
|
+
// isAtLeast's null passthrough — never silently fall back to the
|
|
37
|
+
// redacted-token path on an unparseable version.
|
|
38
|
+
return match ? match.slice(1).map(Number) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isAtLeast(version, minVersion) {
|
|
42
|
+
if (!version) return true;
|
|
43
|
+
for (let i = 0; i < minVersion.length; i += 1) {
|
|
44
|
+
const a = version[i] || 0;
|
|
45
|
+
const b = minVersion[i] || 0;
|
|
46
|
+
if (a !== b) return a > b;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getAccessToken(targetOrg) {
|
|
52
|
+
if (isAtLeast(getCliVersion(), ACCESS_TOKEN_VIA_SHOW_COMMAND_SINCE)) {
|
|
53
|
+
return sfJson([
|
|
54
|
+
'org', 'auth', 'show-access-token',
|
|
55
|
+
'--target-org', targetOrg, '--no-prompt', '--json',
|
|
56
|
+
]).accessToken;
|
|
57
|
+
}
|
|
58
|
+
return sfJson(['org', 'display', '--target-org', targetOrg, '--json']).accessToken;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getOrgStatus(targetOrg) {
|
|
62
|
+
const info = sfJson(['org', 'display', '--target-org', targetOrg, '--json']);
|
|
63
|
+
return {
|
|
64
|
+
username: info.username,
|
|
65
|
+
alias: info.alias || null,
|
|
66
|
+
connectedStatus: info.connectedStatus,
|
|
67
|
+
orgId: info.id,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseArgs(argv) {
|
|
72
|
+
const args = {};
|
|
73
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
74
|
+
const token = argv[i];
|
|
75
|
+
if (!token.startsWith('--')) continue;
|
|
76
|
+
args[token.slice(2)] = argv[i + 1];
|
|
77
|
+
i += 1;
|
|
78
|
+
}
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireArg(args, name) {
|
|
83
|
+
if (!args[name]) {
|
|
84
|
+
console.error(`Missing required --${name}`);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
return args[name];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function main() {
|
|
91
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
92
|
+
const args = parseArgs(rest);
|
|
93
|
+
const targetOrg = requireArg(args, 'target-org');
|
|
94
|
+
|
|
95
|
+
if (command === 'org-status') {
|
|
96
|
+
const status = getOrgStatus(targetOrg);
|
|
97
|
+
process.stdout.write(JSON.stringify(status));
|
|
98
|
+
if (status.connectedStatus !== 'Connected') process.exit(1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.error(`Unknown command: ${command}. Use "org-status"`);
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (require.main === module) {
|
|
106
|
+
main();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { sfJson, getCliVersion, isAtLeast, getAccessToken, getOrgStatus };
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Grants FieldPermissions rows, and object-level ObjectPermissions
|
|
4
|
+
* (PermissionsRead + PermissionsViewAllRecords), on a permission set for the
|
|
5
|
+
* {sobject, fields[]} list produced by extract-crm-field-permissions.js.
|
|
6
|
+
* Add-only by design at both levels: an existing row/flag already granting
|
|
7
|
+
* access is left untouched, and this script never deletes a row or flips a
|
|
8
|
+
* granted boolean back to false — the only operations are "insert a new
|
|
9
|
+
* row" (nothing granted yet) and "flip an existing false flag to true"
|
|
10
|
+
* (row exists but the flag is explicitly off). Neither operation ever
|
|
11
|
+
* removes access, and object-level writes never touch PermissionsCreate/
|
|
12
|
+
* Edit/Delete/ModifyAllRecords — only Read and ViewAllRecords are ever set,
|
|
13
|
+
* and only ever to true. The caller (this skill's Phase 2) is responsible
|
|
14
|
+
* for getting the user's explicit go-ahead before invoking this without
|
|
15
|
+
* --dry-run — this script performs whatever it's told, same as every other
|
|
16
|
+
* write helper in this skill.
|
|
17
|
+
*
|
|
18
|
+
* Reads (permission set lookup, existing FieldPermissions/ObjectPermissions)
|
|
19
|
+
* go through `sf data query`, consistent with the rest of this skill.
|
|
20
|
+
* Writes go through the sObject Collections API (POST/PATCH
|
|
21
|
+
* .../composite/sobjects, batches of 200) instead of one `sf data create
|
|
22
|
+
* record` process per field — dozens of fields across a dozen-plus objects
|
|
23
|
+
* would otherwise mean dozens of CLI spawns. The org's own `sf`-authenticated
|
|
24
|
+
* access token drives every call, never a hardcoded credential, and it is
|
|
25
|
+
* never printed.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node update-field-permissions.js \
|
|
29
|
+
* --target-org <username> \
|
|
30
|
+
* --input <path-to-extract-output.json | -> \
|
|
31
|
+
* [--permission-set-name sfdc_a360_sfcrm_data_extract] \
|
|
32
|
+
* [--dry-run]
|
|
33
|
+
*
|
|
34
|
+
* --input accepts a file path or "-" to read the extract script's JSON
|
|
35
|
+
* array (`[{sobject, fields[]}, ...]`) from stdin.
|
|
36
|
+
*
|
|
37
|
+
* Before diffing fields, every field is checked against a live
|
|
38
|
+
* `sobject/describe` call for its object: fields absent from the object
|
|
39
|
+
* entirely, or present but not FLS-controllable (master-detail/required
|
|
40
|
+
* lookups, standard system fields like Id/Name/SystemModstamp — describe's
|
|
41
|
+
* own `permissionable: false`), are filtered out into `notFlsEligible`
|
|
42
|
+
* rather than attempted — no hardcoded field-name guessing. Object-level
|
|
43
|
+
* permissions have no such eligibility gate — every sobject in the input is
|
|
44
|
+
* a real object the data kit reads, so Read + ViewAllRecords are always
|
|
45
|
+
* in scope for it.
|
|
46
|
+
*
|
|
47
|
+
* Prints one line of JSON to stdout:
|
|
48
|
+
* {
|
|
49
|
+
* permissionSetId, permissionSetLabel, dryRun,
|
|
50
|
+
* alreadyGranted: [{sobject, field}],
|
|
51
|
+
* notFlsEligible: [{sobject, field, onObject}],
|
|
52
|
+
* granted / wouldGrant: [{sobject, field}],
|
|
53
|
+
* failed: [{sobject, field, error}],
|
|
54
|
+
* objectAlreadyGranted: [{sobject, permissionsRead, permissionsViewAllRecords}],
|
|
55
|
+
* objectGranted / objectWouldGrant: [{sobject, permissionsRead, permissionsViewAllRecords}],
|
|
56
|
+
* objectFailed: [{sobject, error}]
|
|
57
|
+
* }
|
|
58
|
+
* `failed`/`objectFailed` are exactly the manual-Setup punch list to hand
|
|
59
|
+
* back to the user when a grant can't be automated. `notFlsEligible` entries
|
|
60
|
+
* with `onObject: false` mean the package expects a field this org's object
|
|
61
|
+
* doesn't actually have — a package/org data mismatch, not something to
|
|
62
|
+
* grant manually. A small set of sobjects (currently just Product2 — see
|
|
63
|
+
* OBJECT_VIEWALL_INELIGIBLE) never get PermissionsViewAllRecords requested
|
|
64
|
+
* at all, because the org's own API rejects that specific flag with a
|
|
65
|
+
* license-constraint error; PermissionsRead is still granted normally for
|
|
66
|
+
* them. This is a known, permanent platform restriction, not a per-run
|
|
67
|
+
* failure — it's applied silently and surfaces as an ordinary
|
|
68
|
+
* `objectGranted`/`objectAlreadyGranted` entry with `permissionsViewAllRecords: false`,
|
|
69
|
+
* never as `objectFailed` and never something to ask the user about.
|
|
70
|
+
*
|
|
71
|
+
* Exit codes: 0 = no failures (includes dry-run and nothing-to-grant),
|
|
72
|
+
* 1 = permission set not found or bad input, 3 = one or more fields or
|
|
73
|
+
* objects failed to grant.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
const fs = require('fs');
|
|
77
|
+
const { sfJson, getAccessToken } = require('./sf-rest');
|
|
78
|
+
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const args = {};
|
|
81
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
82
|
+
const token = argv[i];
|
|
83
|
+
if (token === '--dry-run') {
|
|
84
|
+
args['dry-run'] = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!token.startsWith('--')) continue;
|
|
88
|
+
args[token.slice(2)] = argv[i + 1];
|
|
89
|
+
i += 1;
|
|
90
|
+
}
|
|
91
|
+
return args;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requireArg(args, name) {
|
|
95
|
+
if (!args[name]) {
|
|
96
|
+
console.error(`Missing required --${name}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
return args[name];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readInput(inputArg) {
|
|
103
|
+
const raw = inputArg === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(inputArg, 'utf8');
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(raw);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(`update-field-permissions: --input is not valid JSON — ${error.message}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(parsed)) {
|
|
112
|
+
console.error('update-field-permissions: --input must be a JSON array of {sobject, fields[]}');
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function soqlQuote(value) {
|
|
119
|
+
// Escape backslashes before quotes — otherwise a value ending in `\`
|
|
120
|
+
// combines with the following escaped quote to unescape itself.
|
|
121
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Observed live: this org's license rejects any ObjectPermissions write that
|
|
125
|
+
// sets PermissionsViewAllRecords=true on Product2 —
|
|
126
|
+
// "c2c.integrationconstraints.LicenselessIntegrationPermConstrainer does not
|
|
127
|
+
// allow type=permission, label= View All Product2". Not a guess; a
|
|
128
|
+
// licenseless-integration-user platform restriction confirmed by the API's
|
|
129
|
+
// own error. PermissionsRead is unaffected and still granted normally —
|
|
130
|
+
// only ViewAllRecords is never requested for these sobjects, silently.
|
|
131
|
+
const OBJECT_VIEWALL_INELIGIBLE = new Set(['Product2']);
|
|
132
|
+
|
|
133
|
+
function chunk(list, size) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function describeSobject(instanceUrl, apiVersion, accessToken, sobject) {
|
|
140
|
+
const response = await fetch(`${instanceUrl}/services/data/v${apiVersion}/sobjects/${sobject}/describe`, {
|
|
141
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const text = await response.text();
|
|
145
|
+
throw new Error(`describe ${sobject} failed — HTTP ${response.status}: ${text}`);
|
|
146
|
+
}
|
|
147
|
+
const { fields } = await response.json();
|
|
148
|
+
return new Map(fields.map((f) => [f.name, f.permissionable]));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function postCollection(instanceUrl, apiVersion, accessToken, method, records) {
|
|
152
|
+
const response = await fetch(`${instanceUrl}/services/data/v${apiVersion}/composite/sobjects`, {
|
|
153
|
+
method,
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${accessToken}`,
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const text = await response.text();
|
|
162
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
163
|
+
}
|
|
164
|
+
return response.json();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
const args = parseArgs(process.argv.slice(2));
|
|
169
|
+
const targetOrg = requireArg(args, 'target-org');
|
|
170
|
+
const inputArg = requireArg(args, 'input');
|
|
171
|
+
const permissionSetName = args['permission-set-name'] || 'sfdc_a360_sfcrm_data_extract';
|
|
172
|
+
const dryRun = Boolean(args['dry-run']);
|
|
173
|
+
|
|
174
|
+
const entries = readInput(inputArg).filter(
|
|
175
|
+
(e) => e && e.sobject && Array.isArray(e.fields) && e.fields.length
|
|
176
|
+
);
|
|
177
|
+
if (!entries.length) {
|
|
178
|
+
process.stdout.write(JSON.stringify({ error: 'no_fields_in_input' }));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const psResult = sfJson([
|
|
183
|
+
'data', 'query', '--target-org', targetOrg, '--json',
|
|
184
|
+
'--query', `SELECT Id, Label FROM PermissionSet WHERE Name = '${soqlQuote(permissionSetName)}'`,
|
|
185
|
+
]);
|
|
186
|
+
if (!psResult.records || psResult.records.length < 1) {
|
|
187
|
+
process.stdout.write(JSON.stringify({ error: 'permission_set_not_found', permissionSetName }));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const permissionSetId = psResult.records[0].Id;
|
|
191
|
+
const permissionSetLabel = psResult.records[0].Label;
|
|
192
|
+
|
|
193
|
+
const sobjectList = entries.map((e) => `'${soqlQuote(e.sobject)}'`).join(',');
|
|
194
|
+
const existingResult = sfJson([
|
|
195
|
+
'data', 'query', '--target-org', targetOrg, '--json',
|
|
196
|
+
'--query',
|
|
197
|
+
`SELECT Id, Field, SobjectType, PermissionsRead FROM FieldPermissions WHERE ParentId = '${permissionSetId}' AND SobjectType IN (${sobjectList})`,
|
|
198
|
+
]);
|
|
199
|
+
const existingByField = new Map(
|
|
200
|
+
(existingResult.records || []).map((r) => [r.Field, r])
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const existingObjectResult = sfJson([
|
|
204
|
+
'data', 'query', '--target-org', targetOrg, '--json',
|
|
205
|
+
'--query',
|
|
206
|
+
`SELECT Id, SobjectType, PermissionsRead, PermissionsViewAllRecords FROM ObjectPermissions WHERE ParentId = '${permissionSetId}' AND SobjectType IN (${sobjectList})`,
|
|
207
|
+
]);
|
|
208
|
+
const existingObjectBySobject = new Map(
|
|
209
|
+
(existingObjectResult.records || []).map((r) => [r.SobjectType, r])
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const orgDetails = sfJson(['org', 'display', '--target-org', targetOrg, '--json']);
|
|
213
|
+
const instanceUrl = orgDetails.instanceUrl;
|
|
214
|
+
const apiVersion = orgDetails.apiVersion || '60.0';
|
|
215
|
+
const accessToken = getAccessToken(targetOrg);
|
|
216
|
+
|
|
217
|
+
const sobjectNames = [...new Set(entries.map((e) => e.sobject))];
|
|
218
|
+
const describeResults = await Promise.all(
|
|
219
|
+
sobjectNames.map((sobject) => describeSobject(instanceUrl, apiVersion, accessToken, sobject))
|
|
220
|
+
);
|
|
221
|
+
const describeBySobject = new Map(sobjectNames.map((sobject, i) => [sobject, describeResults[i]]));
|
|
222
|
+
|
|
223
|
+
const alreadyGranted = [];
|
|
224
|
+
const toInsert = [];
|
|
225
|
+
const toUpdate = [];
|
|
226
|
+
const notFlsEligible = [];
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const describeFields = describeBySobject.get(entry.sobject);
|
|
229
|
+
for (const field of entry.fields) {
|
|
230
|
+
if (!describeFields.has(field) || describeFields.get(field) !== true) {
|
|
231
|
+
// Not on the object at all, or present but not FLS-controllable
|
|
232
|
+
// (master-detail/required lookup, standard system field, etc.) —
|
|
233
|
+
// confirmed live from this org's own sobject describe.
|
|
234
|
+
notFlsEligible.push({ sobject: entry.sobject, field, onObject: describeFields.has(field) });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const fieldApiName = `${entry.sobject}.${field}`;
|
|
238
|
+
const existing = existingByField.get(fieldApiName);
|
|
239
|
+
if (existing && existing.PermissionsRead) {
|
|
240
|
+
alreadyGranted.push({ sobject: entry.sobject, field });
|
|
241
|
+
} else if (existing) {
|
|
242
|
+
// Row exists but Read is explicitly off — flip it on, never off.
|
|
243
|
+
toUpdate.push({ sobject: entry.sobject, field, fieldApiName, id: existing.Id });
|
|
244
|
+
} else {
|
|
245
|
+
toInsert.push({ sobject: entry.sobject, field, fieldApiName });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const missing = [...toInsert, ...toUpdate];
|
|
250
|
+
|
|
251
|
+
const objectAlreadyGranted = [];
|
|
252
|
+
const toInsertObject = [];
|
|
253
|
+
const toUpdateObject = [];
|
|
254
|
+
for (const sobject of new Set(entries.map((e) => e.sobject))) {
|
|
255
|
+
const viewAllEligible = !OBJECT_VIEWALL_INELIGIBLE.has(sobject);
|
|
256
|
+
const existing = existingObjectBySobject.get(sobject);
|
|
257
|
+
if (!existing) {
|
|
258
|
+
toInsertObject.push({ sobject, permissionsRead: true, permissionsViewAllRecords: viewAllEligible });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const needsRead = !existing.PermissionsRead;
|
|
262
|
+
const needsViewAll = viewAllEligible && !existing.PermissionsViewAllRecords;
|
|
263
|
+
if (!needsRead && !needsViewAll) {
|
|
264
|
+
objectAlreadyGranted.push({
|
|
265
|
+
sobject,
|
|
266
|
+
permissionsRead: existing.PermissionsRead,
|
|
267
|
+
permissionsViewAllRecords: viewAllEligible ? existing.PermissionsViewAllRecords : false,
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
// Row exists but Read and/or ViewAllRecords is explicitly off —
|
|
271
|
+
// flip only the off flag(s) on, never off, and never touch
|
|
272
|
+
// Create/Edit/Delete/ModifyAllRecords.
|
|
273
|
+
toUpdateObject.push({
|
|
274
|
+
sobject,
|
|
275
|
+
id: existing.Id,
|
|
276
|
+
permissionsRead: needsRead ? true : undefined,
|
|
277
|
+
permissionsViewAllRecords: needsViewAll ? true : undefined,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const missingObject = [...toInsertObject, ...toUpdateObject];
|
|
282
|
+
|
|
283
|
+
if (dryRun || (missing.length === 0 && missingObject.length === 0)) {
|
|
284
|
+
process.stdout.write(JSON.stringify({
|
|
285
|
+
permissionSetId,
|
|
286
|
+
permissionSetLabel,
|
|
287
|
+
dryRun,
|
|
288
|
+
alreadyGranted,
|
|
289
|
+
notFlsEligible,
|
|
290
|
+
wouldGrant: missing.map(({ sobject, field }) => ({ sobject, field })),
|
|
291
|
+
granted: [],
|
|
292
|
+
failed: [],
|
|
293
|
+
objectAlreadyGranted,
|
|
294
|
+
objectWouldGrant: missingObject.map(({ sobject, permissionsRead, permissionsViewAllRecords }) => ({
|
|
295
|
+
sobject,
|
|
296
|
+
permissionsRead: Boolean(permissionsRead),
|
|
297
|
+
permissionsViewAllRecords: Boolean(permissionsViewAllRecords),
|
|
298
|
+
})),
|
|
299
|
+
objectGranted: [],
|
|
300
|
+
objectFailed: [],
|
|
301
|
+
}));
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const granted = [];
|
|
306
|
+
const failed = [];
|
|
307
|
+
|
|
308
|
+
for (const batch of chunk(toInsert, 200)) {
|
|
309
|
+
const records = batch.map((item) => ({
|
|
310
|
+
attributes: { type: 'FieldPermissions' },
|
|
311
|
+
ParentId: permissionSetId,
|
|
312
|
+
SobjectType: item.sobject,
|
|
313
|
+
Field: item.fieldApiName,
|
|
314
|
+
PermissionsRead: true,
|
|
315
|
+
}));
|
|
316
|
+
try {
|
|
317
|
+
const results = await postCollection(instanceUrl, apiVersion, accessToken, 'POST', records);
|
|
318
|
+
results.forEach((result, idx) => {
|
|
319
|
+
const item = batch[idx];
|
|
320
|
+
if (result.success) {
|
|
321
|
+
granted.push({ sobject: item.sobject, field: item.field });
|
|
322
|
+
} else {
|
|
323
|
+
const error = (result.errors || []).map((e) => e.message).join('; ') || 'unknown error';
|
|
324
|
+
failed.push({ sobject: item.sobject, field: item.field, error });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
batch.forEach((item) => failed.push({ sobject: item.sobject, field: item.field, error: error.message }));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const batch of chunk(toUpdate, 200)) {
|
|
333
|
+
const records = batch.map((item) => ({
|
|
334
|
+
attributes: { type: 'FieldPermissions' },
|
|
335
|
+
Id: item.id,
|
|
336
|
+
PermissionsRead: true,
|
|
337
|
+
}));
|
|
338
|
+
try {
|
|
339
|
+
const results = await postCollection(instanceUrl, apiVersion, accessToken, 'PATCH', records);
|
|
340
|
+
results.forEach((result, idx) => {
|
|
341
|
+
const item = batch[idx];
|
|
342
|
+
if (result.success) {
|
|
343
|
+
granted.push({ sobject: item.sobject, field: item.field });
|
|
344
|
+
} else {
|
|
345
|
+
const error = (result.errors || []).map((e) => e.message).join('; ') || 'unknown error';
|
|
346
|
+
failed.push({ sobject: item.sobject, field: item.field, error });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
batch.forEach((item) => failed.push({ sobject: item.sobject, field: item.field, error: error.message }));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const objectGranted = [];
|
|
355
|
+
const objectFailed = [];
|
|
356
|
+
|
|
357
|
+
for (const batch of chunk(toInsertObject, 200)) {
|
|
358
|
+
const records = batch.map((item) => {
|
|
359
|
+
const record = {
|
|
360
|
+
attributes: { type: 'ObjectPermissions' },
|
|
361
|
+
ParentId: permissionSetId,
|
|
362
|
+
SobjectType: item.sobject,
|
|
363
|
+
PermissionsRead: true,
|
|
364
|
+
PermissionsCreate: false,
|
|
365
|
+
PermissionsEdit: false,
|
|
366
|
+
PermissionsDelete: false,
|
|
367
|
+
PermissionsModifyAllRecords: false,
|
|
368
|
+
};
|
|
369
|
+
if (item.permissionsViewAllRecords) record.PermissionsViewAllRecords = true;
|
|
370
|
+
return record;
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const results = await postCollection(instanceUrl, apiVersion, accessToken, 'POST', records);
|
|
374
|
+
results.forEach((result, idx) => {
|
|
375
|
+
const item = batch[idx];
|
|
376
|
+
if (result.success) {
|
|
377
|
+
objectGranted.push({ sobject: item.sobject, permissionsRead: true, permissionsViewAllRecords: Boolean(item.permissionsViewAllRecords) });
|
|
378
|
+
} else {
|
|
379
|
+
const error = (result.errors || []).map((e) => e.message).join('; ') || 'unknown error';
|
|
380
|
+
objectFailed.push({ sobject: item.sobject, error });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
} catch (error) {
|
|
384
|
+
batch.forEach((item) => objectFailed.push({ sobject: item.sobject, error: error.message }));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const batch of chunk(toUpdateObject, 200)) {
|
|
389
|
+
const records = batch.map((item) => {
|
|
390
|
+
const record = { attributes: { type: 'ObjectPermissions' }, Id: item.id };
|
|
391
|
+
if (item.permissionsRead) record.PermissionsRead = true;
|
|
392
|
+
if (item.permissionsViewAllRecords) record.PermissionsViewAllRecords = true;
|
|
393
|
+
return record;
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
const results = await postCollection(instanceUrl, apiVersion, accessToken, 'PATCH', records);
|
|
397
|
+
results.forEach((result, idx) => {
|
|
398
|
+
const item = batch[idx];
|
|
399
|
+
if (result.success) {
|
|
400
|
+
objectGranted.push({
|
|
401
|
+
sobject: item.sobject,
|
|
402
|
+
permissionsRead: Boolean(item.permissionsRead),
|
|
403
|
+
permissionsViewAllRecords: Boolean(item.permissionsViewAllRecords),
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
const error = (result.errors || []).map((e) => e.message).join('; ') || 'unknown error';
|
|
407
|
+
objectFailed.push({ sobject: item.sobject, error });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
batch.forEach((item) => objectFailed.push({ sobject: item.sobject, error: error.message }));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
process.stdout.write(JSON.stringify({
|
|
416
|
+
permissionSetId,
|
|
417
|
+
permissionSetLabel,
|
|
418
|
+
dryRun: false,
|
|
419
|
+
alreadyGranted,
|
|
420
|
+
notFlsEligible,
|
|
421
|
+
granted,
|
|
422
|
+
failed,
|
|
423
|
+
objectAlreadyGranted,
|
|
424
|
+
objectGranted,
|
|
425
|
+
objectFailed,
|
|
426
|
+
}));
|
|
427
|
+
process.exit(failed.length || objectFailed.length ? 3 : 0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
main().catch((err) => {
|
|
431
|
+
console.error(err);
|
|
432
|
+
process.exit(1);
|
|
433
|
+
});
|