mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 19:50:42 +08:00
512 lines
19 KiB
JavaScript
Executable File
512 lines
19 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const HELP = `Data Cloud org readiness classifier
|
|
|
|
Usage:
|
|
node diagnose-org.mjs -o <orgAlias> [--json] [--phase <phase>] [--describe-table <table>] [--timeout <seconds>]
|
|
|
|
Options:
|
|
-o, --target-org Salesforce org alias or username (required)
|
|
--json Print machine-readable JSON
|
|
--phase Optional phase focus: all | connect | prepare | harmonize | segment | act | retrieve
|
|
--describe-table Optional DMO/DLO table name for a retrieve-plane probe
|
|
--timeout Per-command timeout in seconds (default: 45)
|
|
-h, --help Show this help text
|
|
`;
|
|
|
|
const FEATURE_GUIDANCE = {
|
|
CdpDataStreams: 'Data Streams are unavailable for this org/user. Review Data Cloud Setup provisioning, ingestion-related feature availability, and source connector permissions before using preparing-datacloud.',
|
|
CdpIdentityResolution: 'Identity Resolution is unavailable for this org/user. Review Data Cloud harmonization/identity-resolution entitlements and the user\'s Data Cloud permissions before using harmonizing-datacloud for IR work.',
|
|
CdpActivationTarget: 'Activation Targets are unavailable for this org/user. Review activation permissions, destination setup, and any org-specific activation enablement before using activating-datacloud.',
|
|
CdpActivationExternalPlatform: 'Activation destination platforms are unavailable for this org/user. Review Activation Targets setup, destination auth/configuration, and any feature toggles exposed in Data Cloud Setup → Feature Manager.',
|
|
CdpDataSpace: 'Data Spaces are unavailable for this org/user. Review core Data Cloud provisioning and user permissions in Data Cloud Setup before assuming downstream phases can work.',
|
|
};
|
|
|
|
const CAVEATS = [
|
|
'sf data360 doctor is advisory only: the current upstream command checks the search-index endpoint, not every Data Cloud module.',
|
|
'Do not use query describe as a universal tenant probe. Run it only after confirming broader readiness and only against a known DMO or DLO table.',
|
|
'Core Data Cloud provisioning, tenant creation, and license assignment are not reliably exposed as a full CLI enablement flow. Use CLI detection plus Setup guidance instead of promising fully programmatic enablement.',
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = { phase: 'all', timeout: 45, json: false, describeTable: null };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
switch (arg) {
|
|
case '-o':
|
|
case '--target-org':
|
|
args.targetOrg = argv[++i];
|
|
break;
|
|
case '--phase':
|
|
args.phase = argv[++i] ?? 'all';
|
|
break;
|
|
case '--describe-table':
|
|
args.describeTable = argv[++i] ?? null;
|
|
break;
|
|
case '--timeout':
|
|
args.timeout = Number(argv[++i] ?? '45');
|
|
break;
|
|
case '--json':
|
|
args.json = true;
|
|
break;
|
|
case '-h':
|
|
case '--help':
|
|
args.help = true;
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function trimText(text) {
|
|
return (text || '').replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '').trim();
|
|
}
|
|
|
|
function runCommand(command, args, { json = false, timeoutSeconds = 45 } = {}) {
|
|
const finalArgs = [...args];
|
|
if (json && !finalArgs.includes('--json')) finalArgs.push('--json');
|
|
const result = spawnSync(command, finalArgs, {
|
|
encoding: 'utf8',
|
|
timeout: timeoutSeconds * 1000,
|
|
});
|
|
|
|
const stdout = result.stdout || '';
|
|
const stderr = result.stderr || '';
|
|
let parsed = null;
|
|
if (stdout.trim()) {
|
|
try {
|
|
parsed = JSON.parse(stdout);
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
}
|
|
|
|
let errorMessage = null;
|
|
if (result.error) {
|
|
if (result.error.code === 'ENOENT') {
|
|
errorMessage = `${command} is not installed or not on PATH.`;
|
|
} else if (result.error.code === 'ETIMEDOUT') {
|
|
errorMessage = `Timed out after ${timeoutSeconds}s`;
|
|
} else {
|
|
errorMessage = result.error.message;
|
|
}
|
|
}
|
|
|
|
return {
|
|
command: [command, ...finalArgs].join(' '),
|
|
exitCode: typeof result.status === 'number' ? result.status : 1,
|
|
stdout: trimText(stdout),
|
|
stderr: trimText(stderr),
|
|
parsed,
|
|
errorMessage,
|
|
};
|
|
}
|
|
|
|
function extractSuccessSample(parsed) {
|
|
const result = parsed?.result ?? {};
|
|
|
|
if (Array.isArray(result?.data)) {
|
|
return result.data[0] ?? null;
|
|
}
|
|
if (Array.isArray(result?.data?.platforms)) {
|
|
return result.data.platforms[0] ?? null;
|
|
}
|
|
if (Array.isArray(result?.semanticSearchDefinitionDetails)) {
|
|
return result.semanticSearchDefinitionDetails[0] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractDetails(parsed) {
|
|
const result = parsed?.result ?? {};
|
|
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
const details = { ...result };
|
|
delete details.data;
|
|
if (details.status || details.indexCount !== undefined || details.apiVersion || details.instanceUrl) {
|
|
return details;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractCount(parsed) {
|
|
const result = parsed?.result ?? {};
|
|
if (Array.isArray(result?.data)) return result.data.length;
|
|
if (typeof result?.totalSize === 'number') return result.totalSize;
|
|
if (Array.isArray(result?.data?.platforms)) return result.data.platforms.length;
|
|
if (typeof result?.indexCount === 'number') return result.indexCount;
|
|
return null;
|
|
}
|
|
|
|
function classifyError(message) {
|
|
const text = message || '';
|
|
const gateMatch = text.match(/\[([A-Za-z0-9]+)\]/);
|
|
|
|
if (text.includes("Couldn't find CDP tenant ID")) {
|
|
return {
|
|
state: 'query_service_unavailable',
|
|
code: 'QUERY_SERVICE_TENANT_LOOKUP_FAILED',
|
|
reason: 'The query service could not resolve a CDP tenant for this org context. Confirm broader readiness with data-space, DMO, or doctor probes before declaring Data Cloud fully disabled.',
|
|
};
|
|
}
|
|
if (text.includes('This feature is not currently enabled for this user type or org')) {
|
|
const featureCode = gateMatch?.[1] ?? 'UNKNOWN_FEATURE_GATE';
|
|
return {
|
|
state: 'feature_gated',
|
|
code: featureCode,
|
|
reason: FEATURE_GUIDANCE[featureCode] ?? 'This capability is gated for the current org or user. Review provisioning, permissions, and any relevant setup toggles before proceeding.',
|
|
};
|
|
}
|
|
if (text.includes('NOT_FOUND: DataModelEntity')) {
|
|
return {
|
|
state: 'table_not_found',
|
|
code: 'DATA_MODEL_ENTITY_NOT_FOUND',
|
|
reason: 'The specified DMO or DLO table name is not queryable in this org. Pick a real table from dmo list / dlo list or use dmo get first.',
|
|
};
|
|
}
|
|
if (text.includes('Request failed')) {
|
|
return {
|
|
state: 'request_failed',
|
|
code: 'REQUEST_FAILED',
|
|
reason: 'The endpoint returned a generic request failure. Treat this as inconclusive and confirm readiness with adjacent read-only probes.',
|
|
};
|
|
}
|
|
if (text.includes('is not a sf command') || text.includes('not installed or not on PATH')) {
|
|
return {
|
|
state: 'runtime_missing',
|
|
code: 'RUNTIME_MISSING',
|
|
reason: 'The sf data360 runtime is not available. Install or relink the community plugin first.',
|
|
};
|
|
}
|
|
if (text.includes('Timed out after')) {
|
|
return {
|
|
state: 'timeout',
|
|
code: 'TIMEOUT',
|
|
reason: 'The probe timed out. Retry, reduce the scope, or fall back to a smaller read-only command family.',
|
|
};
|
|
}
|
|
return {
|
|
state: 'unknown_error',
|
|
code: 'UNKNOWN_ERROR',
|
|
reason: 'The probe failed for an unclassified reason. Inspect the raw message and confirm readiness with neighboring probes.',
|
|
};
|
|
}
|
|
|
|
function normalizeProbe(name, raw, meta = {}) {
|
|
const base = {
|
|
name,
|
|
command: raw.command,
|
|
exitCode: raw.exitCode,
|
|
stderr: raw.stderr || undefined,
|
|
...meta,
|
|
};
|
|
|
|
if (raw.errorMessage) {
|
|
const error = classifyError(raw.errorMessage);
|
|
return { ...base, ...error, message: raw.errorMessage };
|
|
}
|
|
|
|
if (raw.parsed && (raw.parsed.status === 0 || raw.exitCode === 0)) {
|
|
const count = extractCount(raw.parsed);
|
|
const sample = extractSuccessSample(raw.parsed);
|
|
const details = extractDetails(raw.parsed);
|
|
let state = 'ok';
|
|
if (count !== null) {
|
|
state = count > 0 ? 'enabled_populated' : 'enabled_empty';
|
|
}
|
|
if (raw.parsed?.result?.status === 'ok') {
|
|
state = 'ok';
|
|
}
|
|
return {
|
|
...base,
|
|
state,
|
|
count,
|
|
sample: sample ?? undefined,
|
|
details,
|
|
};
|
|
}
|
|
|
|
const message = raw.parsed?.message || raw.stdout || raw.stderr || `Command failed with exit code ${raw.exitCode}`;
|
|
const error = classifyError(message);
|
|
return { ...base, ...error, message };
|
|
}
|
|
|
|
function summarizePhase(checks) {
|
|
const states = Object.values(checks).map((check) => check.state);
|
|
if (states.length === 0) return 'skipped';
|
|
if (states.every((state) => state === 'skipped')) return 'skipped';
|
|
const hasSuccess = states.some((state) => ['ok', 'enabled_empty', 'enabled_populated'].includes(state));
|
|
const hasPopulated = states.includes('enabled_populated');
|
|
const hasBlocking = states.some((state) => ['feature_gated', 'query_service_unavailable', 'request_failed', 'table_not_found', 'timeout', 'unknown_error'].includes(state));
|
|
if (hasSuccess && hasBlocking) return 'partial';
|
|
if (hasPopulated) return 'ready';
|
|
if (hasSuccess) return 'ready_empty';
|
|
if (states.every((state) => state === 'feature_gated')) return 'feature_gated';
|
|
if (states.every((state) => state === 'query_service_unavailable')) return 'query_unavailable';
|
|
if (states.every((state) => ['request_failed', 'timeout', 'unknown_error'].includes(state))) return 'blocked';
|
|
return 'unknown';
|
|
}
|
|
|
|
function guidanceFromProbe(scope, probe) {
|
|
if (!probe) return [];
|
|
if (['ok', 'enabled_empty', 'enabled_populated', 'skipped'].includes(probe.state)) return [];
|
|
|
|
if (probe.state === 'runtime_missing') {
|
|
return [
|
|
'Install or relink the community sf data360 runtime before attempting Data Cloud work.',
|
|
'Use python3 ~/.claude/sf-skills-install.py --with-datacloud-runtime or bash ./scripts/bootstrap-plugin.sh.',
|
|
];
|
|
}
|
|
|
|
if (probe.state === 'query_service_unavailable') {
|
|
return [
|
|
`${scope}: query-specific tenant lookup failed. Do not treat this as a global Data Cloud outage by itself. Re-check core probes such as data-space list, dmo list, and doctor, then use a known table name if you retry query describe.`,
|
|
];
|
|
}
|
|
|
|
if (probe.state === 'feature_gated') {
|
|
return [`${scope}: ${probe.reason}`];
|
|
}
|
|
|
|
if (probe.state === 'table_not_found') {
|
|
return [`${scope}: ${probe.reason}`];
|
|
}
|
|
|
|
if (probe.state === 'request_failed') {
|
|
return [
|
|
`${scope}: ${probe.reason}`,
|
|
'If doctor is the failing probe, remember that it only checks search indexes; use adjacent read-only probes before deciding the whole org is blocked.',
|
|
];
|
|
}
|
|
|
|
if (probe.state === 'timeout') {
|
|
return [`${scope}: ${probe.reason}`];
|
|
}
|
|
|
|
return [`${scope}: ${probe.reason}`];
|
|
}
|
|
|
|
function parseOrgDisplay(raw, targetOrg) {
|
|
const base = {
|
|
alias: targetOrg,
|
|
state: 'ok',
|
|
};
|
|
|
|
if (raw.errorMessage) {
|
|
return { ...base, state: 'org_not_authenticated', message: raw.errorMessage };
|
|
}
|
|
|
|
if (raw.parsed && raw.parsed.status === 0) {
|
|
const result = raw.parsed.result || {};
|
|
return {
|
|
...base,
|
|
alias: result.alias || targetOrg,
|
|
username: result.username,
|
|
instanceUrl: result.instanceUrl,
|
|
apiVersion: result.apiVersion,
|
|
connectedStatus: result.connectedStatus,
|
|
};
|
|
}
|
|
|
|
const message = raw.parsed?.message || raw.stdout || raw.stderr || `Failed to resolve org ${targetOrg}`;
|
|
return {
|
|
...base,
|
|
state: 'org_not_authenticated',
|
|
message,
|
|
};
|
|
}
|
|
|
|
function buildProbeMatrix(orgAlias, describeTable) {
|
|
return {
|
|
core: {
|
|
doctor: ['data360', 'doctor', '-o', orgAlias],
|
|
dataSpaces: ['data360', 'data-space', 'list', '-o', orgAlias],
|
|
dmos: ['data360', 'dmo', 'list', '-o', orgAlias],
|
|
},
|
|
phases: {
|
|
connect: {
|
|
connectorCatalog: ['data360', 'connection', 'connector-list', '-o', orgAlias],
|
|
connectionsSalesforceCRM: ['data360', 'connection', 'list', '-o', orgAlias, '--connector-type', 'SalesforceCRM'],
|
|
},
|
|
prepare: {
|
|
dataStreams: ['data360', 'data-stream', 'list', '-o', orgAlias],
|
|
dlos: ['data360', 'dlo', 'list', '-o', orgAlias],
|
|
},
|
|
harmonize: {
|
|
dmos: ['data360', 'dmo', 'list', '-o', orgAlias],
|
|
identityResolution: ['data360', 'identity-resolution', 'list', '-o', orgAlias],
|
|
},
|
|
segment: {
|
|
segments: ['data360', 'segment', 'list', '-o', orgAlias],
|
|
calculatedInsights: ['data360', 'calculated-insight', 'list', '-o', orgAlias],
|
|
},
|
|
act: {
|
|
activationPlatforms: ['data360', 'activation', 'platforms', '-o', orgAlias],
|
|
activationTargets: ['data360', 'activation-target', 'list', '-o', orgAlias],
|
|
dataActionTargets: ['data360', 'data-action-target', 'list', '-o', orgAlias],
|
|
},
|
|
retrieve: {
|
|
searchIndexes: ['data360', 'search-index', 'list', '-o', orgAlias],
|
|
queryDescribe: describeTable
|
|
? ['data360', 'query', 'describe', '-o', orgAlias, '--table', describeTable]
|
|
: null,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function printTextReport(report) {
|
|
const lines = [];
|
|
lines.push(`Data Cloud readiness for ${report.org.alias}`);
|
|
lines.push(`Runtime: ${report.runtime.state}`);
|
|
if (report.runtime.message) lines.push(` ${report.runtime.message}`);
|
|
lines.push(`Org auth: ${report.org.state}`);
|
|
if (report.org.username) lines.push(` User: ${report.org.username}`);
|
|
if (report.org.instanceUrl) lines.push(` Instance: ${report.org.instanceUrl}`);
|
|
lines.push('');
|
|
lines.push('Core probes:');
|
|
for (const [name, probe] of Object.entries(report.core)) {
|
|
lines.push(` - ${name}: ${probe.state}${probe.count !== null && probe.count !== undefined ? ` (${probe.count})` : ''}`);
|
|
if (probe.code) lines.push(` code: ${probe.code}`);
|
|
if (probe.message) lines.push(` ${probe.message}`);
|
|
}
|
|
lines.push('');
|
|
lines.push('Phase readiness:');
|
|
for (const [phase, entry] of Object.entries(report.phases)) {
|
|
lines.push(` ${phase}: ${entry.summary}`);
|
|
for (const [name, probe] of Object.entries(entry.checks)) {
|
|
lines.push(` - ${name}: ${probe.state}${probe.count !== null && probe.count !== undefined ? ` (${probe.count})` : ''}`);
|
|
if (probe.code) lines.push(` code: ${probe.code}`);
|
|
if (probe.message) lines.push(` ${probe.message}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
lines.push('Caveats:');
|
|
for (const caveat of report.caveats) {
|
|
lines.push(` - ${caveat}`);
|
|
}
|
|
lines.push('');
|
|
lines.push('Guidance:');
|
|
if (report.guidance.length === 0) {
|
|
lines.push(' - No blocking issues detected from the chosen probes. Continue with read-only inspection, then mutate intentionally.');
|
|
} else {
|
|
for (const item of report.guidance) {
|
|
lines.push(` - ${item}`);
|
|
}
|
|
}
|
|
console.log(lines.join('\n'));
|
|
}
|
|
|
|
function main() {
|
|
let args;
|
|
try {
|
|
args = parseArgs(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
console.error('');
|
|
console.error(HELP);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (args.help) {
|
|
console.log(HELP);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!args.targetOrg) {
|
|
console.error('Missing required --target-org / -o argument.');
|
|
console.error('');
|
|
console.error(HELP);
|
|
process.exit(1);
|
|
}
|
|
|
|
const runtimeCheck = runCommand('sf', ['data360', 'man'], { json: false, timeoutSeconds: args.timeout });
|
|
const runtime = runtimeCheck.exitCode === 0
|
|
? { state: 'ok', command: runtimeCheck.command }
|
|
: { ...classifyError(runtimeCheck.stderr || runtimeCheck.stdout || runtimeCheck.errorMessage || 'sf data360 runtime is unavailable'), command: runtimeCheck.command };
|
|
|
|
const orgDisplay = parseOrgDisplay(
|
|
runCommand('sf', ['org', 'display', '-o', args.targetOrg], { json: true, timeoutSeconds: args.timeout }),
|
|
args.targetOrg
|
|
);
|
|
|
|
const matrix = buildProbeMatrix(args.targetOrg, args.describeTable);
|
|
const report = {
|
|
runtime,
|
|
org: orgDisplay,
|
|
core: {},
|
|
phases: {},
|
|
caveats: [...CAVEATS],
|
|
guidance: [],
|
|
};
|
|
|
|
if (runtime.state !== 'ok' || orgDisplay.state !== 'ok') {
|
|
report.guidance.push(...guidanceFromProbe('runtime', runtime));
|
|
if (orgDisplay.state !== 'ok') {
|
|
report.guidance.push(`Authenticate the target org first: sf org login web -a ${args.targetOrg}`);
|
|
}
|
|
if (args.json) {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
} else {
|
|
printTextReport(report);
|
|
}
|
|
process.exit(runtime.state === 'ok' && orgDisplay.state === 'ok' ? 0 : 1);
|
|
}
|
|
|
|
for (const [name, commandArgs] of Object.entries(matrix.core)) {
|
|
report.core[name] = normalizeProbe(name, runCommand('sf', commandArgs, { json: true, timeoutSeconds: args.timeout }));
|
|
}
|
|
|
|
const selectedPhases = args.phase === 'all'
|
|
? Object.keys(matrix.phases)
|
|
: [args.phase];
|
|
|
|
for (const phase of selectedPhases) {
|
|
const checks = {};
|
|
const phaseMatrix = matrix.phases[phase] || {};
|
|
for (const [name, commandArgs] of Object.entries(phaseMatrix)) {
|
|
if (!commandArgs) {
|
|
checks[name] = { name, state: 'skipped', reason: 'No describe table was supplied for the query probe.' };
|
|
continue;
|
|
}
|
|
checks[name] = normalizeProbe(name, runCommand('sf', commandArgs, { json: true, timeoutSeconds: args.timeout }));
|
|
}
|
|
report.phases[phase] = {
|
|
summary: summarizePhase(checks),
|
|
checks,
|
|
};
|
|
}
|
|
|
|
const uniqueGuidance = new Set();
|
|
for (const [name, probe] of Object.entries(report.core)) {
|
|
for (const item of guidanceFromProbe(`core.${name}`, probe)) uniqueGuidance.add(item);
|
|
}
|
|
for (const [phase, entry] of Object.entries(report.phases)) {
|
|
for (const [name, probe] of Object.entries(entry.checks)) {
|
|
for (const item of guidanceFromProbe(`${phase}.${name}`, probe)) uniqueGuidance.add(item);
|
|
}
|
|
}
|
|
|
|
const coreSignals = Object.values(report.core).map((probe) => probe.state);
|
|
if (coreSignals.some((state) => ['ok', 'enabled_empty', 'enabled_populated'].includes(state))) {
|
|
uniqueGuidance.add('At least one core Data Cloud probe succeeded. Treat individual query or feature-gate failures as scoped issues, not automatic proof that the whole product is disabled.');
|
|
}
|
|
uniqueGuidance.add('For core provisioning, tenant creation, or license assignment gaps, use Setup → Data Cloud Setup and the current standard Data Cloud permission sets. Feature Manager can expose some org-specific toggles, but it is not the source of truth for every missing capability.');
|
|
uniqueGuidance.add('Reference: skills/orchestrating-datacloud/references/feature-readiness.md');
|
|
|
|
report.guidance = [...uniqueGuidance];
|
|
|
|
if (args.json) {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
} else {
|
|
printTextReport(report);
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
main();
|