UNPKG

zapier-platform-cli

Version:

The CLI for managing integrations in Zapier Developer Platform.

547 lines (521 loc) 17.3 kB
const _ = require('lodash'); const { listAuthentications } = require('../../../utils/api'); const { startSpinner, endSpinner } = require('../../../utils/display'); const { fetchChoices } = require('./remote'); const { localAppCommandWithRelayErrorHandler } = require('./relay'); const { customLogger } = require('./logger'); /** * Formats a field definition for display in prompts. * @param {Object} field - The field definition * @param {string} field.key - The field key * @param {string} [field.label] - The field label * @param {string} [field.type] - The field type (defaults to 'string') * @param {boolean} [field.required] - Whether the field is required * @returns {string} Formatted string like "Label | key | type | required" */ const formatFieldDisplay = (field) => { const ftype = field.type || 'string'; let result; if (field.label) { result = `${field.label} | ${field.key} | ${ftype}`; } else { result = `${field.key} | ${ftype}`; } if (field.required) { result += ' | required'; } return result; }; /** * Extracts a display label from an object for use in dynamic dropdowns. * Tries common label keys like 'name', 'title', 'display', etc. * @param {Object} obj - The object to extract a label from * @param {string} [preferredKey] - Preferred key to check first (supports nested paths with __) * @param {string} [fallbackKey] - Fallback key to check last (supports nested paths with __) * @returns {string} The extracted label or empty string if not found */ const getLabelForDynamicDropdown = (obj, preferredKey, fallbackKey) => { const keys = [ 'name', 'Name', 'display', 'Display', 'title', 'Title', 'subject', 'Subject', ]; if (preferredKey) { keys.unshift(preferredKey.split('__')); } if (fallbackKey) { keys.push(fallbackKey.split('__')); } for (const key of keys) { const label = _.get(obj, key); if (label) { return label; } } return ''; }; /** * Filters input fields to find required fields that are missing values. * @param {Object} inputData - The current input data * @param {Array<Object>} inputFields - Array of field definitions * @returns {Array<Object>} Array of required fields that have no value or default */ const getMissingRequiredInputFields = (inputData, inputFields) => { return inputFields.filter( (f) => f.required && !f.default && !inputData[f.key], ); }; /** * Fetches choices for a dynamic dropdown field. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @param {Object} field - The field definition * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns) * @returns {Promise<Array<Object>>} Array of choices formatted as { name, value } objects */ const getDynamicDropdownChoices = async ( command, context, field, invokeAction, ) => { if (context.remote) { return (await fetchChoices(context, field.key)).map((c) => ({ name: `${c.label} (${c.value})`, value: c.value, })); } else { const [triggerKey, idField, labelField] = field.dynamic.split('.'); const trigger = context.appDefinition.triggers[triggerKey]; if (!trigger) { throw new Error( `Cannot find trigger "${triggerKey}" for dynamic dropdown of field "${field.key}".`, ); } const newContext = { ...context, nonInteractive: true, actionType: 'trigger', actionKey: triggerKey, actionTypePlural: 'triggers', meta: { ...context.meta, isFillingDynamicDropdown: true, }, }; return (await invokeAction(command, newContext)).map((c) => { const id = c[idField] ?? 'null'; const label = getLabelForDynamicDropdown(c, labelField, idField); return { name: `${label} (${id})`, value: id, }; }); } }; /** * Checks whether a field uses perform-based dynamic choices (choices: { perform }). * @param {Object} field - The field definition * @returns {boolean} True if the field has perform-based choices */ const isPerformBasedChoices = (field) => field.choices && typeof field.choices === 'object' && !Array.isArray(field.choices) && field.choices.perform !== undefined; /** * Fetches choices for a perform-based dynamic dropdown field. * @param {import('../../ZapierBaseCommand')} command - The command instance * @param {Object} context - The execution context * @param {Object} field - The field definition with choices.perform * @returns {Promise<{choices: Array<Object>, nextPagingToken: string|null}>} */ const getPerformBasedChoices = async (command, context, field) => { if (context.remote) { const choices = (await fetchChoices(context, field.key)).map((c) => ({ name: `${c.label} (${c.value})`, value: c.value, })); return { choices, nextPagingToken: null }; } // Find the field's index in the action's inputFields array const action = context.appDefinition[context.actionTypePlural][context.actionKey]; const allInputFields = action.operation.inputFields || []; const fieldIndex = allInputFields.findIndex( (f) => f.key === field.key && f.choices && f.choices.perform, ); if (fieldIndex === -1) { throw new Error( `Cannot find perform-based choices for field "${field.key}" in ` + `${context.actionTypePlural}.${context.actionKey}.operation.inputFields.`, ); } const methodName = `${context.actionTypePlural}.${context.actionKey}.operation.inputFields.${fieldIndex}.choices.perform`; const displayName = `${context.actionTypePlural}.${context.actionKey}.operation.inputFields[${fieldIndex}].choices.perform`; const adverb = context.remote ? 'remotely' : context.authId ? 'locally with relay' : 'locally'; startSpinner(`Invoking ${displayName} ${adverb}`); const result = await localAppCommandWithRelayErrorHandler({ command: 'execute', method: methodName, bundle: { inputData: context.inputData, inputDataRaw: context.inputData, authData: context.authData, meta: { ...context.meta, isFillingDynamicDropdown: true, }, }, zcacheTestObj: context.zcacheTestObj, cursorTestObj: context.cursorTestObj, customLogger, calledFromCliInvoke: true, appId: context.appId, deployKey: context.deployKey, relayAuthenticationId: context.authId, }); endSpinner(); // The perform function returns { results: [{ id, label }, ...], paging_token } // or a plain array of { id, label } objects let results, nextPagingToken; if (Array.isArray(result)) { results = result; nextPagingToken = null; } else { results = result.results || []; nextPagingToken = result.paging_token || null; } const choices = results.map((c) => ({ name: `${c.label || c.id} (${c.id})`, value: String(c.id), })); return { choices, nextPagingToken }; }; /** * Normalizes static choices into an array of { name, value } objects for * prompting. * @param {Array|string|Object} choices - The static choices definition * @return {Array<Object>} Array of choices formatted as { name, value } */ const getStaticChoices = (choices) => { if (Array.isArray(choices)) { // Can be an array of string or an array of { value, label } if (choices.length === 0) { return []; } else if (typeof choices[0] === 'string') { return choices.map((x) => ({ name: `${x} (${x})`, value: x })); } else { return choices.map((c) => ({ name: `${c.label} (${c.value})`, value: c.value, })); } } else { // If choices is not an array, then it must be an object of { value: label } return Object.entries(choices).map(([value, label]) => ({ name: `${label} (${value})`, value, })); } }; /** * Gets choices for a dropdown field, handling static, trigger-based dynamic, * and perform-based dynamic cases. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @param {Object} field - The field definition * @param {Function} invokeAction - Function to invoke actions (used for trigger-based dynamic dropdowns) * @param {Object} [pagingState] - Pagination state for perform-based choices * @param {boolean} [pagingState.hasPreviousPage] - Whether there is a previous page * @returns {Promise<{choices: Array<Object>, nextPagingToken: string|null}>} */ const getStaticOrDynamicDropdownChoices = async ( command, context, field, invokeAction, pagingState, ) => { if (field.dynamic) { const choices = await getDynamicDropdownChoices( command, context, field, invokeAction, ); const page = context.meta.page || 0; if (page) { choices.unshift({ name: `>>> PREVIOUS PAGE <<<`, value: '__prev_page__', }); } choices.push({ name: `>>> NEXT PAGE <<<`, value: '__next_page__', }); return { choices, nextPagingToken: null }; } else if (isPerformBasedChoices(field)) { const { choices, nextPagingToken } = await getPerformBasedChoices( command, context, field, ); if (pagingState && pagingState.hasPreviousPage) { choices.unshift({ name: `>>> PREVIOUS PAGE <<<`, value: '__prev_page__', }); } if (nextPagingToken) { choices.push({ name: `>>> NEXT PAGE <<<`, value: '__next_page__', }); } return { choices, nextPagingToken }; } else { return { choices: getStaticChoices(field.choices), nextPagingToken: null }; } }; /** * Prompts the user for a single field value. * Handles dynamic dropdowns, boolean fields, and regular text input. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context * @param {Object} field - The field definition * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns) * @returns {Promise<string>} The user-provided value */ const promptForField = async (command, context, field, invokeAction) => { const message = formatFieldDisplay(field) + ':'; if (field.dynamic || field.choices) { const performBased = isPerformBasedChoices(field); let answer; // Paging state for perform-based choices (token-based pagination) const pagingTokenStack = []; let currentPagingToken = null; let nextPagingToken = null; while ( !answer || answer === '__next_page__' || answer === '__prev_page__' ) { if (performBased) { switch (answer) { case '__next_page__': pagingTokenStack.push(currentPagingToken); currentPagingToken = nextPagingToken; break; case '__prev_page__': currentPagingToken = pagingTokenStack.pop() || null; break; } context = { ...context, meta: { ...context.meta, paging_token: currentPagingToken, }, }; } else { let page = 0; switch (answer) { case '__next_page__': page = (context.meta.page || 0) + 1; break; case '__prev_page__': page = Math.max((context.meta.page || 0) - 1, 0); break; } context = { ...context, meta: { ...context.meta, page, }, }; } const result = await getStaticOrDynamicDropdownChoices( command, context, field, invokeAction, { hasPreviousPage: pagingTokenStack.length > 0 }, ); nextPagingToken = result.nextPagingToken; answer = await command.promptWithList(message, result.choices, { useStderr: true, }); } return answer; } else if (field.type === 'boolean') { const yes = await command.confirm(message, false, !field.required, true); return yes ? 'yes' : 'no'; } else { return await command.prompt(message, { useStderr: true }); } }; /** * Prompts for missing required fields or throws an error in non-interactive mode. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context (inputData will be mutated) * @param {Array<Object>} inputFields - Array of field definitions * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns) * @returns {Promise<void>} * @throws {Error} If in non-interactive mode and required fields are missing */ const promptOrErrorForRequiredInputFields = async ( command, context, inputFields, invokeAction, ) => { const missingFields = getMissingRequiredInputFields( context.inputData, inputFields, ); if (missingFields.length) { if (context.nonInteractive || context.meta.isFillingDynamicDropdown) { throw new Error( "You're in non-interactive mode, so you must at least specify these required fields with --inputData: \n" + missingFields.map((f) => '* ' + formatFieldDisplay(f)).join('\n'), ); } for (const f of missingFields) { context.inputData[f.key] = await promptForField( command, context, f, invokeAction, ); } } }; /** * Allows the user to interactively edit input field values. * Displays a list of fields and lets the user select which to edit. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context (inputData will be mutated) * @param {Array<Object>} inputFields - Array of field definitions * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns) * @returns {Promise<void>} */ const promptForInputFieldEdit = async ( command, context, inputFields, invokeAction, ) => { inputFields = inputFields.filter((f) => f.key); if (!inputFields.length) { return; } // Let user select which field to fill/edit while (true) { let fieldChoices = inputFields.map((f) => { let name; if (f.label) { name = `${f.label} (${f.key})`; } else { name = f.key; } if (context.inputData[f.key]) { name += ` [current: "${context.inputData[f.key]}"]`; } else if (f.default) { name += ` [default: "${f.default}"]`; } return { name, value: f.key, }; }); fieldChoices = [ { name: '>>> DONE <<<', short: 'DONE', value: null, }, ...fieldChoices, ]; const fieldKey = await command.promptWithList( 'Would you like to edit any of these input fields? Select "DONE" when you are all set.', fieldChoices, { useStderr: true }, ); if (!fieldKey) { break; } const field = inputFields.find((f) => f.key === fieldKey); context.inputData[fieldKey] = await promptForField( command, context, field, invokeAction, ); } }; /** * Main entry point for field prompting. Handles required fields and optional editing. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @param {Object} context - The execution context (inputData will be mutated) * @param {Array<Object>} inputFields - Array of field definitions * @param {Function} invokeAction - Function to invoke actions (used for dynamic dropdowns) * @returns {Promise<void>} */ const promptForFields = async (command, context, inputFields, invokeAction) => { await promptOrErrorForRequiredInputFields( command, context, inputFields, invokeAction, ); if (!context.nonInteractive && !context.meta.isFillingDynamicDropdown) { await promptForInputFieldEdit(command, context, inputFields, invokeAction); } }; /** * Prompts the user to select an authentication/connection from their available authentications. * @param {import('../../ZapierBaseCommand')} command - The command instance for prompting * @returns {Promise<number>} The selected authentication ID * @throws {Error} If no authentications are found for the integration */ const promptForAuthentication = async (command) => { const auths = (await listAuthentications()).authentications; if (!auths || auths.length === 0) { throw new Error( 'No authentications/connections found for your integration. ' + 'Add a new connection at https://zapier.com/app/assets/connections ' + 'or use local auth data by removing the `--authentication-id` flag.', ); } const authChoices = auths.map((auth) => ({ name: `${auth.title} | ${auth.app_version} | ID: ${auth.id}`, value: auth.id, })); return command.promptWithList( 'Which authentication/connection would you like to use?', authChoices, { useStderr: true }, ); }; module.exports = { formatFieldDisplay, getLabelForDynamicDropdown, getMissingRequiredInputFields, promptForAuthentication, promptForField, promptOrErrorForRequiredInputFields, promptForInputFieldEdit, promptForFields, };