@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic classifier for the ACTIVATED agents a user needs access to.
|
|
3
|
+
//
|
|
4
|
+
// Reads ONE input:
|
|
5
|
+
// - BotDefinition query capture (from `sf data query --json`) shaped as:
|
|
6
|
+
// SELECT Id, DeveloperName, MasterLabel,
|
|
7
|
+
// (SELECT Status FROM BotVersions WHERE Status='Active')
|
|
8
|
+
// FROM BotDefinition WHERE Type='InternalCopilot'
|
|
9
|
+
// Every NGA / Agentforce agent is a BotDefinition with Type='InternalCopilot';
|
|
10
|
+
// an agent is ACTIVATED iff it has at least one BotVersion with Status='Active'
|
|
11
|
+
// (the child subquery returns ≥1 record).
|
|
12
|
+
//
|
|
13
|
+
// The Agent Access custom permset should grant access ONLY to activated agents,
|
|
14
|
+
// so this classifier separates activated from inactive and hands the caller the
|
|
15
|
+
// activated set to present for a multi-select.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node classify-activated-agents.mjs <bot-definitions.json>
|
|
19
|
+
//
|
|
20
|
+
// Emits a single JSON object to stdout:
|
|
21
|
+
// { agents: [ { Id, DeveloperName, MasterLabel, active } ],
|
|
22
|
+
// activatedAgents: [ { Id, DeveloperName, MasterLabel } ],
|
|
23
|
+
// inactiveAgents: [ { Id, DeveloperName, MasterLabel } ],
|
|
24
|
+
// verdict: "AGENTS-FOUND" | "NONE-ACTIVE" | "CANNOT-CONFIRM",
|
|
25
|
+
// reasons: [...] }
|
|
26
|
+
// Exit is always 0 on parseable bodies; verdict carries the decision. Exit 2 on
|
|
27
|
+
// missing argv.
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
|
|
31
|
+
function readEnvelope(path) {
|
|
32
|
+
try {
|
|
33
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
34
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
35
|
+
const data = JSON.parse(text);
|
|
36
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `non-object JSON at ${path}` };
|
|
37
|
+
if (typeof data.status === 'number' && data.status !== 0) {
|
|
38
|
+
return { ok: false, reason: `sf data query at ${path} failed with status ${data.status}` };
|
|
39
|
+
}
|
|
40
|
+
const records = Array.isArray(data.result?.records) ? data.result.records : null;
|
|
41
|
+
if (!records) return { ok: false, reason: `missing result.records[] at ${path}` };
|
|
42
|
+
return { ok: true, records };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [botPath] = process.argv.slice(2);
|
|
49
|
+
if (!botPath) {
|
|
50
|
+
process.stderr.write('usage: node classify-activated-agents.mjs <bot-definitions.json>\n');
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bots = readEnvelope(botPath);
|
|
55
|
+
if (!bots.ok) {
|
|
56
|
+
process.stdout.write(JSON.stringify({
|
|
57
|
+
agents: [], activatedAgents: [], inactiveAgents: [],
|
|
58
|
+
verdict: 'CANNOT-CONFIRM',
|
|
59
|
+
reasons: [bots.reason],
|
|
60
|
+
}, null, 2) + '\n');
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A child-relationship subquery surfaces as a nested { totalSize, records:[...] }
|
|
65
|
+
// object, or null when there are no matching children.
|
|
66
|
+
function activeVersionCount(r) {
|
|
67
|
+
const bv = r?.BotVersions;
|
|
68
|
+
if (!bv) return 0;
|
|
69
|
+
if (Array.isArray(bv.records)) return bv.records.length;
|
|
70
|
+
if (typeof bv.totalSize === 'number') return bv.totalSize;
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const agents = bots.records
|
|
75
|
+
.filter((r) => r && typeof r.Id === 'string')
|
|
76
|
+
.map((r) => ({
|
|
77
|
+
Id: r.Id,
|
|
78
|
+
DeveloperName: r.DeveloperName ?? null,
|
|
79
|
+
MasterLabel: r.MasterLabel ?? null,
|
|
80
|
+
active: activeVersionCount(r) > 0,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const activatedAgents = agents.filter((a) => a.active).map(({ active, ...rest }) => rest);
|
|
84
|
+
const inactiveAgents = agents.filter((a) => !a.active).map(({ active, ...rest }) => rest);
|
|
85
|
+
|
|
86
|
+
const reasons = [];
|
|
87
|
+
let verdict;
|
|
88
|
+
if (activatedAgents.length > 0) {
|
|
89
|
+
verdict = 'AGENTS-FOUND';
|
|
90
|
+
reasons.push(`Found ${activatedAgents.length} activated agent(s): ${activatedAgents.map((a) => a.MasterLabel ?? a.DeveloperName).join(', ')}.`);
|
|
91
|
+
if (inactiveAgents.length > 0) {
|
|
92
|
+
reasons.push(`Excluded ${inactiveAgents.length} agent(s) with no active version (not activated yet): ${inactiveAgents.map((a) => a.MasterLabel ?? a.DeveloperName).join(', ')}.`);
|
|
93
|
+
}
|
|
94
|
+
reasons.push('Present the activated agents for a multi-select — the Agent Access permset gets one SetupEntityAccess grant per chosen agent.');
|
|
95
|
+
} else {
|
|
96
|
+
verdict = 'NONE-ACTIVE';
|
|
97
|
+
if (agents.length === 0) {
|
|
98
|
+
reasons.push('No InternalCopilot agents exist on this org — there is nothing for the Agent Access permset to grant. Create + activate an agent first.');
|
|
99
|
+
} else {
|
|
100
|
+
reasons.push(`${agents.length} InternalCopilot agent(s) exist but none has an active version — activate an agent before granting access.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.stdout.write(JSON.stringify({
|
|
105
|
+
agents, activatedAgents, inactiveAgents, verdict, reasons,
|
|
106
|
+
}, null, 2) + '\n');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic idempotency classifier for the custom `Agent_Access` permission
|
|
3
|
+
// set and its per-agent SetupEntityAccess grants.
|
|
4
|
+
//
|
|
5
|
+
// The Agent Access concern has two writes that must be idempotent:
|
|
6
|
+
// 1. Create the `Agent_Access` PermissionSet (Name=Agent_Access,
|
|
7
|
+
// Label="Agent Access") — only if it does not already exist.
|
|
8
|
+
// 2. For each CHOSEN activated agent, add a SetupEntityAccess row
|
|
9
|
+
// { ParentId: <Agent_Access permset Id>, SetupEntityId: <BotDefinition Id> }
|
|
10
|
+
// — only if a grant for that agent is not already present.
|
|
11
|
+
// (SetupEntityType is NOT sent in the POST body — it is not createable and
|
|
12
|
+
// is derived from the 0Xx key prefix; it IS filterable on the read.)
|
|
13
|
+
//
|
|
14
|
+
// Reads:
|
|
15
|
+
// 1. `agent-access-permset.json` — `sf data query --json` capture of
|
|
16
|
+
// SELECT Id, Name, Label FROM PermissionSet WHERE Name='Agent_Access'
|
|
17
|
+
// 2. `sea-existing.json` OR the literal sentinel `NO-PERMSET`:
|
|
18
|
+
// - permset present ⇒ pass the capture of
|
|
19
|
+
// SELECT SetupEntityId FROM SetupEntityAccess
|
|
20
|
+
// WHERE ParentId='<permsetId>' AND SetupEntityType='BotDefinition'
|
|
21
|
+
// - permset absent ⇒ pass `NO-PERMSET` (there is no parent to query yet).
|
|
22
|
+
// 3. `chosenAgentIds-csv` — comma-separated BotDefinition Ids the user selected.
|
|
23
|
+
//
|
|
24
|
+
// Usage:
|
|
25
|
+
// node classify-agent-access-state.mjs <agent-access-permset.json> <sea-existing.json | NO-PERMSET> <chosenAgentIds-csv>
|
|
26
|
+
//
|
|
27
|
+
// Emits a single JSON object to stdout:
|
|
28
|
+
// { permsetExists, permsetId, chosenAgentIds, grantedAgentIds, missingAgentIds,
|
|
29
|
+
// needsCreate, needsGrants,
|
|
30
|
+
// verdict: "NEEDS-WORK" | "ALREADY-COMPLETE" | "CANNOT-CONFIRM",
|
|
31
|
+
// reasons: [...] }
|
|
32
|
+
// Exit is always 0 on parseable bodies; verdict carries the decision. Exit 2 on
|
|
33
|
+
// missing argv.
|
|
34
|
+
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
|
|
37
|
+
function readEnvelope(path) {
|
|
38
|
+
try {
|
|
39
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
40
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
41
|
+
const data = JSON.parse(text);
|
|
42
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `non-object JSON at ${path}` };
|
|
43
|
+
if (typeof data.status === 'number' && data.status !== 0) {
|
|
44
|
+
return { ok: false, reason: `sf data query at ${path} failed with status ${data.status}` };
|
|
45
|
+
}
|
|
46
|
+
const records = Array.isArray(data.result?.records) ? data.result.records : null;
|
|
47
|
+
if (!records) return { ok: false, reason: `missing result.records[] at ${path}` };
|
|
48
|
+
return { ok: true, records };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const argv = process.argv.slice(2).filter((a) => a !== undefined && a !== '');
|
|
55
|
+
if (argv.length < 3) {
|
|
56
|
+
process.stderr.write('usage: node classify-agent-access-state.mjs <agent-access-permset.json> <sea-existing.json | NO-PERMSET> <chosenAgentIds-csv>\n');
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
const [permsetPath, seaArg, chosenCsv] = argv;
|
|
60
|
+
const chosenAgentIds = chosenCsv.split(',').map((s) => s.trim()).filter(Boolean);
|
|
61
|
+
|
|
62
|
+
function cannotConfirm(reasons) {
|
|
63
|
+
process.stdout.write(JSON.stringify({
|
|
64
|
+
permsetExists: null, permsetId: null, chosenAgentIds,
|
|
65
|
+
grantedAgentIds: [], missingAgentIds: [],
|
|
66
|
+
needsCreate: null, needsGrants: null,
|
|
67
|
+
verdict: 'CANNOT-CONFIRM', reasons,
|
|
68
|
+
}, null, 2) + '\n');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (chosenAgentIds.length === 0) {
|
|
73
|
+
cannotConfirm(['no chosen agent Ids supplied — pass the comma-separated BotDefinition Ids the user selected.']);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const permset = readEnvelope(permsetPath);
|
|
77
|
+
if (!permset.ok) cannotConfirm([permset.reason]);
|
|
78
|
+
|
|
79
|
+
const permsetRow = permset.records.find((r) => r?.Name === 'Agent_Access') ?? null;
|
|
80
|
+
const permsetExists = !!permsetRow;
|
|
81
|
+
const permsetId = permsetExists ? (permsetRow.Id ?? null) : null;
|
|
82
|
+
|
|
83
|
+
let grantedAgentIds = [];
|
|
84
|
+
if (permsetExists) {
|
|
85
|
+
if (seaArg === 'NO-PERMSET') {
|
|
86
|
+
cannotConfirm(['Agent_Access permset exists but the SetupEntityAccess capture was passed as NO-PERMSET — query the existing grants and pass the real capture.']);
|
|
87
|
+
}
|
|
88
|
+
const sea = readEnvelope(seaArg);
|
|
89
|
+
if (!sea.ok) cannotConfirm([sea.reason]);
|
|
90
|
+
const grantedSet = new Set(sea.records.map((r) => r?.SetupEntityId).filter(Boolean));
|
|
91
|
+
grantedAgentIds = chosenAgentIds.filter((id) => grantedSet.has(id));
|
|
92
|
+
}
|
|
93
|
+
// When the permset does not exist, seaArg must be NO-PERMSET and nothing is granted.
|
|
94
|
+
|
|
95
|
+
const missingAgentIds = chosenAgentIds.filter((id) => !grantedAgentIds.includes(id));
|
|
96
|
+
const needsCreate = !permsetExists;
|
|
97
|
+
const needsGrants = missingAgentIds.length > 0;
|
|
98
|
+
|
|
99
|
+
const reasons = [];
|
|
100
|
+
reasons.push(permsetExists
|
|
101
|
+
? `Agent_Access permission set already exists (${permsetId}).`
|
|
102
|
+
: 'Agent_Access permission set does not exist yet — it will be created.');
|
|
103
|
+
reasons.push(`Chosen agents: ${chosenAgentIds.length}; already granted: ${grantedAgentIds.length}; missing grants: ${missingAgentIds.length}.`);
|
|
104
|
+
|
|
105
|
+
const verdict = (needsCreate || needsGrants) ? 'NEEDS-WORK' : 'ALREADY-COMPLETE';
|
|
106
|
+
reasons.push(verdict === 'NEEDS-WORK'
|
|
107
|
+
? 'Proceed to the confirm-to-write gate, then create the permset (if missing) and add one SetupEntityAccess grant per missing agent.'
|
|
108
|
+
: 'The Agent_Access permset exists and already grants every chosen agent — no SetupEntityAccess writes needed (still assign the permset to the target user(s) if not yet assigned).');
|
|
109
|
+
|
|
110
|
+
process.stdout.write(JSON.stringify({
|
|
111
|
+
permsetExists, permsetId, chosenAgentIds, grantedAgentIds, missingAgentIds,
|
|
112
|
+
needsCreate, needsGrants, verdict, reasons,
|
|
113
|
+
}, null, 2) + '\n');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic idempotency classifier for the agent-runtime-access-assign flow.
|
|
3
|
+
//
|
|
4
|
+
// Reads the `sf data query --json` captures:
|
|
5
|
+
// - PermissionSetAssignment (permset ↔ user) for the target user + permset
|
|
6
|
+
// - PermissionSetLicenseAssign (PSL ↔ user) for the target user + PSL
|
|
7
|
+
// — ONLY when the selected permset actually has a backing license.
|
|
8
|
+
// Pass the sentinel `NO-PSL` in place of the PSLA path when the selected
|
|
9
|
+
// permset has no backing `LicenseId` (a standalone permset such as the
|
|
10
|
+
// custom `Agent_Access` set — no PSL query, no PSL POST).
|
|
11
|
+
// and decides whether the assign write should be skipped for this user+permset.
|
|
12
|
+
//
|
|
13
|
+
// Rules (A9):
|
|
14
|
+
// - needsPsl AND both rows present ⇒ needsWrite=false (already fully assigned).
|
|
15
|
+
// - needsPsl AND either row missing ⇒ needsWrite=true (assign the missing
|
|
16
|
+
// piece; the workflow assigns PSL before permset regardless of which one is
|
|
17
|
+
// currently missing to preserve the write-order invariant).
|
|
18
|
+
// - !needsPsl (pslaPath === "NO-PSL"):
|
|
19
|
+
// * permsetAssigned ⇒ needsWrite=false (nothing to write).
|
|
20
|
+
// * !permsetAssigned ⇒ needsWrite=true (assign the permset only — skip the
|
|
21
|
+
// PSL POST).
|
|
22
|
+
// `licenseAssigned` is reported as null in this branch (not applicable).
|
|
23
|
+
// - Query envelope missing/failed ⇒ CANNOT-CONFIRM; surface it and stop.
|
|
24
|
+
//
|
|
25
|
+
// Usage:
|
|
26
|
+
// node classify-assignment-state.mjs <psa-existing.json> <psla-existing.json | NO-PSL>
|
|
27
|
+
//
|
|
28
|
+
// Emits a single JSON object to stdout:
|
|
29
|
+
// { permsetAssigned, licenseAssigned, needsWrite,
|
|
30
|
+
// verdict: "NEEDS-WRITE" | "ALREADY-ASSIGNED" | "CANNOT-CONFIRM",
|
|
31
|
+
// reasons: [...] }
|
|
32
|
+
// Exit is always 0 on parseable bodies; verdict carries the decision.
|
|
33
|
+
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
|
|
36
|
+
function readEnvelope(path) {
|
|
37
|
+
try {
|
|
38
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
39
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
40
|
+
const data = JSON.parse(text);
|
|
41
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `non-object JSON at ${path}` };
|
|
42
|
+
if (typeof data.status === 'number' && data.status !== 0) {
|
|
43
|
+
return { ok: false, reason: `sf data query at ${path} failed with status ${data.status}` };
|
|
44
|
+
}
|
|
45
|
+
const records = Array.isArray(data.result?.records) ? data.result.records : null;
|
|
46
|
+
if (!records) return { ok: false, reason: `missing result.records[] at ${path}` };
|
|
47
|
+
return { ok: true, records };
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const argv = process.argv.slice(2).filter((a) => !!a);
|
|
54
|
+
if (argv.length < 2) {
|
|
55
|
+
process.stderr.write('usage: node classify-assignment-state.mjs <psa-existing.json> <psla-existing.json | NO-PSL>\n');
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
const [psaPath, pslaArg] = argv;
|
|
59
|
+
const needsPsl = pslaArg !== 'NO-PSL';
|
|
60
|
+
|
|
61
|
+
const psa = readEnvelope(psaPath);
|
|
62
|
+
const psla = needsPsl ? readEnvelope(pslaArg) : { ok: true, records: [], skipped: true };
|
|
63
|
+
|
|
64
|
+
const readErrors = [psa, psla].filter((r) => !r.ok).map((r) => r.reason);
|
|
65
|
+
if (readErrors.length) {
|
|
66
|
+
process.stdout.write(JSON.stringify({
|
|
67
|
+
permsetAssigned: null, licenseAssigned: null, needsWrite: null,
|
|
68
|
+
verdict: 'CANNOT-CONFIRM',
|
|
69
|
+
reasons: readErrors,
|
|
70
|
+
}, null, 2) + '\n');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const permsetAssigned = psa.records.length > 0;
|
|
75
|
+
const licenseAssigned = needsPsl ? psla.records.length > 0 : null;
|
|
76
|
+
const needsWrite = needsPsl
|
|
77
|
+
? !(permsetAssigned && licenseAssigned)
|
|
78
|
+
: !permsetAssigned;
|
|
79
|
+
|
|
80
|
+
const verdict = needsWrite ? 'NEEDS-WRITE' : 'ALREADY-ASSIGNED';
|
|
81
|
+
const reasons = [
|
|
82
|
+
`PermissionSetAssignment: ${permsetAssigned ? 'present' : 'missing'} (${psa.records.length} row${psa.records.length === 1 ? '' : 's'}).`,
|
|
83
|
+
needsPsl
|
|
84
|
+
? `PermissionSetLicenseAssign: ${licenseAssigned ? 'present' : 'missing'} (${psla.records.length} row${psla.records.length === 1 ? '' : 's'}).`
|
|
85
|
+
: 'PermissionSetLicenseAssign: not applicable — the selected permission set has no backing LicenseId (standalone-permset flow; PSL SOQL + POST are skipped).',
|
|
86
|
+
];
|
|
87
|
+
reasons.push(
|
|
88
|
+
needsWrite
|
|
89
|
+
? (needsPsl
|
|
90
|
+
? 'At least one assignment is missing — proceed to the confirm-to-write gate, then assign (PSL first, permset second).'
|
|
91
|
+
: 'Permission set assignment is missing — proceed to the confirm-to-write gate, then assign the permset ONLY (skip the PSL POST).')
|
|
92
|
+
: (needsPsl
|
|
93
|
+
? 'Both the permission set and its backing license are already assigned to this user — skip the write, go straight to verify.'
|
|
94
|
+
: 'The permission set is already assigned to this user (no backing license required) — skip the write, go straight to verify.')
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
process.stdout.write(JSON.stringify({
|
|
98
|
+
permsetAssigned, licenseAssigned, needsWrite, verdict, reasons,
|
|
99
|
+
}, null, 2) + '\n');
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic availability classifier for the platform feature permsets an
|
|
3
|
+
// ITSM agent's actions need at RUNTIME (so a user can exercise the agent).
|
|
4
|
+
//
|
|
5
|
+
// Reads ONE input:
|
|
6
|
+
// - PermissionSet query capture (from `sf data query --json`) filtered to the
|
|
7
|
+
// six known platform feature permset `Name` values across three features,
|
|
8
|
+
// each with a "use" tier (lighter, user/agent-facing) and an "admin" tier:
|
|
9
|
+
//
|
|
10
|
+
// Feature use tier admin tier
|
|
11
|
+
// ----------------- -------------------------------- --------------------------------
|
|
12
|
+
// Prompt Templates EinsteinGPTPromptTemplateUser EinsteinGPTPromptTemplateManager
|
|
13
|
+
// Data Cloud GenieUserEnhancedSecurity GenieAdmin
|
|
14
|
+
// Unified Catalog UnifiedCatalogCommunityUser UnifiedCatalogAdmin
|
|
15
|
+
//
|
|
16
|
+
// No single org is guaranteed to have all three features provisioned, so the
|
|
17
|
+
// classifier reports which tiers are actually PRESENT (a row exists) and which
|
|
18
|
+
// are ABSENT (feature not provisioned on this org — skip it, do not fail).
|
|
19
|
+
//
|
|
20
|
+
// `needsPsl` is derived PER ROW from `LicenseId !== null` — the write path POSTs
|
|
21
|
+
// a PermissionSetLicenseAssign using the row's own `LicenseId`; PSL names are
|
|
22
|
+
// never hard-coded. `displayLabel` prefers the org's actual `Label`, falling
|
|
23
|
+
// back to a canonical label when the row is absent.
|
|
24
|
+
//
|
|
25
|
+
// Usage:
|
|
26
|
+
// node classify-platform-permset-availability.mjs <permsets.json>
|
|
27
|
+
//
|
|
28
|
+
// Emits a single JSON object to stdout:
|
|
29
|
+
// { features: [ { feature, tiers: { use, admin }, anyPresent } ],
|
|
30
|
+
// provisionedFeatures: [...], absentFeatures: [...],
|
|
31
|
+
// verdict: "ASSIGNABLE" | "NONE-PROVISIONED" | "CANNOT-CONFIRM",
|
|
32
|
+
// reasons: [...] }
|
|
33
|
+
// where each tier is { tier, name, displayLabel, present, Id, LicenseId, needsPsl }.
|
|
34
|
+
// Exit is always 0 on parseable bodies; verdict carries the decision. Exit 2 on
|
|
35
|
+
// missing argv.
|
|
36
|
+
|
|
37
|
+
import { readFileSync } from 'node:fs';
|
|
38
|
+
|
|
39
|
+
// The fixed catalog. `label` is the canonical fallback; the org's own Label wins
|
|
40
|
+
// when the row is present (e.g. GenieAdmin ships as "Data Cloud Architect" on
|
|
41
|
+
// some orgs, "Data Cloud Admin" on others — render whatever the org says).
|
|
42
|
+
const CATALOG = [
|
|
43
|
+
{
|
|
44
|
+
feature: 'Prompt Templates',
|
|
45
|
+
use: { name: 'EinsteinGPTPromptTemplateUser', label: 'Prompt Template User' },
|
|
46
|
+
admin: { name: 'EinsteinGPTPromptTemplateManager', label: 'Prompt Template Manager' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
feature: 'Data Cloud',
|
|
50
|
+
use: { name: 'GenieUserEnhancedSecurity', label: 'Data Cloud User' },
|
|
51
|
+
admin: { name: 'GenieAdmin', label: 'Data Cloud Admin' },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
feature: 'Unified Catalog',
|
|
55
|
+
use: { name: 'UnifiedCatalogCommunityUser', label: 'Unified Catalog Community User' },
|
|
56
|
+
admin: { name: 'UnifiedCatalogAdmin', label: 'Unified Catalog Admin' },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function readEnvelope(path) {
|
|
61
|
+
try {
|
|
62
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
63
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
64
|
+
const data = JSON.parse(text);
|
|
65
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `non-object JSON at ${path}` };
|
|
66
|
+
if (typeof data.status === 'number' && data.status !== 0) {
|
|
67
|
+
return { ok: false, reason: `sf data query at ${path} failed with status ${data.status}` };
|
|
68
|
+
}
|
|
69
|
+
const records = Array.isArray(data.result?.records) ? data.result.records : null;
|
|
70
|
+
if (!records) return { ok: false, reason: `missing result.records[] at ${path}` };
|
|
71
|
+
return { ok: true, records };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [psPath] = process.argv.slice(2);
|
|
78
|
+
if (!psPath) {
|
|
79
|
+
process.stderr.write('usage: node classify-platform-permset-availability.mjs <permsets.json>\n');
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ps = readEnvelope(psPath);
|
|
84
|
+
if (!ps.ok) {
|
|
85
|
+
process.stdout.write(JSON.stringify({
|
|
86
|
+
features: [],
|
|
87
|
+
provisionedFeatures: [],
|
|
88
|
+
absentFeatures: CATALOG.map((f) => f.feature),
|
|
89
|
+
verdict: 'CANNOT-CONFIRM',
|
|
90
|
+
reasons: [ps.reason],
|
|
91
|
+
}, null, 2) + '\n');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Index the query rows by PermissionSet.Name for O(1) lookup.
|
|
96
|
+
const byName = new Map();
|
|
97
|
+
for (const r of ps.records) {
|
|
98
|
+
if (r && typeof r.Name === 'string') byName.set(r.Name, r);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tierOf(tier, spec) {
|
|
102
|
+
const row = byName.get(spec.name);
|
|
103
|
+
const present = !!row;
|
|
104
|
+
return {
|
|
105
|
+
tier,
|
|
106
|
+
name: spec.name,
|
|
107
|
+
displayLabel: (present && typeof row.Label === 'string' && row.Label) ? row.Label : spec.label,
|
|
108
|
+
present,
|
|
109
|
+
Id: present ? (row.Id ?? null) : null,
|
|
110
|
+
LicenseId: present ? (row.LicenseId ?? null) : null,
|
|
111
|
+
needsPsl: present ? ((row.LicenseId ?? null) !== null) : false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const features = CATALOG.map((f) => {
|
|
116
|
+
const use = tierOf('use', f.use);
|
|
117
|
+
const admin = tierOf('admin', f.admin);
|
|
118
|
+
return { feature: f.feature, tiers: { use, admin }, anyPresent: use.present || admin.present };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const provisionedFeatures = features.filter((f) => f.anyPresent).map((f) => f.feature);
|
|
122
|
+
const absentFeatures = features.filter((f) => !f.anyPresent).map((f) => f.feature);
|
|
123
|
+
|
|
124
|
+
const reasons = [];
|
|
125
|
+
let verdict;
|
|
126
|
+
if (provisionedFeatures.length === 0) {
|
|
127
|
+
verdict = 'NONE-PROVISIONED';
|
|
128
|
+
reasons.push('None of the three platform features (Prompt Templates, Data Cloud, Unified Catalog) is provisioned on this org — no runtime action-execution permset can be assigned.');
|
|
129
|
+
reasons.push('This does NOT block the Agent Access concern; continue there so users can still open the activated agents.');
|
|
130
|
+
} else {
|
|
131
|
+
verdict = 'ASSIGNABLE';
|
|
132
|
+
reasons.push(`Provisioned feature(s) with an assignable tier: ${provisionedFeatures.join(', ')}.`);
|
|
133
|
+
if (absentFeatures.length > 0) {
|
|
134
|
+
reasons.push(`Not provisioned on this org (report as unavailable, do not assign): ${absentFeatures.join(', ')}.`);
|
|
135
|
+
}
|
|
136
|
+
reasons.push('For EACH provisioned feature, ask the user which tier to assign (use/agent vs admin) — do not auto-select a tier.');
|
|
137
|
+
const standalone = [];
|
|
138
|
+
for (const f of features) {
|
|
139
|
+
for (const t of ['use', 'admin']) {
|
|
140
|
+
const row = f.tiers[t];
|
|
141
|
+
if (row.present && !row.needsPsl) standalone.push(row.name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (standalone.length > 0) {
|
|
145
|
+
reasons.push(`Standalone tier(s) with no backing LicenseId (assign skips PSL SOQL/POST if selected): ${standalone.join(', ')}.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
process.stdout.write(JSON.stringify({
|
|
150
|
+
features,
|
|
151
|
+
provisionedFeatures,
|
|
152
|
+
absentFeatures,
|
|
153
|
+
verdict,
|
|
154
|
+
reasons,
|
|
155
|
+
}, null, 2) + '\n');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-target-user eligibility gate for the Unified Catalog feature tiers. The
|
|
3
|
+
// Unified Catalog PSLs are license-SHAPE gated (not seat-gated): the Community
|
|
4
|
+
// User tier can be held only by a Unified Employee-licensed user, and the Admin
|
|
5
|
+
// tier is meaningful only for a System Administrator. Offering a tier the user's
|
|
6
|
+
// license/profile can never hold produces a hard license-shape failure at write
|
|
7
|
+
// time, so this gate decides which UC tiers to OFFER for a given user — or to omit
|
|
8
|
+
// Unified Catalog for that user entirely. A pre-write eligibility gate, evaluated
|
|
9
|
+
// per target user; it never writes.
|
|
10
|
+
//
|
|
11
|
+
// Inputs (argv):
|
|
12
|
+
// 1. availability.json — the classify-platform-permset-availability.mjs output
|
|
13
|
+
// capture (carries the Unified Catalog tiers with present/Id/LicenseId/needsPsl).
|
|
14
|
+
// 2. user.json — a `sf data query --json` capture of the ONE target user,
|
|
15
|
+
// selecting Profile.Name and Profile.UserLicense.Name.
|
|
16
|
+
//
|
|
17
|
+
// Output (stdout JSON):
|
|
18
|
+
// { feature:"Unified Catalog", provisioned,
|
|
19
|
+
// user:{ profile, license, unifiedEmployee, sysAdmin },
|
|
20
|
+
// offer:[ <tier objects from the availability classifier> ], omit, note }
|
|
21
|
+
// offer[] is the subset of PRESENT UC tiers this user is eligible for:
|
|
22
|
+
// - "use" (UnifiedCatalogCommunityUser) → only when the user is Unified Employee
|
|
23
|
+
// - "admin" (UnifiedCatalogAdmin) → only when the user is System Administrator
|
|
24
|
+
// omit=true (offer empty) ⇒ do NOT present Unified Catalog for this user; report it
|
|
25
|
+
// "not applicable for this user's license/profile — skipped" (never a failed write).
|
|
26
|
+
// Fails CLOSED: an unreadable user capture yields omit=true (better to skip than to
|
|
27
|
+
// offer a tier that will fail). Exit 0 on parseable bodies; exit 2 on missing argv.
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
|
|
31
|
+
// The Unified Employee cohort (Community User tier) and the System Administrator
|
|
32
|
+
// profile (Admin tier) — the only two cohorts whose license/profile shape can hold
|
|
33
|
+
// a Unified Catalog tier.
|
|
34
|
+
const UNIFIED_RE = /unified\s*employee/i;
|
|
35
|
+
const SYSADMIN_RE = /system\s*administrator/i;
|
|
36
|
+
|
|
37
|
+
function readJson(path) {
|
|
38
|
+
try {
|
|
39
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
40
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
41
|
+
return { ok: true, data: JSON.parse(text) };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emit(obj) {
|
|
48
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [availPath, userPath] = process.argv.slice(2);
|
|
52
|
+
if (!availPath || !userPath) {
|
|
53
|
+
process.stderr.write('usage: node gate-unified-catalog-tiers.mjs <availability.json> <user.json>\n');
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const avail = readJson(availPath);
|
|
58
|
+
const userEnv = readJson(userPath);
|
|
59
|
+
|
|
60
|
+
// Locate the Unified Catalog feature block emitted by the availability classifier.
|
|
61
|
+
const ucFeature = avail.ok && Array.isArray(avail.data?.features)
|
|
62
|
+
? avail.data.features.find((f) => f.feature === 'Unified Catalog')
|
|
63
|
+
: null;
|
|
64
|
+
const useTier = ucFeature?.tiers?.use ?? null;
|
|
65
|
+
const adminTier = ucFeature?.tiers?.admin ?? null;
|
|
66
|
+
const provisioned = !!(useTier?.present || adminTier?.present);
|
|
67
|
+
|
|
68
|
+
// The one target user's profile + license (result.records[0]).
|
|
69
|
+
const rec = userEnv.ok ? (userEnv.data?.result?.records?.[0] ?? null) : null;
|
|
70
|
+
const profile = `${rec?.Profile?.Name ?? ''}`;
|
|
71
|
+
const license = `${rec?.Profile?.UserLicense?.Name ?? ''}`;
|
|
72
|
+
const unifiedEmployee = UNIFIED_RE.test(license) || UNIFIED_RE.test(profile);
|
|
73
|
+
const sysAdmin = SYSADMIN_RE.test(profile);
|
|
74
|
+
|
|
75
|
+
const offer = [];
|
|
76
|
+
if (useTier?.present && unifiedEmployee) offer.push(useTier);
|
|
77
|
+
if (adminTier?.present && sysAdmin) offer.push(adminTier);
|
|
78
|
+
|
|
79
|
+
const omit = offer.length === 0;
|
|
80
|
+
let note;
|
|
81
|
+
if (!avail.ok) {
|
|
82
|
+
note = `availability capture unreadable (${avail.reason}) — cannot gate Unified Catalog; omit for this user.`;
|
|
83
|
+
} else if (!provisioned) {
|
|
84
|
+
note = 'Unified Catalog is not provisioned on this org — skip it (already handled by the availability classifier).';
|
|
85
|
+
} else if (!userEnv.ok) {
|
|
86
|
+
note = `target-user capture unreadable (${userEnv.reason}) — cannot confirm the user's cohort; omit Unified Catalog for this user.`;
|
|
87
|
+
} else if (omit) {
|
|
88
|
+
note = `This user (license "${license || '?'}", profile "${profile || '?'}") is neither a Unified Employee (Community User tier) nor a System Administrator (Admin tier) — omit Unified Catalog for this user; report "not applicable for this user's license/profile — skipped".`;
|
|
89
|
+
} else {
|
|
90
|
+
note = `Offer the ${offer.map((t) => t.tier).join(' + ')} tier(s) for Unified Catalog (unifiedEmployee=${unifiedEmployee}, sysAdmin=${sysAdmin}).`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
emit({
|
|
94
|
+
feature: 'Unified Catalog',
|
|
95
|
+
provisioned,
|
|
96
|
+
user: { profile, license, unifiedEmployee, sysAdmin },
|
|
97
|
+
offer,
|
|
98
|
+
omit,
|
|
99
|
+
note,
|
|
100
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ranks REAL, human candidate users to offer as selectable options behind the
|
|
3
|
+
// Phase-2 AskUserQuestion when the caller did not name a target user. This is a
|
|
4
|
+
// presentation aid, never a gating decision — it keeps the offered list
|
|
5
|
+
// deterministic (no editorializing down to one name) and audience-appropriate.
|
|
6
|
+
//
|
|
7
|
+
// Inputs (argv):
|
|
8
|
+
// 1. users.json — capture of `sf data query --json` selecting active users
|
|
9
|
+
// with Id, Name, Username, Profile.Name, Profile.UserLicense.Name.
|
|
10
|
+
// 2. audience — 'fulfiller' | 'employee' | 'any' (anything else ⇒ 'any').
|
|
11
|
+
// A Fulfiller agent is run by IT staff on a STANDARD license;
|
|
12
|
+
// an Employee agent by end employees on a UNIFIED EMPLOYEE
|
|
13
|
+
// license — so the matching cohort is surfaced first.
|
|
14
|
+
// 3. runningUserId — optional; the "Me" user, excluded here (offered separately
|
|
15
|
+
// as the recommended option).
|
|
16
|
+
//
|
|
17
|
+
// Output (stdout JSON):
|
|
18
|
+
// { candidates: [ { Id, Name, Username, profile, cohort } ], audience, total, note }
|
|
19
|
+
// at most 5 candidates; service/integration/bot/agent accounts removed. Exit is
|
|
20
|
+
// always 0 on a parseable body (empty candidates ⇒ offer "Me" + free-text only);
|
|
21
|
+
// exit 2 only on missing argv.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
// Non-human / service accounts are never good runtime-access targets. Matched on
|
|
26
|
+
// profile name, license name, username, OR display name so a mislabeled record
|
|
27
|
+
// on any one field is still filtered.
|
|
28
|
+
const SERVICE_RE = /integration|automated process|einstein agent|analytics cloud|salesforceiq|\binsights\b|chatter (free|external)|guest|portal|sites|\bbot\b|botuser|digitalagent/i;
|
|
29
|
+
|
|
30
|
+
// The Unified Employee cohort — the human tier an Employee agent's users belong to.
|
|
31
|
+
// It is defined by the Unified Employee USER LICENSE, which is the authoritative
|
|
32
|
+
// signal; the profile name is only a defensive fallback (a custom profile carrying
|
|
33
|
+
// the license must still rank as Unified Employee).
|
|
34
|
+
const UNIFIED_RE = /unified\s*employee/i;
|
|
35
|
+
|
|
36
|
+
function readEnvelope(path) {
|
|
37
|
+
try {
|
|
38
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
39
|
+
if (!text) return { ok: false, reason: `empty file: ${path}` };
|
|
40
|
+
const data = JSON.parse(text);
|
|
41
|
+
if (data && typeof data.status === 'number' && data.status !== 0) {
|
|
42
|
+
return { ok: false, reason: `sf data query at ${path} failed with status ${data.status}` };
|
|
43
|
+
}
|
|
44
|
+
const records = Array.isArray(data?.result?.records) ? data.result.records : null;
|
|
45
|
+
if (!records) return { ok: false, reason: `missing result.records[] at ${path}` };
|
|
46
|
+
return { ok: true, records };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return { ok: false, reason: `unreadable/invalid JSON at ${path}: ${e?.message ?? e}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emit(obj) {
|
|
53
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [usersPath, audienceArg, runningUserId] = process.argv.slice(2);
|
|
57
|
+
if (!usersPath) {
|
|
58
|
+
process.stderr.write('usage: node rank-candidate-users.mjs <users.json> [fulfiller|employee|any] [runningUserId]\n');
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
const audience = (audienceArg === 'fulfiller' || audienceArg === 'employee') ? audienceArg : 'any';
|
|
62
|
+
|
|
63
|
+
const env = readEnvelope(usersPath);
|
|
64
|
+
if (!env.ok) {
|
|
65
|
+
emit({ candidates: [], audience, total: 0, note: `${env.reason} — offer "Me" plus free-text entry only.` });
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const profileName = (r) => `${r?.Profile?.Name ?? ''}`;
|
|
70
|
+
const licenseName = (r) => `${r?.Profile?.UserLicense?.Name ?? ''}`;
|
|
71
|
+
const isService = (r) =>
|
|
72
|
+
SERVICE_RE.test(profileName(r)) || SERVICE_RE.test(licenseName(r)) ||
|
|
73
|
+
SERVICE_RE.test(`${r?.Username ?? ''}`) || SERVICE_RE.test(`${r?.Name ?? ''}`);
|
|
74
|
+
const cohortOf = (r) =>
|
|
75
|
+
(UNIFIED_RE.test(licenseName(r)) || UNIFIED_RE.test(profileName(r)) ? 'unified-employee' : 'standard');
|
|
76
|
+
|
|
77
|
+
const humans = env.records
|
|
78
|
+
.filter((r) => r && typeof r.Id === 'string')
|
|
79
|
+
.filter((r) => r.Id !== runningUserId)
|
|
80
|
+
.filter((r) => !isService(r))
|
|
81
|
+
.map((r) => ({ Id: r.Id, Name: r.Name ?? r.Username, Username: r.Username, profile: profileName(r), cohort: cohortOf(r) }));
|
|
82
|
+
|
|
83
|
+
// Rank the audience-matching cohort first, preserving input order within a cohort
|
|
84
|
+
// (the query already orders by most-recent login).
|
|
85
|
+
const prefer = audience === 'employee' ? 'unified-employee' : audience === 'fulfiller' ? 'standard' : null;
|
|
86
|
+
const ranked = prefer
|
|
87
|
+
? [...humans.filter((u) => u.cohort === prefer), ...humans.filter((u) => u.cohort !== prefer)]
|
|
88
|
+
: humans;
|
|
89
|
+
|
|
90
|
+
const candidates = ranked.slice(0, 5);
|
|
91
|
+
const note = candidates.length === 0
|
|
92
|
+
? 'No non-service human users found besides the running user — offer "Me" plus free-text entry only.'
|
|
93
|
+
: `Offering ${candidates.length} candidate(s)${prefer ? `, ${prefer} cohort first (audience: ${audience})` : ''}.`;
|
|
94
|
+
|
|
95
|
+
emit({ candidates, audience, total: humans.length, note });
|