UNPKG

@pnp/cli-microsoft365

Version:

Manage Microsoft 365 and SharePoint Framework projects on any platform

394 lines • 14.1 kB
import os from 'os'; import { z } from 'zod'; import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { CheckStatus, formatting } from '../../../../utils/formatting.js'; const supportedUx = ['copilot', 'search', 'all']; export const options = z.strictObject({ ...globalOptionsZod.shape, id: z.string().alias('i'), ux: z.enum(supportedUx).optional() }); class ExternalConnectionDoctorCommand extends GraphCommand { constructor() { super(...arguments); this.checksStatus = []; } get name() { return commands.CONNECTION_DOCTOR; } get description() { return 'Checks if the external connection is correctly configured for use with the specified Microsoft 365 experience'; } get schema() { return options; } async commandAction(logger, args) { const ux = args.options.ux ?? 'all'; const output = args.options.output; this.checksStatus = []; const show = output === 'text'; let checks = [ { id: 'loadExternalConnection', text: 'Load connection', fn: this.loadConnection, type: 'required' }, { id: 'loadSchema', text: 'Load schema', fn: this.loadSchema, type: 'required' } ]; if (ux === 'copilot' || ux === 'all') { checks.push({ id: 'copilotRequiredSemanticLabels', text: 'Required semantic labels', fn: this.checkCopilotRequiredSemanticLabels, type: 'required' }, { id: 'searchableProperties', text: 'Searchable properties', fn: this.checkSearchableProperties, type: 'required' }, { id: 'contentIngested', text: 'Items have content ingested', fn: this.checkContentIngested, type: 'required' }, { id: 'enabledForInlineResults', text: 'Connection configured for inline results', type: 'required' }, { id: 'itemsHaveActivities', text: 'Items have activities recorded', type: 'recommended' }, { id: 'meaningfulNameAndDescription', text: 'Meaningful connection name and description', type: 'required' }); } if (ux === 'search' || ux === 'all') { checks.push({ id: 'semanticLabels', text: 'Semantic labels', fn: this.checkSemanticLabels, type: 'recommended' }, { id: 'searchableProperties', text: 'Searchable properties', fn: this.checkSearchableProperties, type: 'recommended' }, { id: 'resultType', text: 'Result type', fn: this.checkResultType, type: 'recommended' }, { id: 'contentIngested', text: 'Items have content ingested', fn: this.checkContentIngested, type: 'recommended' }, { id: 'itemsHaveActivities', text: 'Items have activities recorded', type: 'recommended' }); } checks.push({ id: 'urlToItemResolver', text: 'urlToItemResolver configured', fn: this.checkUrlToItemResolverConfigured, type: 'recommended' }); // filter out duplicate checks based on their IDs checks = checks.filter((check, index, self) => self.findIndex(c => c.id === check.id) === index); for (const check of checks) { if (this.debug) { await logger.logToStderr(`Running check ${check.id}...`); } // only automated checks have functions if (!check.fn) { if (show) { await logger.log(formatting.getStatus(CheckStatus.Information, `${check.text} (manual)`)); } this.checksStatus.push({ ...check, status: 'manual' }); continue; } const result = await check.fn.bind(this)(check.id, args); this.checksStatus.push({ ...check, ...result }); if (result.status === 'passed') { if (show) { await logger.log(formatting.getStatus(CheckStatus.Success, check.text)); } continue; } if (result.status === 'failed') { if (show) { const message = `${check.text}: ${result.errorMessage}`; if (check.type === 'required') { await logger.log(formatting.getStatus(CheckStatus.Failure, message)); } else { await logger.log(formatting.getStatus(CheckStatus.Warning, check.text)); } } if (result.shouldStop) { break; } } } if (show || output === 'none') { return; } this.checksStatus.forEach(s => { delete s.data; delete s.fn; delete s.shouldStop; }); if (output === 'json' || output === 'md') { await logger.log(this.checksStatus); return; } if (output === 'csv') { this.checksStatus.forEach(r => { // we need to set errorMessage to empty string so that it's not // removed from the CSV output r.errorMessage = r.errorMessage ?? ''; }); await logger.log(this.checksStatus); } } getMdOutput(logStatement, command, options) { const output = [ `# ${command.getCommandName()} ${Object.keys(options).filter(o => o !== 'output').map(k => `--${k} "${options[k]}"`).join(' ')}`, os.EOL, os.EOL, `Date: ${(new Date().toLocaleDateString())}`, os.EOL, os.EOL ]; if (logStatement && logStatement.length > 0) { const properties = ['text', 'type', 'status', 'errorMessage']; output.push('Check|Type|Status|Error message', os.EOL); output.push(':----|:--:|:----:|:------------', os.EOL); logStatement.forEach(r => { output.push(properties.map(p => r[p] ?? '').join('|'), os.EOL); }); logStatement.push(os.EOL); } return output.join('').trimEnd(); } async loadConnection(id, args) { const requestOptions = { url: `${this.resource}/v1.0/external/connections/${args.options.id}`, headers: { accept: 'application/json;odata.metadata=none' }, responseType: 'json' }; try { const externalConnection = await request.get(requestOptions); return { id, data: externalConnection, status: 'passed' }; } catch (ex) { return { id, error: ex?.response?.data?.error?.innerError?.message, errorMessage: 'Connection not found', shouldStop: true, status: 'failed' }; } } async loadSchema(id, args) { const requestOptions = { url: `${this.resource}/v1.0/external/connections/${args.options.id}/schema`, headers: { accept: 'application/json;odata.metadata=none', prefer: 'include-unknown-enum-members' }, responseType: 'json' }; try { const schema = await request.get(requestOptions); return { id, data: schema, status: 'passed' }; } catch (ex) { return { id, errorMessage: 'Schema not found', error: ex?.response?.data?.error?.innerError?.message, shouldStop: true, status: 'failed' }; } } async checkCopilotRequiredSemanticLabels(id) { const schema = this.checksStatus.find(r => r.id === 'loadSchema').data; const requiredLabels = ['title', 'url', 'iconUrl']; for (const label of requiredLabels) { if (!schema.properties?.find(p => p.labels?.find(l => l.toString() === label))) { return { id, errorMessage: `Missing label ${label}`, status: 'failed' }; } } return { id, status: 'passed' }; } async checkSearchableProperties(id) { const schema = this.checksStatus.find(r => r.id === 'loadSchema').data; if (!schema.properties?.some(p => p.isSearchable)) { return { id, errorMessage: 'Schema does not have any searchable properties', status: 'failed' }; } return { id, status: 'passed' }; } async checkContentIngested(id, args) { try { // find items that belong to the connection const searchRequestOptions = { url: `${this.resource}/v1.0/search/query`, headers: { accept: 'application/json;odata.metadata=none' }, responseType: 'json', data: { requests: [ { entityTypes: [ 'externalItem' ], contentSources: [ `/external/connections/${args.options.id}` ], query: { queryString: '*' }, from: 0, size: 1 } ] } }; const result = await request.post(searchRequestOptions); const hit = result.value?.[0].hitsContainers?.[0]?.hits?.[0]; if (!hit) { return { id, errorMessage: 'No items found that belong to the connection', status: 'failed' }; } // something@tenant,itemId const itemId = hit.resource?.properties?.substrateContentDomainId?.split(',')?.[1]; if (!itemId) { return { id, errorMessage: 'Item does not have substrateContentDomainId property or the property is invalid', status: 'failed' }; } const externalItemRequestOptions = { url: `${this.resource}/v1.0/external/connections/${args.options.id}/items/${itemId}`, headers: { accept: 'application/json;odata.metadata=none' }, responseType: 'json' }; const externalItem = await request.get(externalItemRequestOptions); if (!externalItem.content?.value) { return { id, data: externalItem, errorMessage: 'Item does not have content or content is empty', status: 'failed' }; } return { id, data: externalItem, status: 'passed' }; } catch (ex) { return { id, error: ex?.response?.data?.error?.innerError?.message, errorMessage: 'Error while checking if content is ingested', status: 'failed' }; } } async checkUrlToItemResolverConfigured(id) { const externalConnection = this.checksStatus.find(r => r.id === 'loadExternalConnection').data; if (!externalConnection.activitySettings?.urlToItemResolvers?.some(r => r)) { return { id, errorMessage: 'urlToItemResolver is not configured', status: 'failed' }; } return { id, status: 'passed' }; } async checkSemanticLabels(id) { const schema = this.checksStatus.find(r => r.id === 'loadSchema').data; const hasLabels = schema.properties?.some(p => p.labels?.some(l => l)); if (!hasLabels) { return { id, errorMessage: `Schema does not have semantic labels`, status: 'failed' }; } return { id, status: 'passed' }; } async checkResultType(id) { const externalConnection = this.checksStatus.find(r => r.id === 'loadExternalConnection').data; if (!externalConnection.searchSettings?.searchResultTemplates?.some(t => t)) { return { id, errorMessage: `Connection has no result types`, status: 'failed' }; } return { id, status: 'passed' }; } } export default new ExternalConnectionDoctorCommand(); //# sourceMappingURL=connection-doctor.js.map