├── .dockerignore ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── config.yml ├── scripts │ ├── change-log-helper.js │ ├── create-fork-pipeline-changes.js │ ├── get-version-type.js │ ├── trigger-workflow.js │ ├── update-api-spec-with-changelog.sh │ ├── update-change-log.js │ ├── update-release.js │ └── validate_cli_tokens.py └── workflows │ ├── cli-core-audit.yml │ ├── cli-core-test.yml │ └── release.yml ├── .gitignore ├── .mocharc.yml ├── .nycrc.json ├── .releaserc.json ├── .vscode ├── launch.json └── settings.json ├── CHANGES.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── ISSUE_TEMPLATE.md ├── LICENSE ├── Makefile ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── appveyor.yml ├── githooks └── pre-commit ├── package-lock.json ├── package.json ├── sonar-project.properties ├── src ├── base-commands │ ├── base-command.js │ └── twilio-client-command.js ├── index.js └── services │ ├── api-schema │ ├── json-converter.js │ └── twilio-converter.js │ ├── cli-http-client.js │ ├── config.js │ ├── env.js │ ├── error.js │ ├── javascript-utilities.js │ ├── messaging │ ├── help-messages.js │ ├── logging.js │ ├── templates.js │ └── templating.js │ ├── naming-conventions.js │ ├── open-api-client.js │ ├── output-formats │ ├── columns.js │ ├── index.js │ ├── json.js │ └── tsv.js │ ├── require-install.js │ └── twilio-api │ ├── api-browser.js │ ├── index.js │ ├── twilio-client.js │ ├── twilio_accounts_v1.json │ ├── twilio_api_v2010.json │ ├── twilio_assistants_v1.json │ ├── twilio_autopilot_v1.json │ ├── twilio_bulkexports_v1.json │ ├── twilio_chat_v1.json │ ├── twilio_chat_v2.json │ ├── twilio_chat_v3.json │ ├── twilio_content_sdk.json │ ├── twilio_content_v1.json │ ├── twilio_content_v2.json │ ├── twilio_conversations_v1.json │ ├── twilio_events_v1.json │ ├── twilio_fax_v1.json │ ├── twilio_flex_v1.json │ ├── twilio_flex_v2.json │ ├── twilio_frontline_v1.json │ ├── twilio_iam_organizations.json │ ├── twilio_iam_v1.json │ ├── twilio_insights_v1.json │ ├── twilio_insights_v2.json │ ├── twilio_intelligence_v2.json │ ├── twilio_ip_messaging_v1.json │ ├── twilio_ip_messaging_v2.json │ ├── twilio_lookups_v1.json │ ├── twilio_lookups_v2.json │ ├── twilio_marketplace_v1.json │ ├── twilio_media_v1.json │ ├── twilio_messaging_v1.json │ ├── twilio_microvisor_v1.json │ ├── twilio_monitor_v1.json │ ├── twilio_monitor_v2.json │ ├── twilio_notify_v1.json │ ├── twilio_numbers_v1.json │ ├── twilio_numbers_v2.json │ ├── twilio_oauth_v1.json │ ├── twilio_preview.json │ ├── twilio_pricing_v1.json │ ├── twilio_pricing_v2.json │ ├── twilio_proxy_v1.json │ ├── twilio_routes_v2.json │ ├── twilio_serverless_v1.json │ ├── twilio_studio_v1.json │ ├── twilio_studio_v2.json │ ├── twilio_supersim_v1.json │ ├── twilio_sync_v1.json │ ├── twilio_taskrouter_v1.json │ ├── twilio_trunking_v1.json │ ├── twilio_trusthub_v1.json │ ├── twilio_verify_v2.json │ ├── twilio_video_v1.json │ ├── twilio_voice_v1.json │ └── twilio_wireless_v1.json └── test ├── .eslintrc ├── base-commands ├── base-command.test.js └── twilio-client-command.test.js ├── helpers └── init.js ├── release-scripts ├── change-log-helper.test.js └── get-version-type.test.js └── services ├── api-schema.test.js ├── cli-http-client.test.js ├── config.test.js ├── env.test.js ├── javascript-utilities.test.js ├── messaging ├── logging.test.js └── templating.test.js ├── naming-conventions.test.js ├── require-install.test.js └── twilio-api ├── api-browser.test.js └── twilio-client.test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /tmp 6 | /yarn.lock 7 | node_modules 8 | coverage 9 | *.glksave 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "twilio", 4 | "twilio-mocha" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 2018 8 | }, 9 | "rules": { 10 | "global-require": "off", 11 | "prefer-named-capture-group": "off", 12 | "sonarjs/cognitive-complexity": "off", 13 | "sonarjs/no-identical-expressions": "off", 14 | "sonarjs/no-duplicate-string": "off", 15 | "sonarjs/no-identical-functions": "off", 16 | "sonarjs/no-collapsible-if": "off", 17 | "sonarjs/prefer-immediate-return": "off", 18 | "no-throw-literal": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Twilio Support 3 | url: https://twilio.com/help/contact 4 | about: Get Support 5 | - name: Stack Overflow 6 | url: https://stackoverflow.com/questions/tagged/twilio-cli-core+or+twilio+node 7 | about: Ask questions on Stack Overflow 8 | - name: Documentation 9 | url: https://www.twilio.com/docs/twilio-cli 10 | about: View Reference Documentation 11 | -------------------------------------------------------------------------------- /.github/scripts/change-log-helper.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const readline = require('readline'); 3 | 4 | const { logger } = require('../../src/services/messaging/logging'); 5 | 6 | const defaultVersionRegex = /(\d+)\.(\d+)\.(\d+)/; 7 | const defaultDateRegex = /\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])/; 8 | const cliCoreChangelogFile = 'CHANGES.md'; 9 | const oaiChangelogFile = 'OAI_CHANGES.md'; 10 | 11 | class ChangeLogHelper { 12 | constructor( 13 | cliCoreChangelogFilename = cliCoreChangelogFile, 14 | oaiChangelogFilename = oaiChangelogFile, 15 | versionRegex = defaultVersionRegex, 16 | dateRegex = defaultDateRegex, 17 | ) { 18 | this.versionRegex = versionRegex; 19 | this.dateRegex = dateRegex; 20 | this.cliCoreChangelogFilename = cliCoreChangelogFilename; 21 | this.oaiChangelogFilename = oaiChangelogFilename; 22 | this.logger = logger; 23 | } 24 | 25 | async getAllReleaseVersionsFromGivenDate(date) { 26 | this.logger.info(`Started detecting the versions from the date: ${date}`); 27 | const versions = []; 28 | const readLine = await this.getReadLiner(this.oaiChangelogFilename); 29 | for await (const line of readLine) { 30 | const currentDate = this.dateRegex.exec(line); 31 | if (currentDate) { 32 | const version = this.versionRegex.exec(line); 33 | if (version) { 34 | versions.push(version[0]); 35 | } 36 | if (currentDate[0] <= date) { 37 | break; 38 | } 39 | } 40 | } 41 | this.logger.info(`Detected Versions: ${versions}`); 42 | return versions; 43 | } 44 | 45 | async getLatestChangelogGeneratedDate() { 46 | this.logger.info('Started detecting the latest date in cli core changelog'); 47 | let latestDate; 48 | const readLine = await this.getReadLiner(this.cliCoreChangelogFilename); 49 | for await (const line of readLine) { 50 | latestDate = this.dateRegex.exec(line); 51 | if (latestDate) { 52 | latestDate = latestDate[0]; 53 | this.logger.info(`Detected the latest Date: ${latestDate}`); 54 | break; 55 | } 56 | } 57 | return latestDate; 58 | } 59 | 60 | async getChangesAfterGivenDate(date) { 61 | this.logger.info(`Started getting the changelog from given date: ${date}`); 62 | let readLines = false; 63 | let fileData = ''; 64 | const readLine = await this.getReadLiner(this.oaiChangelogFilename); 65 | for await (const line of readLine) { 66 | const currentDate = this.dateRegex.exec(line); 67 | if (currentDate) { 68 | if (currentDate[0] > date) { 69 | this.logger.info('Reading the lines'); 70 | readLines = true; 71 | } else { 72 | this.logger.info(`Changes from OpenAPI specs: ${fileData}`); 73 | break; 74 | } 75 | } else if (readLines) { 76 | fileData += `${line}\n`; 77 | } 78 | } 79 | return fileData; 80 | } 81 | 82 | async appendChangesToChangelog() { 83 | this.logger.info('Started appendChangesToChangelog'); 84 | try { 85 | const latestDate = await this.getLatestChangelogGeneratedDate(); // changes.md 86 | if (latestDate) { 87 | const changeLog = await this.getChangesAfterGivenDate(latestDate); // oai_changes.md 88 | if (changeLog) { 89 | this.logger.info('Updating the CHANGES.md'); 90 | const data = fs.readFileSync(this.cliCoreChangelogFilename); 91 | if (data.toString().includes(changeLog)) { 92 | this.logger.info(`Provided changes are already in cli core changeLog: ${changeLog}`); 93 | return; 94 | } 95 | const fd = fs.openSync(this.cliCoreChangelogFilename, 'w+'); 96 | const insert = Buffer.from(changeLog); 97 | fs.writeSync(fd, insert, 0, insert.length, 0); 98 | fs.writeSync(fd, data, 0, data.length, insert.length); 99 | fs.close(fd, (err) => { 100 | if (err) throw err; 101 | }); 102 | fs.writeFileSync('changeLog.md', changeLog); 103 | } 104 | } 105 | } catch (error) { 106 | this.logger.error(`Error while updating the changelog: ${error.message}`); 107 | throw new Error(error); 108 | } 109 | } 110 | 111 | async getReadLiner(filename) { 112 | if (!fs.existsSync(filename)) { 113 | throw new Error(`File not found: ${filename}`); 114 | } 115 | const fileStream = fs.createReadStream(filename); 116 | return readline.createInterface({ 117 | input: fileStream, 118 | }); 119 | } 120 | } 121 | module.exports = { 122 | ChangeLogHelper, 123 | }; 124 | -------------------------------------------------------------------------------- /.github/scripts/create-fork-pipeline-changes.js: -------------------------------------------------------------------------------- 1 | const saveFile = require('fs').writeFileSync; 2 | 3 | function packageChanges(rootPath, owner) { 4 | const pkgJsonPath = `${rootPath}/package.json` 5 | const json = require(pkgJsonPath); 6 | 7 | if (!json.hasOwnProperty('repository')) { 8 | json.repository = {}; 9 | json.repository['type'] = 'git' 10 | json.repository['url'] = `https://github.com/${owner}/twilio-cli-core.git` 11 | } else { 12 | json.repository['url'] = `https://github.com/${owner}/twilio-cli-core.git` 13 | } 14 | 15 | saveFile(pkgJsonPath, JSON.stringify(json, null, 2)); 16 | } 17 | 18 | function releasercChanges(rootPath) { 19 | const releasercJsonPath = `${rootPath}/.releaserc.json` 20 | const json = require(releasercJsonPath); 21 | if (json.plugins.includes("@semantic-release/npm")) { 22 | json.plugins = _removeItem(json.plugins, "@semantic-release/npm") 23 | json.plugins.push(["@semantic-release/npm", {"npmPublish": false}]) 24 | } 25 | 26 | saveFile(releasercJsonPath, JSON.stringify(json, null, 2)); 27 | } 28 | 29 | function _removeItem(arr, value) { 30 | var index = arr.indexOf(value); 31 | if (index > -1) { 32 | arr.splice(index, 1); 33 | } 34 | return arr; 35 | } 36 | 37 | 38 | packageChanges(process.argv[2], process.argv[3]) 39 | releasercChanges(process.argv[2]) 40 | -------------------------------------------------------------------------------- /.github/scripts/get-version-type.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { ChangeLogHelper } = require('./change-log-helper'); 3 | 4 | const ch = new ChangeLogHelper(); 5 | 6 | const getVersionType = async () => { 7 | const latestDate = await ch.getLatestChangelogGeneratedDate(); 8 | const versions = await ch.getAllReleaseVersionsFromGivenDate(latestDate); 9 | if (versions.length >= 2) { 10 | const version1 = versions[0].split('.'); 11 | const version2 = versions[versions.length - 1].split('.'); 12 | for (let i = 0; i < 3; i++) { 13 | if (version1[i] !== version2[i]) return i; 14 | } 15 | } 16 | return -1; 17 | }; 18 | (async () => { 19 | console.log(await getVersionType()); 20 | })(); 21 | module.exports = { 22 | getVersionType, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/scripts/trigger-workflow.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const { Octokit } = require('@octokit/rest'); 3 | 4 | /** 5 | * Functionality from benc-uk/workflow-dispatch. 6 | * Link: https://github.com/benc-uk/workflow-dispatch 7 | */ 8 | const triggerWorkflow = async () => { 9 | try { 10 | const octokit = new Octokit({ 11 | auth: process.env.REPO_ACCESS_TOKEN, 12 | }); 13 | const workflowRef = process.env.WORKFLOW_NAME; 14 | const ref = process.env.BRANCH_NAME; 15 | const [owner, repo] = process.env.REPO_NAME ? process.env.REPO_NAME.split('/') : [null, null]; 16 | 17 | // Decode inputs, this MUST be a valid JSON string 18 | let inputs = {}; 19 | const inputsJson = process.env.INPUTS; 20 | if (inputsJson) { 21 | inputs = JSON.parse(inputsJson); 22 | } 23 | 24 | if(inputs['change-log'] === null){ 25 | inputs['change-log'] = ""; 26 | } 27 | 28 | const workflow = await octokit.rest.actions.getWorkflow({ 29 | owner, 30 | repo, 31 | workflow_id: workflowRef, 32 | }); 33 | 34 | core.info(`Workflow id is: ${workflow.data.id}`); 35 | 36 | const dispatchResp = await octokit.rest.actions.createWorkflowDispatch({ 37 | owner, 38 | repo, 39 | workflow_id: workflow.data.id, 40 | ref, 41 | inputs, 42 | }); 43 | core.info(`API response status: ${dispatchResp.status}.`); 44 | } catch (error) { 45 | core.setFailed(error.message); 46 | } 47 | }; 48 | 49 | module.exports = { 50 | triggerWorkflow, 51 | }; 52 | -------------------------------------------------------------------------------- /.github/scripts/update-api-spec-with-changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Copying api-definitions" 3 | cp -R ~/oai_definitions/json/. src/services/twilio-api/ 4 | echo "Running update changelog script" 5 | node .github/scripts/update-change-log.js 6 | changeLog='' 7 | versionType=-1 8 | if [ -f changeLog.md ]; then 9 | changeLog=$(cat changeLog.md) 10 | rm -rf changeLog.md 11 | if [ "$changeLog" != '' ]; then 12 | changeLog="${changeLog//'%'/'%25'}" 13 | changeLog="${changeLog//$'\n'/'%0A'}" 14 | changeLog="${changeLog//$'\r'/'%0D'}" 15 | versionType=$(node .github/scripts/get-version-type.js | tail -n -1) 16 | fi 17 | fi 18 | echo "Changelog: $changeLog" 19 | echo "Version type: $versionType" 20 | rm -rf OAI_CHANGES.md 21 | echo "Git configurations" 22 | git config --global user.email "team_interfaces+github@twilio.com" 23 | git config --global user.name "twilio-dx" 24 | branch=$(git branch --show-current) 25 | echo "Current branch: $branch" 26 | git add -A 27 | if [ -n "$(git status --porcelain)" ]; then 28 | echo "There are changes to commit."; 29 | commitMessage='' 30 | if [ "$versionType" == 0 ] || [ "$versionType" == 1 ] 31 | then 32 | commitMessage='oaiFeat: Updated api definitions' 33 | elif [ "$versionType" == 2 ] 34 | then 35 | commitMessage='oaiFix: Updated api definitions' 36 | else 37 | echo "Invalid versionType: $versionType"; 38 | exit 39 | fi 40 | echo "Commit message:$commitMessage" 41 | git commit -m "$commitMessage" 42 | git push origin "$branch" 43 | else 44 | echo "No changes to commit"; 45 | fi 46 | -------------------------------------------------------------------------------- /.github/scripts/update-change-log.js: -------------------------------------------------------------------------------- 1 | const { ChangeLogHelper } = require('./change-log-helper'); 2 | 3 | const ch = new ChangeLogHelper(); 4 | 5 | const updateChangeLog = async () => { 6 | return ch.appendChangesToChangelog(); 7 | }; 8 | (async () => { 9 | await updateChangeLog(); 10 | })(); 11 | -------------------------------------------------------------------------------- /.github/scripts/update-release.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const { Octokit } = require("@octokit/core"); 3 | 4 | /** 5 | * Functionality from tubone24/update_release. 6 | * Link: https://github.com/tubone24/update_release 7 | */ 8 | const updateRelease = async () => { 9 | try { 10 | const octokit = new Octokit({ 11 | auth: process.env.REPO_ACCESS_TOKEN 12 | }) 13 | const [owner, repo] = process.env.REPO_NAME ? process.env.REPO_NAME.split('/') : [null, null]; 14 | const tag = process.env.TAG_NAME; 15 | 16 | //https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name 17 | const getReleaseResponse = await octokit.request('GET /repos/{owner}/{repo}/releases/tags/{tag}',{ 18 | owner, 19 | repo, 20 | tag, 21 | }); 22 | 23 | 24 | const { 25 | data: { 26 | id: oldReleaseId, 27 | html_url: oldHtmlUrl, 28 | upload_url: oldUploadUrl, 29 | body: oldBody, 30 | draft: oldDraft, 31 | name: oldName, 32 | prerelease: oldPrerelease, 33 | }, 34 | } = getReleaseResponse; 35 | 36 | core.info(`Got release info: '${oldReleaseId}', ${oldName}, '${oldHtmlUrl}', '${oldUploadUrl},'`); 37 | core.info(`Body: ${oldBody}`); 38 | core.info(`Draft: ${oldDraft}, Prerelease: ${oldPrerelease}`); 39 | 40 | const newBody = process.env.RELEASE_BODY; 41 | const newPrerelease = process.env.PRE_RELEASE; 42 | 43 | let body; 44 | if (newBody === '') { 45 | body = oldBody; 46 | } else { 47 | body = `${oldBody}\n${newBody}`; 48 | } 49 | 50 | let prerelease; 51 | if (newPrerelease !== '' && Boolean(newPrerelease)) { 52 | prerelease = newPrerelease === 'true'; 53 | } else { 54 | prerelease = oldPrerelease; 55 | } 56 | 57 | //https://docs.github.com/en/rest/releases/releases#update-a-release 58 | await octokit.request('PATCH /repos/{owner}/{repo}/releases/{release_id}', { 59 | owner, 60 | release_id: oldReleaseId, 61 | repo, 62 | body, 63 | name: oldName, 64 | draft: oldDraft, 65 | prerelease, 66 | }); 67 | 68 | core.info(`Updated release with body: ${body}`); 69 | } catch (error) { 70 | core.setFailed(error.message); 71 | } 72 | }; 73 | 74 | module.exports = { 75 | updateRelease, 76 | }; 77 | -------------------------------------------------------------------------------- /.github/scripts/validate_cli_tokens.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | 4 | time.sleep(60) #delay added as we need the CLI token validation workflow to complete 5 | 6 | github_url="https://api.github.com/repos/twilio/twilio-cli/actions/workflows/release-token-validation.yml/runs" 7 | response = requests.get(github_url) 8 | output=response.json() 9 | print(output['workflow_runs'][0]['conclusion']) 10 | -------------------------------------------------------------------------------- /.github/workflows/cli-core-audit.yml: -------------------------------------------------------------------------------- 1 | name: NPM Audit Check 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | 7 | jobs: 8 | audit: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node-version: [lts/*, 22.x, 20.x] 14 | steps: 15 | - name: Checkout cli repo 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: make install 25 | - name: Run audit check 26 | run: npm audit --audit-level=moderate --production 27 | # minimum vulnerability level that will cause the command to fail 28 | # audit reports with low severity would pass the test 29 | notify-complete-fail: 30 | if: ${{ failure() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 31 | needs: [ audit ] 32 | name: Notify Npm Audit Failed 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Slack Notification 37 | uses: rtCamp/action-slack-notify@v2 38 | env: 39 | SLACK_WEBHOOK: ${{ secrets.ALERT_SLACK_WEB_HOOK }} 40 | SLACK_COLOR: 'danger' 41 | SLACK_USERNAME: CLI Github Actions 42 | SLACK_MSG_AUTHOR: twilio-dx 43 | SLACK_ICON_EMOJI: ':github:' 44 | SLACK_TITLE: "Twilio Cli" 45 | SLACK_MESSAGE: 'Cli audit test failed' 46 | MSG_MINIMAL: actions url 47 | SLACK_FOOTER: Posted automatically using GitHub Actions 48 | -------------------------------------------------------------------------------- /.github/workflows/cli-core-test.yml: -------------------------------------------------------------------------------- 1 | name: Cli Core Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node-version: [lts/*, 22.x, 20.x] 14 | steps: 15 | - name: Checkout cli core repo 16 | uses: actions/checkout@v4 17 | with: 18 | # Disabling shallow clone is recommended for improving relevancy of reporting 19 | fetch-depth: 0 20 | - run: make install 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | - name: Run tests 27 | run: make test 28 | - name: SonarCloud Scan 29 | uses: sonarsource/sonarcloud-github-action@master 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 33 | notify-complete-fail: 34 | if: ${{ failure() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} 35 | needs: [ test ] 36 | name: Notify Test Failed 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Slack Notification 41 | uses: rtCamp/action-slack-notify@v2 42 | env: 43 | SLACK_WEBHOOK: ${{ secrets.ALERT_SLACK_WEB_HOOK }} 44 | SLACK_COLOR: 'danger' 45 | SLACK_USERNAME: CLI Github Actions 46 | SLACK_MSG_AUTHOR: twilio-dx 47 | SLACK_ICON_EMOJI: ':github:' 48 | SLACK_TITLE: "Twilio Cli Core" 49 | SLACK_MESSAGE: 'Cli core tests failed' 50 | MSG_MINIMAL: actions url 51 | SLACK_FOOTER: Posted automatically using GitHub Actions 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Cli-core Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | cli-branch: 6 | description: 'Run cli workflow in the given branch' 7 | default: main 8 | homebrew-branch: 9 | description: 'HomeBrew branch name' 10 | default: main 11 | homebrew-prerelease: 12 | description: 'HomeBrew prerelease' 13 | default: 'false' 14 | jobs: 15 | cli-core-token-validation: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout cli-core repo 19 | uses: actions/checkout@v2 20 | - run: | 21 | git pull 22 | make install 23 | - name: Extract branch name 24 | id: extract_branch 25 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 26 | - name: Trigger CLI token validation workflow 27 | run: | 28 | fileName="$GITHUB_WORKSPACE/.github/scripts/trigger-workflow.js" 29 | node -e "require('$fileName').triggerWorkflow()" 30 | env: 31 | WORKFLOW_NAME: '.github/workflows/release-token-validation.yml' 32 | REPO_NAME: ${{ github.repository_owner }}/twilio-cli 33 | REPO_ACCESS_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }} 34 | BRANCH_NAME: ${{steps.extract_branch.outputs.branch}} 35 | - name: Validate REPO_ACCESS_TOKEN 36 | uses: actions/checkout@v2 37 | with: 38 | repository: '${{ github.repository_owner }}/twilio-oai' 39 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 40 | - name: Validate AWS tokens 41 | uses: aws-actions/configure-aws-credentials@v1 42 | with: 43 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 44 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 45 | aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} 46 | aws-region: us-east-1 47 | 48 | cli-token-validation: 49 | needs: [ cli-core-token-validation ] 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout cli-core repo 53 | uses: actions/checkout@v2 54 | - name: Execute py script to validate twilio-cli tokens 55 | id: cli_token 56 | run: | 57 | output=$(python3 .github/scripts/validate_cli_tokens.py) 58 | echo "::set-output name=tokenStatus::$output" 59 | - name: Print status 60 | run: echo "${{ steps.cli_token.outputs.tokenStatus }}" 61 | - name: Validate the github workflow 62 | if: ${{ steps.cli_token.outputs.tokenStatus != 'success'}} 63 | run: exit 1 64 | 65 | # notify-start: 66 | # needs: [ cli-token-validation ] 67 | # name: Notify Release Started 68 | # runs-on: ubuntu-latest 69 | # steps: 70 | # - uses: actions/checkout@v2 71 | # - name: Extract branch name 72 | # id: extract_branch 73 | # run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 74 | # - name: Slack Notification 75 | # if: ${{steps.extract_branch.outputs.branch == 'main' }} 76 | # uses: rtCamp/action-slack-notify@v2 77 | # env: 78 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEB_HOOK }} 79 | # SLACK_COLOR: "#36a64f" 80 | # SLACK_USERNAME: CLI Release Bot 81 | # SLACK_ICON_EMOJI: ":ship:" 82 | # SLACK_TITLE: "Twilio Cli" 83 | # SLACK_MESSAGE: 'Release Started :rocket:' 84 | # MSG_MINIMAL: actions url 85 | test: 86 | needs: [ cli-token-validation ] 87 | runs-on: ubuntu-latest 88 | strategy: 89 | matrix: 90 | node-version: [20.x, 22.x, lts/*] 91 | steps: 92 | - name: Checkout cli-core repo 93 | uses: actions/checkout@v4 94 | - run: make install 95 | - name: Use Node.js ${{ matrix.node-version }} 96 | uses: actions/setup-node@v4 97 | with: 98 | node-version: ${{ matrix.node-version }} 99 | cache: 'npm' 100 | - name: Run tests 101 | run: make test 102 | update-api-specs: 103 | runs-on: ubuntu-latest 104 | needs: [ test ] 105 | outputs: 106 | change-log: ${{ steps.update-specs.outputs.change-log }} 107 | version-type: ${{ steps.update-specs.outputs.version-type }} 108 | steps: 109 | - name: Create temporary folder for copying json files from OAI repo 110 | run: mkdir -p ~/oai_definitions/json 111 | - name: Checkout OAI repo 112 | uses: actions/checkout@v2 113 | with: 114 | repository: '${{ github.repository_owner }}/twilio-oai' 115 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 116 | - run: | 117 | cp -R spec/json/. ~/oai_definitions/json/ 118 | cp -R CHANGES.md ~/oai_definitions/CHANGES.md 119 | - name: Checkout cli-core repo 120 | uses: actions/checkout@v2 121 | with: 122 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 123 | - name: Update OAI specs 124 | id: update-specs 125 | run: | 126 | make install 127 | cp -R ~/oai_definitions/CHANGES.md OAI_CHANGES.md 128 | source .github/scripts/update-api-spec-with-changelog.sh 129 | echo "::set-output name=change-log::$changeLog" 130 | echo "::set-output name=version-type::$versionType" 131 | release: 132 | runs-on: ubuntu-latest 133 | needs: [update-api-specs] 134 | outputs: 135 | tag-name: ${{ steps.update-release.outputs.TAG_NAME }} 136 | steps: 137 | - name: Checkout cli-core repo 138 | uses: actions/checkout@v4 139 | with: 140 | persist-credentials: false 141 | - run: | 142 | git pull 143 | make install 144 | - name: Use Node.js 18.x 145 | uses: actions/setup-node@v2 146 | with: 147 | node-version: 18.x 148 | - name: semanticRelease 149 | id: semantic-release 150 | run: DEBUG=semantic-release:* npx semantic-release -t \${version} 151 | env: 152 | GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }} 153 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 154 | - name: Update release 155 | id: update-release 156 | run: | 157 | echo "::set-output name=TAG_NAME::$TAG_NAME" 158 | fileName="$GITHUB_WORKSPACE/.github/scripts/update-release.js" 159 | node -e "require('$fileName').updateRelease()" 160 | env: 161 | REPO_ACCESS_TOKEN: ${{ github.token }} 162 | TAG_NAME: ${{ steps.semantic-release.outputs.TAG_NAME }} 163 | RELEASE_BODY: ${{needs.update-api-specs.outputs.change-log}} 164 | REPO_NAME: ${{ github.repository }} 165 | triggerCliWorkflow: 166 | runs-on: ubuntu-latest 167 | needs: [ update-api-specs, release] 168 | steps: 169 | - name: Checkout cli-core repo 170 | uses: actions/checkout@v4 171 | - run: | 172 | git pull 173 | make install 174 | - name: Invoke CLI workflow with changelog and version-type 175 | run: | 176 | fileName="$GITHUB_WORKSPACE/.github/scripts/trigger-workflow.js" 177 | node -e "require('$fileName').triggerWorkflow()" 178 | env: 179 | REPO_ACCESS_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }} 180 | WORKFLOW_NAME: 'release.yml' 181 | BRANCH_NAME: ${{github.event.inputs.cli-branch}} 182 | REPO_NAME: ${{ github.repository_owner }}/twilio-cli 183 | INPUTS: '{ "change-log": ${{ toJSON(needs.update-api-specs.outputs.change-log) }}, "version-type": "${{needs.update-api-specs.outputs.version-type}}", "homebrew-branch": "${{github.event.inputs.homebrew-branch}}", "homebrew-prerelease": "${{github.event.inputs.homebrew-prerelease}}", "tag-name": "${{ needs.release.outputs.tag-name }}" }' 184 | # notify-complete-fail: 185 | # if: ${{ (needs.cli-token-validation.result != 'failure' || needs.cli-core-token-validation.result != 'failure' ) && (failure() || cancelled()) }} 186 | # needs: [ triggerCliWorkflow ] 187 | # name: Notify Release Failed 188 | # runs-on: ubuntu-latest 189 | # steps: 190 | # - uses: actions/checkout@v2 191 | # - name: Slack Notification 192 | # uses: rtCamp/action-slack-notify@v2 193 | # env: 194 | # SLACK_WEBHOOK: ${{ secrets.ALERT_SLACK_WEB_HOOK }} 195 | # SLACK_COLOR: "#ff3333" 196 | # SLACK_USERNAME: CLI Release Bot 197 | # SLACK_ICON_EMOJI: ":ship:" 198 | # SLACK_TITLE: "Twilio Cli-core" 199 | # SLACK_MESSAGE: 'Release workflow Failed' 200 | # MSG_MINIMAL: actions url 201 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /tmp 6 | /yarn.lock 7 | package-lock.json 8 | node_modules 9 | coverage 10 | *.glksave 11 | twilio-cli-*.tgz 12 | .DS_Store 13 | *.env 14 | .env 15 | 16 | ### Backup package.json files created during release ### 17 | *.bak 18 | 19 | .idea 20 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | require: test/helpers/init.js 2 | recursive: true 3 | reporter: spec 4 | timeout: false 5 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": [ 3 | "text", 4 | "lcov" 5 | ], 6 | "check-coverage": true, 7 | "lines": 90 8 | } 9 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main", 4 | { 5 | "name": "release-feature-branch", 6 | "prerelease": "rc" 7 | } 8 | ], 9 | "plugins": [ 10 | [ 11 | "@semantic-release/commit-analyzer", 12 | { 13 | "preset": "angular", 14 | "releaseRules": [{ 15 | "type": "chore", 16 | "release": "patch" 17 | },{ 18 | "type": "oaiFix", 19 | "release": "patch" 20 | },{ 21 | "type": "oaiFeat", 22 | "release": "minor" 23 | }] 24 | } 25 | ], 26 | [ 27 | "@semantic-release/release-notes-generator", 28 | { 29 | "preset": "conventionalcommits", 30 | "presetConfig": { 31 | "types": [{ 32 | "type": "feat", 33 | "section": "Library - Features" 34 | }, 35 | { 36 | "type": "fix", 37 | "section": "Library - Fixes" 38 | }, 39 | { 40 | "type": "chore", 41 | "section": "Library - Chores" 42 | }, 43 | { 44 | "type": "test", 45 | "section": "Library - Test" 46 | }, 47 | { 48 | "type": "docs", 49 | "section": "Library - Docs" 50 | } 51 | ] 52 | } 53 | } 54 | ], 55 | [ 56 | "@semantic-release/changelog", 57 | { 58 | "changelogFile": "CHANGES.md" 59 | } 60 | ], 61 | "@semantic-release/npm", 62 | [ 63 | "@semantic-release/github", 64 | { 65 | "successComment": false 66 | } 67 | ], 68 | [ 69 | "@semantic-release/git", 70 | { 71 | "assets": [ 72 | "CHANGES.md", 73 | "package.json" 74 | ], 75 | "message": "chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 76 | } 77 | ], 78 | [ 79 | "@semantic-release/exec", 80 | { 81 | "successCmd": "echo '::set-output name=TAG_NAME::${nextRelease.version}'" 82 | } 83 | ] 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach by Process ID", 11 | "processId": "${command:PickProcess}" 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Mocha Tests", 17 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 18 | "args": ["-u", "tdd", "--timeout", "999999", "--colors", "${workspaceFolder}/test/**/*.test.js"], 19 | "internalConsoleOptions": "openOnSessionStart" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jshint.enable": false, 3 | "javascript.implicitProjectConfig.checkJs": false 4 | } 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at open-source@twilio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.0 2 | RUN apt-get update && apt-get install -y libsecret-1-dev 3 | 4 | RUN mkdir /cli-core 5 | WORKDIR /cli-core 6 | COPY . . 7 | RUN npm install 8 | CMD ["npm", "test"] 9 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Issue Summary 10 | A summary of the issue and the environment in which it occurs. If suitable, include the steps required to reproduce the bug. Please feel free to include screenshots, screencasts, or code examples. 11 | 12 | ### Steps to Reproduce 13 | 1. This is the first step 14 | 2. This is the second step 15 | 3. Further steps, etc. 16 | 17 | ### Code Snippet 18 | ```node 19 | # paste code here 20 | ``` 21 | 22 | ### Exception/Log 23 | ``` 24 | # paste exception/log here 25 | ``` 26 | 27 | ### Technical details: 28 | * twilio-cli-core version: 29 | * node version: 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2022, Twilio, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: githooks install test clean 2 | 3 | githooks: 4 | ln -sf ../../githooks/pre-commit .git/hooks/pre-commit 5 | 6 | install: githooks 7 | rm -f package-lock.json 8 | npm install --no-optional 9 | 10 | test: 11 | npm test 12 | 13 | clean: 14 | rm -rf node_modules 15 | 16 | generate-fork-pipeline-changes: 17 | git co main 18 | node .github/scripts/create-fork-pipeline-changes.js $(PWD) $(owner) 19 | 20 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | # Fixes # 18 | 19 | A short description of what this PR does. 20 | 21 | ### Checklist 22 | - [x] I acknowledge that all my contributions will be made under the project's license 23 | - [ ] I have made a material change to the repo (functionality, testing, spelling, grammar) 24 | - [ ] I have read the [Contribution Guidelines](https://github.com/twilio/twilio-cli-core/blob/main/CONTRIBUTING.md) and my PR follows them 25 | - [ ] I have titled the PR appropriately 26 | - [ ] I have updated my branch with the main branch 27 | - [ ] I have added tests that prove my fix is effective or that my feature works 28 | - [ ] I have added the necessary documentation about the functionality in the appropriate .md file 29 | - [ ] I have added inline documentation to the code I modified 30 | 31 | If you have questions, please file a [support ticket](https://twilio.com/help/contact), or create a GitHub Issue in this repository. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twilio-cli-core 2 | 3 | [![Learn with TwilioQuest](https://img.shields.io/static/v1?label=TwilioQuest&message=Learn%20to%20contribute%21&color=F22F46&labelColor=1f243c&style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAASFBMVEUAAAAZGRkcHBwjIyMoKCgAAABgYGBoaGiAgICMjIyzs7PJycnMzMzNzc3UoBfd3d3m5ubqrhfrMEDu7u739/f4vSb/3AD///9tbdyEAAAABXRSTlMAAAAAAMJrBrEAAAKoSURBVHgB7ZrRcuI6EESdyxXGYoNFvMD//+l2bSszRgyUYpFAsXOeiJGmj4NkuWx1Qeh+Ekl9DgEXOBwOx+Px5xyQhDykfgq4wG63MxxaR4ddIkg6Ul3g84vCIcjPBA5gmUMeXESrlukuoK33+33uID8TWeLAdOWsKpJYzwVMB7bOzYSGOciyUlXSn0/ABXTosJ1M1SbypZ4O4MbZuIDMU02PMbauhhHMHXbmebmALIiEbbbbbUrpF1gwE9kFfRNAJaP+FQEXCCTGyJ4ngDrjOFo3jEL5JdqjF/pueR4cCeCGgAtwmuRS6gDwaRiGvu+DMFwSBLTE3+jF8JyuV1okPZ+AC4hDFhCHyHQjdjPHUKFDlHSJkHQXMB3KpSwXNGJPcwwTdZiXlRN0gSp0zpWxNtM0beYE0nRH6QIbO7rawwXaBYz0j78gxjokDuv12gVeUuBD0MDi0OQCLvDaAho4juP1Q/jkAncXqIcCfd+7gAu4QLMACCLxpRsSuQh0igu0C9Svhi7weAGZg50L3IE3cai4IfkNZAC8dfdhsUD3CgKBVC9JE5ABAFzg4QL/taYPAAWrHdYcgfLaIgAXWJ7OV38n1LEF8tt2TH29E+QAoDoO5Ve/LtCQDmKM9kPbvCEBApK+IXzbcSJ0cIGF6e8gpcRhUDogWZ8JnaWjPXc/fNnBBUKRngiHgTUSivSzDRDgHZQOLvBQgf8rRt+VdBUUhwkU6VpJ+xcOwQUqZr+mR0kvBUgv6cB4+37hQAkXqE8PwGisGhJtN4xAHMzrsgvI7rccXqSvKh6jltGlrOHA3Xk1At3LC4QiPdX9/0ndHpGVvTjR4bZA1ypAKgVcwE5vx74ulwIugDt8e/X7JgfkucBMIAr26ndnB4UCLnDOqvteQsHlgX9N4A+c4cW3DXSPbwAAAABJRU5ErkJggg==)](https://twil.io/learn-open-source) 4 | 5 | This module contains core functionality for the twilio-cli. 6 | 7 | ## Requirements 8 | Currently, Node 14+ is supported. We support the [LTS versions](https://nodejs.org/en/about/releases) of Node. 9 | 10 | ## Base commands 11 | 12 | ### BaseCommand 13 | 14 | The base command class for _all_ twilio-cli commands. Includes support for configuration management, logging, and output formatting. 15 | 16 | ### TwilioClientCommand 17 | 18 | A base command class for commands that need a Twilio client to make API requests. Handles loading credentials from the profile configuration. 19 | 20 | ## Services 21 | 22 | ### Output formats 23 | 24 | Formatters to take a JSON array and write to the stdout. Current formatters include: 25 | 26 | - Columns (default, human readable) 27 | - JSON (raw API output) 28 | - TSV 29 | 30 | ### CliRequestClient 31 | 32 | A custom http client for the Twilio helper library to allow us to log API requests as well as modify the User-Agent header. 33 | 34 | ### Usage with proxy 35 | - `HTTP_PROXY`: If using Twilio CLI behind a proxy, set the URL of the proxy in an environment variable called `HTTP_PROXY`. 36 | 37 | ### Config 38 | 39 | Manages the CLI configuration options, such as Twilio profiles and credentials. 40 | 41 | ### Logger 42 | 43 | Standardizes logging output of debug, info, warning, and error messages to stderr (all go to stderr to allow differentiation between command output and logging messages). 44 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | nodejs_version: "8" 3 | cache: 4 | - '%AppData%\npm-cache -> appveyor.yml' 5 | - node_modules -> package-lock.json 6 | 7 | install: 8 | - ps: Install-Product node $env:nodejs_version x64 9 | - npm install 10 | test_script: 11 | - npm test 12 | after_test: 13 | - .\node_modules\.bin\nyc report --reporter text-lcov > coverage.lcov 14 | - ps: | 15 | $env:PATH = 'C:\msys64\usr\bin;' + $env:PATH 16 | Invoke-WebRequest -Uri 'https://codecov.io/bash' -OutFile codecov.sh 17 | bash codecov.sh 18 | 19 | build: off 20 | 21 | -------------------------------------------------------------------------------- /githooks/pre-commit: -------------------------------------------------------------------------------- 1 | make test 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@twilio/cli-core", 3 | "version": "7.27.2", 4 | "description": "Core functionality for the twilio-cli", 5 | "keywords": [ 6 | "twilio" 7 | ], 8 | "homepage": "https://github.com/twilio/twilio-cli-core", 9 | "bugs": "https://github.com/twilio/twilio-cli/issues", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/twilio/twilio-cli-core.git" 13 | }, 14 | "license": "MIT", 15 | "author": "Twilio @twilio", 16 | "main": "src/index.js", 17 | "files": [ 18 | "/bin", 19 | "/src", 20 | "/.github/scripts/update-release.js", 21 | "/.github/scripts/trigger-workflow.js" 22 | ], 23 | "scripts": { 24 | "lint": "eslint --ext js --ext jsx src/ test/", 25 | "lint:fix": "npm run lint -- --fix", 26 | "test": "nyc mocha --forbid-only \"test/**/*.test.js\"", 27 | "posttest": "eslint --ignore-path .gitignore ." 28 | }, 29 | "dependencies": { 30 | "@actions/core": "^1.0.0", 31 | "@actions/github": "^6.0.0", 32 | "@oclif/core": "^1.16.0", 33 | "@oclif/plugin-help": "^5.1.3", 34 | "@oclif/plugin-plugins": "2.1.0", 35 | "@octokit/rest": "^21.1.1", 36 | "axios": "^1.7.4", 37 | "chalk": "^4.1.2", 38 | "columnify": "^1.5.4", 39 | "fs-extra": "^9.0.1", 40 | "https-proxy-agent": "^5.0.0", 41 | "inquirer": "^8.0.0", 42 | "qs": "^6.9.4", 43 | "semver": "^7.5.2", 44 | "tsv": "^0.2.0", 45 | "twilio": "^5.3.0", 46 | "proxyquire": "^2.1.3" 47 | }, 48 | "devDependencies": { 49 | "@oclif/test": "^1.2.6", 50 | "@semantic-release/changelog": "^5.0.1", 51 | "@semantic-release/exec": "^5.0.0", 52 | "@semantic-release/git": "^9.0.0", 53 | "@twilio/cli-test": "^2.1.0", 54 | "chai": "^4.2.0", 55 | "conventional-changelog-conventionalcommits": "^4.6.0", 56 | "eslint": "^8.20.0", 57 | "eslint-config-twilio": "~2.0.0", 58 | "eslint-config-twilio-mocha": "~2.0.0", 59 | "mocha": "^10.0.0", 60 | "mock-fs": "^5.5.0", 61 | "nock": "^13.0.2", 62 | "nyc": "^15.1.0", 63 | "sinon": "^9.0.2", 64 | "tmp": "^0.2.1" 65 | }, 66 | "engines": { 67 | "node": ">=14.0.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=twilio_twilio-cli-core 2 | sonar.projectName=twilio-cli-core 3 | sonar.organization=twilio 4 | 5 | sonar.sources=src/ 6 | sonar.tests=test/ 7 | # sonar.test.exclusions= < No exclusions currently > 8 | sonar.javascript.lcov.reportPaths=coverage/lcov.info 9 | -------------------------------------------------------------------------------- /src/base-commands/base-command.js: -------------------------------------------------------------------------------- 1 | const { Command, Flags: oclifFlags } = require('@oclif/core'); 2 | const { Errors } = require('@oclif/core'); 3 | 4 | const pkg = require('../../package.json'); 5 | const MessageTemplates = require('../services/messaging/templates'); 6 | const { Config, ConfigData, PluginConfig } = require('../services/config'); 7 | const { TwilioCliError } = require('../services/error'); 8 | const { logger, LoggingLevel } = require('../services/messaging/logging'); 9 | const { OutputFormats } = require('../services/output-formats'); 10 | const { getCommandPlugin, requireInstall } = require('../services/require-install'); 11 | const { instanceOf } = require('../services/javascript-utilities'); 12 | 13 | let inquirer; // We'll lazy-load this only when it's needed. 14 | 15 | const DEFAULT_LOG_LEVEL = 'info'; 16 | const DEFAULT_OUTPUT_FORMAT = 'columns'; 17 | 18 | class BaseCommand extends Command { 19 | constructor(argv, config) { 20 | super(argv, config); 21 | this.configFile = new Config(''); 22 | this.userConfig = new ConfigData(); 23 | } 24 | 25 | get inquirer() { 26 | if (!inquirer) { 27 | inquirer = require('inquirer'); 28 | } 29 | return inquirer; 30 | } 31 | 32 | async run() { 33 | const { args, flags } = await this.parse(this.constructor); 34 | this.args = args; 35 | this.flags = flags; 36 | await this.loadConfig(); 37 | 38 | this.outputProcessor = this.flags.silent 39 | ? OutputFormats.none 40 | : OutputFormats[this.flags['cli-output-format'] || DEFAULT_OUTPUT_FORMAT]; 41 | 42 | this.logger = logger; 43 | // Give precedence to silent flag 44 | this.logger.config.level = this.flags.silent 45 | ? LoggingLevel.none 46 | : LoggingLevel[flags['cli-log-level'] || DEFAULT_LOG_LEVEL]; 47 | 48 | this.logger.debug(`Config File: ${this.configFile.filePath}`); 49 | 50 | // Replace oclif's output commands 51 | this.log = this.logger.info; 52 | this.error = this.logger.error; 53 | this.warn = this.logger.warn; 54 | } 55 | 56 | async loadConfig() { 57 | this.configFile = new Config(this.config.configDir); 58 | this.userConfig = await this.configFile.load(); 59 | } 60 | 61 | async catch(error) { 62 | if (!this.logger || instanceOf(error, Errors.CLIError)) { 63 | return super.catch(error); 64 | } 65 | 66 | if (instanceOf(error, TwilioCliError)) { 67 | // User/API errors 68 | if (this.flags['cli-output-format'] === 'json') { 69 | this.output(error.data); 70 | } else { 71 | this.logger.error(error.message); 72 | this.logger.debug(error.stack); 73 | } 74 | let code = 1; 75 | if (error.exitCode) { 76 | code = parseInt(error.exitCode.toString().substring(0, 2), 10); 77 | } 78 | this.exit(code); 79 | } else { 80 | // System errors 81 | let url = ''; 82 | try { 83 | url = this.getIssueUrl(getCommandPlugin(this)); 84 | } catch (e) { 85 | // No-op 86 | } 87 | 88 | this.logger.error(MessageTemplates.unexpectedError({ url })); 89 | this.logger.debug(error.message); 90 | this.logger.debug(error.stack); 91 | this.exit(1); 92 | } 93 | 94 | throw error; 95 | } 96 | 97 | getIssueUrl(plugin) { 98 | const getPropertyUrl = (value) => value && (value.url || value); 99 | const getPackageUrl = (pjson) => 100 | getPropertyUrl(pjson.bugs) || getPropertyUrl(pjson.homepage) || getPropertyUrl(pjson.repository); 101 | 102 | /* 103 | * If we found the plugin and an issue URL for it, use it. Otherwise 104 | * fallback to our own issue URL. 105 | */ 106 | return (plugin && getPackageUrl(plugin.pjson)) || getPackageUrl(pkg); 107 | } 108 | 109 | /** 110 | * Drops the week day and timezone name from the result of Date.toString(). 111 | * 112 | * In: "Fri May 24 2019 11:43:11 GMT-0600 (MDT)" 113 | * Out: "May 24 2019 11:43:11 GMT-0600" 114 | * 115 | * @param {string} value - date string to sanitize 116 | * @returns {string} the sanitized date string 117 | */ 118 | sanitizeDateString(value) { 119 | return value.slice(4, 33); 120 | } 121 | 122 | output(fullData, properties, options) { 123 | if (this.flags['no-header']) { 124 | if (options) { 125 | options.showHeaders = false; 126 | } else { 127 | options = { showHeaders: false }; 128 | } 129 | } 130 | if (!this.outputProcessor) { 131 | // Silenced output 132 | return; 133 | } 134 | 135 | const dataArray = fullData.constructor === Array ? fullData : [fullData]; 136 | 137 | if (dataArray.length === 0) { 138 | this.logger.info('No results'); 139 | return; 140 | } 141 | 142 | const limitedData = properties ? this.getLimitedData(dataArray, properties) : null; 143 | 144 | process.stdout.write(`${this.outputProcessor(dataArray, limitedData || dataArray, options)}\n`); 145 | } 146 | 147 | getLimitedData(dataArray, properties) { 148 | const invalidPropertyNames = new Set(); 149 | const propNames = properties.split(',').map((p) => p.trim()); 150 | const limitedData = dataArray.map((fullItem) => { 151 | const limitedItem = {}; 152 | 153 | propNames.forEach((p) => { 154 | let propValue = fullItem[p]; 155 | 156 | if (propValue === undefined) { 157 | invalidPropertyNames.add(p); 158 | return; 159 | } 160 | 161 | if (propValue instanceof Date) { 162 | const dateString = propValue.toString(); 163 | propValue = this.sanitizeDateString(dateString); 164 | } else if (typeof propValue === 'object') { 165 | propValue = JSON.stringify(propValue); 166 | } 167 | 168 | limitedItem[p] = propValue; 169 | }); 170 | 171 | return limitedItem; 172 | }); 173 | 174 | if (invalidPropertyNames.size > 0) { 175 | const warn = this.logger.warn.bind(this.logger); 176 | invalidPropertyNames.forEach((p) => { 177 | warn(`"${p}" is not a valid property name.`); 178 | }); 179 | } 180 | 181 | return limitedData; 182 | } 183 | 184 | getPromptMessage(message) { 185 | // Drop the trailing period and put a colon at the end of the message. 186 | return message.trim().replace(/[.:]?$/, ':'); 187 | } 188 | 189 | async install(name) { 190 | return requireInstall(name, this); 191 | } 192 | 193 | get pluginConfig() { 194 | if (!this._pluginConfig) { 195 | const plugin = getCommandPlugin(this); 196 | this._pluginConfig = new PluginConfig(this.config.configDir, plugin.name); 197 | } 198 | return this._pluginConfig; 199 | } 200 | 201 | async getPluginConfig() { 202 | return this.pluginConfig.getConfig(); 203 | } 204 | 205 | async setPluginConfig(config) { 206 | return this.pluginConfig.setConfig(config); 207 | } 208 | } 209 | 210 | BaseCommand.flags = { 211 | 'cli-log-level': oclifFlags.enum({ 212 | char: 'l', 213 | helpLabel: '-l', 214 | default: DEFAULT_LOG_LEVEL, 215 | options: Object.keys(LoggingLevel), 216 | description: 'Level of logging messages.', 217 | }), 218 | 219 | 'cli-output-format': oclifFlags.enum({ 220 | char: 'o', 221 | helpLabel: '-o', 222 | default: DEFAULT_OUTPUT_FORMAT, 223 | options: Object.keys(OutputFormats), 224 | description: 'Format of command output.', 225 | }), 226 | 227 | silent: oclifFlags.boolean({ 228 | description: 'Suppress output and logs. This is a shorthand for "-l none -o none".', 229 | default: false, 230 | }), 231 | }; 232 | 233 | module.exports = BaseCommand; 234 | -------------------------------------------------------------------------------- /src/base-commands/twilio-client-command.js: -------------------------------------------------------------------------------- 1 | const { Flags: flags } = require('@oclif/core'); 2 | 3 | const BaseCommand = require('./base-command'); 4 | const CliRequestClient = require('../services/cli-http-client'); 5 | const { TwilioApiClient, TwilioApiFlags } = require('../services/twilio-api'); 6 | const { TwilioCliError } = require('../services/error'); 7 | const { translateValues, instanceOf } = require('../services/javascript-utilities'); 8 | const { camelCase, kebabCase } = require('../services/naming-conventions'); 9 | const { ACCESS_DENIED, HELP_ENVIRONMENT_VARIABLES } = require('../services/messaging/help-messages'); 10 | 11 | // CLI flags are kebab-cased, whereas API flags are PascalCased. 12 | const CliFlags = translateValues(TwilioApiFlags, kebabCase); 13 | 14 | const ACCESS_DENIED_CODE = 20003; 15 | 16 | class TwilioClientCommand extends BaseCommand { 17 | constructor(argv, config) { 18 | super(argv, config); 19 | this.httpClient = undefined; 20 | this.twilio = undefined; 21 | this.twilioApi = undefined; 22 | } 23 | 24 | async run() { 25 | await super.run(); 26 | 27 | // check if profile flag is required as per the config 28 | if (this.userConfig.requireProfileInput && !this.flags.profile) { 29 | throw new TwilioCliError( 30 | `Error: Missing required flag:\n -p, --profile PROFILE ${TwilioClientCommand.flags.profile.description} To disable this check run:\n\n twilio config:set --no-require-profile-input`, 31 | ); 32 | } 33 | this.currentProfile = this.userConfig.getProfileById(this.flags.profile); 34 | const pluginName = (this.config.userAgent || ' ').split(' ')[0]; 35 | 36 | const reportUnconfigured = (verb, message = '', commandName = 'create') => { 37 | const profileParam = this.flags.profile ? ` --profile "${this.flags.profile}"` : ''; 38 | throw new TwilioCliError( 39 | `To ${verb} the profile, run:\n\n twilio profiles:${commandName}${profileParam}${message}`, 40 | ); 41 | }; 42 | 43 | if (!this.currentProfile) { 44 | const profileName = this.flags.profile ? ` "${this.flags.profile}"` : ''; 45 | if (Object.keys(this.userConfig.profiles).length !== 0 && !profileName) { 46 | this.logger.error(`There is no active profile set.`); 47 | reportUnconfigured('activate', '', 'use'); 48 | } else { 49 | this.logger.error(`Could not find profile${profileName}.`); 50 | reportUnconfigured('create', `\n\n${HELP_ENVIRONMENT_VARIABLES}`); 51 | } 52 | } 53 | 54 | this.logger.debug(`Using profile: ${this.currentProfile.id}`); 55 | 56 | if (!this.currentProfile.apiKey || !this.currentProfile.apiSecret) { 57 | this.logger.error(`Could not get credentials for profile "${this.currentProfile.id}".`); 58 | reportUnconfigured('reconfigure'); 59 | } 60 | 61 | this.httpClient = new CliRequestClient(this.id, this.logger, undefined, pluginName); 62 | } 63 | 64 | async catch(error) { 65 | /* 66 | * Append to the error message when catching API access denied errors with 67 | * profile-auth (i.e., standard API key auth). 68 | */ 69 | if (instanceOf(error, TwilioCliError) && error.exitCode === ACCESS_DENIED_CODE) { 70 | if (!this.currentProfile.id.startsWith('${TWILIO')) { 71 | // Auth *not* using env vars. 72 | error.message += `\n\n${ACCESS_DENIED}`; 73 | } 74 | } 75 | 76 | return super.catch(error); 77 | } 78 | 79 | parseProperties() { 80 | if (!this.constructor.PropertyFlags) { 81 | return null; 82 | } 83 | 84 | let updatedProperties = null; 85 | Object.keys(this.constructor.PropertyFlags).forEach((propName) => { 86 | if (this.flags[propName] !== undefined) { 87 | updatedProperties = updatedProperties || {}; 88 | const paramName = camelCase(propName); 89 | updatedProperties[paramName] = this.flags[propName]; 90 | } 91 | }); 92 | 93 | return updatedProperties; 94 | } 95 | 96 | async updateResource(resource, resourceSid, updatedProperties) { 97 | const results = { 98 | sid: resourceSid, 99 | result: '?', 100 | }; 101 | 102 | updatedProperties = updatedProperties || this.parseProperties(); 103 | this.logger.debug('Updated properties:'); 104 | this.logger.debug(updatedProperties); 105 | 106 | if (updatedProperties) { 107 | try { 108 | await resource(resourceSid).update(updatedProperties); 109 | results.result = 'Success'; 110 | Object.assign(results, updatedProperties); 111 | } catch (error) { 112 | this.logger.error(error.message); 113 | results.result = 'Error'; 114 | } 115 | } else { 116 | this.logger.warn('Nothing to update.'); 117 | results.result = 'Nothing to update'; 118 | } 119 | 120 | return results; 121 | } 122 | 123 | get twilioClient() { 124 | if (!this.twilio) { 125 | this.twilio = this.buildClient(require('twilio')); 126 | } 127 | return this.twilio; 128 | } 129 | 130 | get twilioApiClient() { 131 | if (!this.twilioApi) { 132 | this.twilioApi = this.buildClient(TwilioApiClient); 133 | } 134 | return this.twilioApi; 135 | } 136 | 137 | buildClient(ClientClass) { 138 | return new ClientClass(this.currentProfile.apiKey, this.currentProfile.apiSecret, { 139 | accountSid: this.flags[CliFlags.ACCOUNT_SID] || this.currentProfile.accountSid, 140 | edge: process.env.TWILIO_EDGE || this.userConfig.edge, 141 | region: this.currentProfile.region, 142 | httpClient: this.httpClient, 143 | }); 144 | } 145 | } 146 | 147 | TwilioClientCommand.flags = { 148 | profile: flags.string({ 149 | char: 'p', 150 | description: 'Shorthand identifier for your profile.', 151 | }), 152 | ...BaseCommand.flags, 153 | }; 154 | 155 | TwilioClientCommand.accountSidFlag = { 156 | [CliFlags.ACCOUNT_SID]: flags.string({ 157 | description: 'Access resources for the specified account.', 158 | }), 159 | }; 160 | 161 | TwilioClientCommand.limitFlags = { 162 | [CliFlags.LIMIT]: flags.string({ 163 | description: `The maximum number of resources to return. Use '--${CliFlags.NO_LIMIT}' to disable.`, 164 | default: 50, 165 | exclusive: [CliFlags.NO_LIMIT], 166 | }), 167 | [CliFlags.NO_LIMIT]: flags.boolean({ 168 | default: false, 169 | hidden: true, 170 | exclusive: [CliFlags.LIMIT], 171 | }), 172 | }; 173 | 174 | TwilioClientCommand.noHeader = { 175 | 'no-header': flags.boolean({ 176 | description: 'Skip including of headers while listing the data.', 177 | }), 178 | }; 179 | 180 | module.exports = TwilioClientCommand; 181 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseCommands: { 3 | BaseCommand: require('./base-commands/base-command'), 4 | TwilioClientCommand: require('./base-commands/twilio-client-command'), 5 | }, 6 | services: { 7 | TwilioApi: require('./services/twilio-api'), 8 | CliRequestClient: require('./services/cli-http-client'), 9 | config: require('./services/config'), 10 | error: require('./services/error'), 11 | JSUtils: require('./services/javascript-utilities'), 12 | logging: require('./services/messaging/logging'), 13 | templating: require('./services/messaging/templating'), 14 | namingConventions: require('./services/naming-conventions'), 15 | outputFormats: require('./services/output-formats'), 16 | }, 17 | configureEnv: require('./services/env'), 18 | releaseScripts: { 19 | UpdateRelease: require('../.github/scripts/update-release'), 20 | TriggerWorkflow: require('../.github/scripts/trigger-workflow'), 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/services/api-schema/json-converter.js: -------------------------------------------------------------------------------- 1 | const { logger } = require('../messaging/logging'); 2 | 3 | const SCHEMA_TYPE_TO_CONVERT_FUNC_MAP = { 4 | array: 'convertArray', 5 | boolean: 'convertBoolean', 6 | integer: 'convertInteger', 7 | number: 'convertNumber', 8 | object: 'convertObject', 9 | string: 'convertString', 10 | }; 11 | 12 | /** 13 | * A JSON Schema conversion orchestrator. It accepts a JSON schema and value 14 | * and converts any fields that require ... conversion. 15 | */ 16 | class JsonSchemaConverter { 17 | constructor() { 18 | this.logger = logger; 19 | } 20 | 21 | convertSchema(schema, value) { 22 | if (schema) { 23 | if (!value) { 24 | if (!schema.nullable) { 25 | this.logger.debug(`Null value found when nullable not allowed by schema: ${JSON.stringify(schema)}`); 26 | } 27 | 28 | return value; 29 | } 30 | 31 | const convertFunc = SCHEMA_TYPE_TO_CONVERT_FUNC_MAP[schema.type]; 32 | 33 | if (convertFunc) { 34 | value = this[convertFunc](schema, value); 35 | } else { 36 | this.logger.debug(`No conversion function for "${schema.type}" schema type`); 37 | } 38 | } 39 | 40 | return value; 41 | } 42 | 43 | convertArray(schema, value) { 44 | // Recurse into the value using the schema's items schema. 45 | return value.map((item) => this.convertSchema(schema.items, item)); 46 | } 47 | 48 | convertObject(schema, value) { 49 | const converted = {}; 50 | 51 | let { properties } = schema; 52 | 53 | /* 54 | * If the schema has no properties, it is a free-form object with arbitrary 55 | * property/value pairs. We'll map the object's keys to null-schemas so 56 | * they'll be processed as-is (i.e., no type so just use the value). 57 | */ 58 | if (!properties) { 59 | const nameList = Object.keys(value).map((name) => ({ [name]: null })); 60 | 61 | properties = Object.assign({}, ...nameList); 62 | } 63 | 64 | /* 65 | * Convert each object property and store it in the converted object, if a 66 | * value was provided. 67 | */ 68 | Object.entries(properties).forEach(([name, propSchema]) => { 69 | const { propName, propValue } = this.convertObjectProperty(propSchema, name, value[name]); 70 | 71 | if (propValue !== undefined) { 72 | converted[propName] = propValue; 73 | } 74 | }); 75 | 76 | return converted; 77 | } 78 | 79 | convertObjectProperty(propSchema, propName, propValue) { 80 | if (propValue !== undefined) { 81 | propValue = this.convertSchema(propSchema, propValue); 82 | } 83 | 84 | return { propName, propValue }; 85 | } 86 | 87 | convertBoolean(schema, value) { 88 | return value; 89 | } 90 | 91 | convertInteger(schema, value) { 92 | return value; 93 | } 94 | 95 | convertNumber(schema, value) { 96 | return value; 97 | } 98 | 99 | convertString(schema, value) { 100 | return value; 101 | } 102 | } 103 | 104 | module.exports = JsonSchemaConverter; 105 | -------------------------------------------------------------------------------- /src/services/api-schema/twilio-converter.js: -------------------------------------------------------------------------------- 1 | const JsonSchemaConverter = require('./json-converter'); 2 | const { camelCase } = require('../naming-conventions'); 3 | 4 | const STRING_FORMAT_TO_CONVERT_FUNC_MAP = { 5 | 'date-time': 'convertDateTime', 6 | 'date-time-rfc-2822': 'convertDateTime', 7 | uri: 'convertUri', 8 | }; 9 | 10 | /** 11 | * A Twilio extension of the JSON Schema converter. We do additional date-time 12 | * conversion and also camelCase object property names (keys). 13 | */ 14 | class TwilioSchemaConverter extends JsonSchemaConverter { 15 | convertObjectProperty(propSchema, propName, propValue) { 16 | // Convert the property *and* camelCase the key to make it more JSON-ic. 17 | if (propValue !== undefined) { 18 | propValue = this.convertSchema(propSchema, propValue); 19 | } 20 | 21 | if (propSchema) { 22 | propName = camelCase(propName); 23 | } 24 | 25 | return { propName, propValue }; 26 | } 27 | 28 | convertString(schema, value) { 29 | if (schema.format) { 30 | const validateFunc = STRING_FORMAT_TO_CONVERT_FUNC_MAP[schema.format]; 31 | 32 | if (validateFunc) { 33 | value = this[validateFunc](schema, value); 34 | } else { 35 | this.logger.debug(`No conversion function for "${schema.format}" schema format`); 36 | } 37 | } 38 | 39 | return value; 40 | } 41 | 42 | convertDateTime(schema, value) { 43 | // The date constructor accepts both ISO 8601 and RFC 2822 date-time formats. 44 | const dateValue = new Date(value); 45 | 46 | if (isNaN(dateValue)) { 47 | this.logger.debug(`Date-Time value "${value}" is not properly formatted for "${schema.format}" schema format`); 48 | return value; 49 | } 50 | 51 | return dateValue; 52 | } 53 | 54 | convertUri(schema, value) { 55 | // We don't currently do any URI conversion. This just keeps from logging non-helpful debug. 56 | return value; 57 | } 58 | } 59 | 60 | module.exports = TwilioSchemaConverter; 61 | -------------------------------------------------------------------------------- /src/services/cli-http-client.js: -------------------------------------------------------------------------------- 1 | const http_ = require('http'); 2 | const https = require('https'); 3 | const os = require('os'); 4 | 5 | const HttpsProxyAgent = require('https-proxy-agent'); 6 | const qs = require('qs'); 7 | 8 | const pkg = require('../../package.json'); 9 | const { TwilioCliError } = require('../services/error'); 10 | const { NETWORK_ERROR } = require('../services/messaging/help-messages'); 11 | 12 | const NETWORK_ERROR_CODES = new Set(['ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNABORTED']); 13 | 14 | const STANDARD_HEADERS = ['user-agent', 'accept-charset', 'connection', 'authorization', 'accept', 'content-type']; 15 | 16 | class CliRequestClient { 17 | constructor(commandName, logger, http, extensions = ' ') { 18 | this.commandName = commandName; 19 | this.logger = logger; 20 | this.pluginName = extensions; 21 | this.http = http || require('axios'); 22 | if (process.env.HTTP_PROXY) { 23 | /* 24 | * If environment variable HTTP_PROXY is set, 25 | * add an appropriate httpsAgent to axios. 26 | */ 27 | this.http.defaults.proxy = false; 28 | this.http.defaults.httpsAgent = new HttpsProxyAgent(process.env.HTTP_PROXY); 29 | } 30 | } 31 | 32 | /** 33 | * Make an HTTP request. 34 | * 35 | * @param {object} opts - The options argument 36 | * @param {string} opts.method - The http method 37 | * @param {string} opts.uri - The request uri 38 | * @param {string} [opts.username] - The username used for auth 39 | * @param {string} [opts.password] - The password used for auth 40 | * @param {object} [opts.headers] - The request headers 41 | * @param {object} [opts.params] - The request params 42 | * @param {object} [opts.data] - The request data 43 | * @param {int} [opts.timeout=30000] - The request timeout in milliseconds 44 | * @param {boolean} [opts.allowRedirects] - Should the client follow redirects 45 | * @param {boolean} [opts.forever] - Set to true to use the forever-agent 46 | */ 47 | async request(opts) { 48 | opts = opts || {}; 49 | if (!opts.method) { 50 | throw new Error('http method is required'); 51 | } 52 | 53 | if (!opts.uri) { 54 | throw new Error('uri is required'); 55 | } 56 | 57 | const headers = opts.headers || {}; 58 | 59 | if (!headers.Connection && !headers.connection) { 60 | headers.Connection = 'close'; 61 | } 62 | 63 | if (opts.username && opts.password) { 64 | const b64Auth = Buffer.from(`${opts.username}:${opts.password}`).toString('base64'); 65 | headers.Authorization = `Basic ${b64Auth}`; 66 | } 67 | // User-Agent will have these info : / ( ) 68 | const componentInfo = []; 69 | componentInfo.push(`(${os.platform()} ${os.arch()})`); // ( ) 70 | const userAgentArr = (headers['User-Agent'] || ' ').split(' '); // contains twilio-node/version (darwin x64) node/v16.4.2 71 | componentInfo.push(userAgentArr[0]); // Api client version 72 | componentInfo.push(userAgentArr[3]); // nodejs version 73 | componentInfo.push(this.commandName); // cli-command 74 | headers['User-Agent'] = `${this.pluginName} ${pkg.name}/${pkg.version} ${componentInfo.filter(Boolean).join(' ')}`; 75 | 76 | const options = { 77 | timeout: opts.timeout || 30000, 78 | maxRedirects: opts.allowRedirects ? 10 : 0, 79 | url: opts.uri, 80 | method: opts.method, 81 | headers, 82 | httpAgent: opts.forever ? new http_.Agent({ keepAlive: true }) : undefined, 83 | httpsAgent: opts.forever ? new https.Agent({ keepAlive: true }) : undefined, 84 | validateStatus: (status) => { 85 | return status >= 100 && status < 600; 86 | }, 87 | }; 88 | 89 | if (opts.data) { 90 | options.data = qs.stringify(opts.data, { arrayFormat: 'repeat' }); 91 | } 92 | 93 | if (opts.params) { 94 | options.params = opts.params; 95 | options.paramsSerializer = (params) => { 96 | return qs.stringify(params, { arrayFormat: 'repeat' }); 97 | }; 98 | } 99 | 100 | this.lastRequest = options; 101 | this.logRequest(options); 102 | 103 | try { 104 | const response = await this.http(options); 105 | 106 | this.logger.debug(`response.statusCode: ${response.status}`); 107 | this.logger.debug(`response.headers: ${JSON.stringify(response.headers)}`); 108 | 109 | if (response.status < 200 || response.status >= 400) { 110 | const { message, code } = this.formatErrorMessage(response.data); 111 | throw new TwilioCliError(message, code, response.data); 112 | } 113 | 114 | return { 115 | body: response.data, 116 | statusCode: response.status, 117 | headers: response.headers, 118 | }; 119 | } catch (error) { 120 | if (NETWORK_ERROR_CODES.has(error.code)) { 121 | throw new TwilioCliError(NETWORK_ERROR); 122 | } 123 | 124 | throw error; 125 | } 126 | } 127 | 128 | logRequest(options) { 129 | this.logger.debug('-- BEGIN Twilio API Request --'); 130 | this.logger.debug(`${options.method} ${options.url}`); 131 | 132 | if (options.data) { 133 | this.logger.debug('Form data:'); 134 | this.logger.debug(options.data); 135 | } 136 | 137 | if (options.params && Object.keys(options.params).length > 0) { 138 | this.logger.debug('Querystring:'); 139 | this.logger.debug(options.params); 140 | } 141 | 142 | const customHeaders = Object.keys(options.headers).filter((header) => { 143 | return !STANDARD_HEADERS.includes(header.toLowerCase()); 144 | }); 145 | if (customHeaders) { 146 | this.logger.debug('Custom HTTP Headers:'); 147 | customHeaders.forEach((header) => this.logger.debug(`${header}: ${options.headers[header]}`)); 148 | } 149 | 150 | this.logger.debug(`User-Agent: ${options.headers['User-Agent']}`); 151 | this.logger.debug('-- END Twilio API Request --'); 152 | } 153 | 154 | /* eslint-disable camelcase */ 155 | // In the rare event parameters are missing, display a readable message 156 | formatErrorMessage({ code, message, more_info, details }) { 157 | const moreInfoMessage = more_info ? `See ${more_info} for more info.` : ''; 158 | const error = { 159 | message: `Error code ${code || 'N/A'} from Twilio: ${message || 'No message provided'}. ${moreInfoMessage}`, 160 | code, 161 | details, 162 | }; 163 | 164 | return error; 165 | } 166 | /* eslint-enable camelcase */ 167 | } 168 | 169 | module.exports = CliRequestClient; 170 | -------------------------------------------------------------------------------- /src/services/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | const path = require('path'); 3 | 4 | const fs = require('fs-extra'); 5 | 6 | const MessageTemplates = require('./messaging/templates'); 7 | 8 | const CLI_NAME = 'twilio-cli'; 9 | 10 | class ConfigDataProfile { 11 | constructor(accountSid, region, apiKey, apiSecret) { 12 | this.accountSid = accountSid; 13 | this.region = region; 14 | this.apiKey = apiKey; 15 | this.apiSecret = apiSecret; 16 | } 17 | } 18 | 19 | class ConfigDataProject { 20 | constructor(id, accountSid, region) { 21 | this.id = id; 22 | this.accountSid = accountSid; 23 | this.region = region; 24 | } 25 | } 26 | 27 | class ConfigData { 28 | constructor() { 29 | this.edge = undefined; 30 | this.email = {}; 31 | this.prompts = {}; 32 | this.projects = []; 33 | this.activeProfile = null; 34 | this.profiles = {}; 35 | this.requireProfileInput = undefined; 36 | } 37 | 38 | getProfileFromEnvironment() { 39 | const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_API_KEY, TWILIO_API_SECRET, TWILIO_REGION } = process.env; 40 | if (!TWILIO_ACCOUNT_SID) { 41 | return undefined; 42 | } 43 | 44 | if (TWILIO_API_KEY && TWILIO_API_SECRET) { 45 | return { 46 | // eslint-disable-next-line no-template-curly-in-string 47 | id: '${TWILIO_API_KEY}/${TWILIO_API_SECRET}', 48 | accountSid: TWILIO_ACCOUNT_SID, 49 | apiKey: TWILIO_API_KEY, 50 | apiSecret: TWILIO_API_SECRET, 51 | region: TWILIO_REGION, 52 | }; 53 | } 54 | 55 | if (TWILIO_AUTH_TOKEN) { 56 | return { 57 | // eslint-disable-next-line no-template-curly-in-string 58 | id: '${TWILIO_ACCOUNT_SID}/${TWILIO_AUTH_TOKEN}', 59 | accountSid: TWILIO_ACCOUNT_SID, 60 | apiKey: TWILIO_ACCOUNT_SID, 61 | apiSecret: TWILIO_AUTH_TOKEN, 62 | region: TWILIO_REGION, 63 | }; 64 | } 65 | 66 | return undefined; 67 | } 68 | 69 | getProfileFromConfigFileById(profileId) { 70 | let profile = this.profiles[profileId]; 71 | if (!profile) { 72 | profile = this.projects.find((p) => p.id === profileId); 73 | } 74 | return profile; 75 | } 76 | 77 | getProfileById(profileId) { 78 | let profile; 79 | 80 | if (!profileId) { 81 | profile = this.getProfileFromEnvironment(); 82 | } 83 | 84 | if (!profile) { 85 | if (profileId) { 86 | // Clean the profile ID. 87 | profileId = this.sanitize(profileId); 88 | profile = this.getProfileFromConfigFileById(profileId); 89 | // Explicitly add `id` to the returned profile 90 | if (profile && !profile.hasOwnProperty('id')) { 91 | profile.id = profileId; 92 | } 93 | } else { 94 | profile = this.getActiveProfile(); 95 | } 96 | } 97 | 98 | return profile; 99 | } 100 | 101 | setActiveProfile(profileId) { 102 | if (profileId) { 103 | const profile = this.getProfileById(profileId); 104 | 105 | if (profile) { 106 | this.activeProfile = profile.id; 107 | return profile; 108 | } 109 | } 110 | 111 | return undefined; 112 | } 113 | 114 | getActiveProfile() { 115 | let profile; 116 | if (this.projects.length > 0 || Object.keys(this.profiles).length > 0) { 117 | if (this.activeProfile) { 118 | profile = this.getProfileFromConfigFileById(this.activeProfile); 119 | } 120 | 121 | if (!profile) { 122 | profile = this.projects[0]; 123 | } 124 | } 125 | return profile; 126 | } 127 | 128 | removeProfile(profileToRemove) { 129 | if (this.profiles[profileToRemove.id]) { 130 | delete this.profiles[profileToRemove.id]; 131 | } else { 132 | this.projects = this.projects.filter((profile) => { 133 | return profile.id !== profileToRemove.id; 134 | }); 135 | } 136 | if (profileToRemove.id === this.activeProfile) { 137 | this.activeProfile = null; 138 | } 139 | } 140 | 141 | addProfile(id, accountSid, region, apiKey, apiSecret) { 142 | // Clean all the inputs. 143 | id = this.sanitize(id); 144 | accountSid = this.sanitize(accountSid); 145 | region = this.sanitize(region); 146 | 147 | const existing = this.getProfileById(id); 148 | 149 | // Remove if existing in historical projects. 150 | if (existing) { 151 | this.projects = this.projects.filter((p) => p.id !== existing.id); 152 | } 153 | 154 | // Update profiles object 155 | this.profiles[id] = new ConfigDataProfile(accountSid, region, apiKey, apiSecret); 156 | } 157 | 158 | addProject(id, accountSid, region) { 159 | id = this.sanitize(id); 160 | accountSid = this.sanitize(accountSid); 161 | region = this.sanitize(region); 162 | 163 | this.projects.push(new ConfigDataProject(id, accountSid, region)); 164 | } 165 | 166 | isPromptAcked(promptId) { 167 | const prompt = this.prompts[promptId]; 168 | 169 | return Boolean(prompt && prompt.acked); 170 | } 171 | 172 | ackPrompt(promptId) { 173 | let prompt = this.prompts[promptId]; 174 | 175 | if (!prompt) { 176 | prompt = {}; 177 | this.prompts[promptId] = prompt; 178 | } 179 | 180 | prompt.acked = true; 181 | } 182 | 183 | loadFromObject(configObj) { 184 | this.edge = configObj.edge; 185 | this.email = configObj.email || {}; 186 | this.requireProfileInput = configObj.requireProfileInput; 187 | this.prompts = configObj.prompts || {}; 188 | // Note the historical 'projects' naming. 189 | configObj.projects = configObj.projects || []; 190 | configObj.projects.forEach((project) => this.addProject(project.id, project.accountSid, project.region)); 191 | this.profiles = configObj.profiles || {}; 192 | this.setActiveProfile(configObj.activeProject); 193 | } 194 | 195 | sanitize(string) { 196 | // Trim whitespace if given a non-null string. 197 | return string ? string.trim() : string; 198 | } 199 | } 200 | 201 | class Config { 202 | constructor(configDir) { 203 | this.configDir = configDir; 204 | this.filePath = path.join(configDir, 'config.json'); 205 | } 206 | 207 | async load() { 208 | const configData = new ConfigData(); 209 | 210 | if (!fs.existsSync(this.filePath)) { 211 | return configData; 212 | } 213 | 214 | configData.loadFromObject(await fs.readJSON(this.filePath)); 215 | return configData; 216 | } 217 | 218 | async save(configData) { 219 | configData = { 220 | edge: configData.edge, 221 | email: configData.email, 222 | requireProfileInput: configData.requireProfileInput, 223 | prompts: configData.prompts, 224 | // Note the historical 'projects' naming. 225 | projects: configData.projects, 226 | profiles: configData.profiles, 227 | activeProject: configData.activeProfile, 228 | }; 229 | 230 | fs.mkdirSync(this.configDir, { recursive: true }); 231 | await fs.writeJSON(this.filePath, configData, { flag: 'w' }); 232 | 233 | return MessageTemplates.configSaved({ path: this.filePath }); 234 | } 235 | } 236 | 237 | class PluginConfig { 238 | constructor(configDir, pluginName) { 239 | this.filePath = path.join(configDir, 'plugins', pluginName, 'config.json'); 240 | } 241 | 242 | async getConfig() { 243 | try { 244 | return await fs.readJSON(this.filePath, { encoding: 'utf-8' }); 245 | } catch (error) { 246 | return {}; 247 | } 248 | } 249 | 250 | async setConfig(config) { 251 | try { 252 | await fs.writeJSON(this.filePath, config); 253 | } catch (error) { 254 | await fs.mkdir(path.dirname(this.filePath), { recursive: true }); 255 | await fs.writeJSON(this.filePath, config); 256 | } 257 | } 258 | } 259 | 260 | module.exports = { 261 | CLI_NAME, 262 | Config, 263 | ConfigData, 264 | PluginConfig, 265 | }; 266 | -------------------------------------------------------------------------------- /src/services/env.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | 4 | const { CLI_NAME } = require('./config'); 5 | 6 | const configureEnv = () => { 7 | const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); 8 | const twilioDir = path.join(home, `.${CLI_NAME}`); 9 | 10 | const envDirs = ['TWILIO_CACHE_DIR', 'TWILIO_CONFIG_DIR', 'TWILIO_DATA_DIR']; 11 | 12 | envDirs.forEach((envVarName) => { 13 | if (!process.env[envVarName]) { 14 | process.env[envVarName] = twilioDir; 15 | } 16 | }); 17 | }; 18 | 19 | module.exports = configureEnv; 20 | -------------------------------------------------------------------------------- /src/services/error.js: -------------------------------------------------------------------------------- 1 | class TwilioCliError extends Error { 2 | constructor(message, exitCode, data) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | this.exitCode = exitCode; 6 | this.data = data; 7 | } 8 | } 9 | 10 | module.exports = { TwilioCliError }; 11 | -------------------------------------------------------------------------------- /src/services/javascript-utilities.js: -------------------------------------------------------------------------------- 1 | const doesObjectHaveProperty = (obj, propertyName) => { 2 | if (!obj) { 3 | return false; 4 | } 5 | return Object.prototype.hasOwnProperty.call(obj, propertyName); 6 | }; 7 | 8 | /** 9 | * Recursively translates the keys and values of the object using the given translator functions. 10 | * 11 | * @param {object} obj - The object to have its keys translated 12 | * @param {function(object)} [keyFunc] - The function to translate and return each key 13 | * @param {function(object)} [valueFunc] - The function to translate and return each value 14 | * @returns {*} Input obj with keys and/or values translated 15 | */ 16 | const translateObject = (obj, keyFunc, valueFunc) => { 17 | if (!obj) { 18 | return obj; 19 | } 20 | 21 | if (Array.isArray(obj)) { 22 | return obj.map((item) => translateObject(item, keyFunc, valueFunc)); 23 | } 24 | 25 | if (typeof obj === 'object') { 26 | const jsonObj = typeof obj.toJSON === 'function' ? obj.toJSON() : obj; 27 | 28 | const translated = {}; 29 | for (const oldKey in jsonObj) { 30 | if (doesObjectHaveProperty(obj, oldKey)) { 31 | const newKey = keyFunc ? keyFunc(oldKey) : oldKey; 32 | const value = obj[oldKey]; 33 | 34 | translated[newKey] = translateObject(value, keyFunc, valueFunc); 35 | } 36 | } 37 | 38 | return translated; 39 | } 40 | 41 | return valueFunc ? valueFunc(obj) : obj; 42 | }; 43 | 44 | /** 45 | * Recursively translates the keys of the object using the given key translator function. 46 | * 47 | * @param {object} obj - The object to have its keys translated 48 | * @param {function(object)} keyFunc - The function to translate and return each key 49 | * @returns {*} Input obj with keys translated 50 | */ 51 | const translateKeys = (obj, keyFunc) => { 52 | return translateObject(obj, keyFunc, null); 53 | }; 54 | 55 | /** 56 | * Recursively translates the values of the object using the given values translator function. 57 | * 58 | * @param {object} obj - The object to have its values translated 59 | * @param {function(object)} valueFunc - The function to translate and return each value 60 | * @returns {*} Input obj with values translated 61 | */ 62 | const translateValues = (obj, valueFunc) => { 63 | return translateObject(obj, null, valueFunc); 64 | }; 65 | 66 | const sleep = (ms) => { 67 | return new Promise((resolve) => setTimeout(resolve, ms)); 68 | }; 69 | 70 | const splitArray = (array, testFunc) => { 71 | const left = []; 72 | const right = []; 73 | 74 | array.forEach((item) => (testFunc(item) ? left.push(item) : right.push(item))); 75 | 76 | return [left, right]; 77 | }; 78 | 79 | /** 80 | * Checks whether an object is instance of a given class 81 | * @param {Object} instance the instance object to check 82 | * @param klass the class the instance object to be checked against 83 | * @returns {boolean} whether the instance is instanceof the provided klass 84 | */ 85 | const instanceOf = (instance, klass) => { 86 | while (instance && instance !== Object.prototype) { 87 | if (!instance || !instance.constructor || !instance.constructor.name) { 88 | return false; 89 | } 90 | 91 | if (klass.name === instance.constructor.name) { 92 | return true; 93 | } 94 | 95 | instance = Object.getPrototypeOf(instance); 96 | } 97 | 98 | return false; 99 | }; 100 | 101 | module.exports = { 102 | doesObjectHaveProperty, 103 | translateObject, 104 | translateKeys, 105 | translateValues, 106 | sleep, 107 | splitArray, 108 | instanceOf, 109 | }; 110 | -------------------------------------------------------------------------------- /src/services/messaging/help-messages.js: -------------------------------------------------------------------------------- 1 | const { CLI_NAME } = require('../config'); 2 | 3 | const ENV_VAR_CMD = process.platform === 'win32' ? 'set' : 'export'; 4 | const ENV_VARS_USAGE = `# OPTION 1 (recommended) 5 | ${ENV_VAR_CMD} TWILIO_ACCOUNT_SID=your Account SID from twil.io/console 6 | ${ENV_VAR_CMD} TWILIO_API_KEY=an API Key created at twil.io/get-api-key 7 | ${ENV_VAR_CMD} TWILIO_API_SECRET=the secret for the API Key 8 | 9 | # OPTION 2 10 | ${ENV_VAR_CMD} TWILIO_ACCOUNT_SID=your Account SID from twil.io/console 11 | ${ENV_VAR_CMD} TWILIO_AUTH_TOKEN=your Auth Token from twil.io/console`; 12 | 13 | exports.HELP_ENVIRONMENT_VARIABLES = `Alternatively, ${CLI_NAME} can use credentials stored in environment variables: 14 | 15 | ${ENV_VARS_USAGE} 16 | 17 | Once these environment variables are set, a ${CLI_NAME} profile is not required and you may skip the "login" step.`; 18 | 19 | exports.ACCESS_DENIED = `${CLI_NAME} profiles use Standard API Keys which are not permitted to manage Accounts (e.g., create Subaccounts) and other API Keys. If you require this functionality a Master API Key or Auth Token must be stored in environment variables: 20 | 21 | ${ENV_VARS_USAGE}`; 22 | 23 | exports.NETWORK_ERROR = `${CLI_NAME} encountered a network connectivity error. \ 24 | Please check your network connection and try your command again. \ 25 | Check on Twilio service status at https://status.twilio.com/`; 26 | -------------------------------------------------------------------------------- /src/services/messaging/logging.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | const LoggingLevel = { 4 | debug: -1, 5 | info: 0, 6 | warn: 1, 7 | error: 2, 8 | none: 10, 9 | }; 10 | 11 | const LoggingLevelStyle = { 12 | [LoggingLevel.debug]: (msg) => chalk.dim(`[DEBUG] ${msg}`), 13 | [LoggingLevel.info]: (msg) => msg, 14 | [LoggingLevel.warn]: (msg) => chalk.italic(` » ${msg}`), 15 | [LoggingLevel.error]: (msg) => chalk.bold(` » ${msg}`), 16 | }; 17 | 18 | class Logger { 19 | constructor(config) { 20 | this.config = config; 21 | } 22 | 23 | debug(msg) { 24 | this.log(msg, LoggingLevel.debug); 25 | } 26 | 27 | info(msg) { 28 | this.log(msg, LoggingLevel.info); 29 | } 30 | 31 | warn(msg) { 32 | this.log(msg, LoggingLevel.warn); 33 | } 34 | 35 | error(msg) { 36 | this.log(msg, LoggingLevel.error); 37 | } 38 | 39 | log(msg, level) { 40 | level = level || LoggingLevel.info; 41 | 42 | if (level >= this.config.level) { 43 | const message = typeof msg === 'string' ? msg : JSON.stringify(msg); 44 | process.stderr.write(`${LoggingLevelStyle[level](message)}\n`); 45 | } 46 | } 47 | } 48 | 49 | const logger = new Logger({ 50 | level: LoggingLevel.info, 51 | }); 52 | 53 | module.exports = { 54 | LoggingLevel, 55 | Logger, // class 56 | logger, // global instance 57 | }; 58 | -------------------------------------------------------------------------------- /src/services/messaging/templates.js: -------------------------------------------------------------------------------- 1 | const { templatize } = require('./templating'); 2 | 3 | exports.configSaved = templatize`twilio-cli configuration saved to "${'path'}"`; 4 | 5 | exports.unexpectedError = templatize`twilio-cli encountered an unexpected error. \ 6 | To report this issue, execute the command with the "-l debug" flag, then copy the output to a new issue here: \ 7 | "${'url'}"`; 8 | -------------------------------------------------------------------------------- /src/services/messaging/templating.js: -------------------------------------------------------------------------------- 1 | const templatize = (templateStrings, ...templateKeys) => { 2 | return (...values) => { 3 | // Assume the last value is an object. 4 | const dict = values[values.length - 1] || {}; 5 | const result = [templateStrings[0]]; 6 | 7 | templateKeys.forEach((key, i) => { 8 | /* 9 | * Numerical keys will perform a 0-based index lookup on the provided values. 10 | * Others will perform a string-key lookup on the last value. 11 | */ 12 | const value = Number.isInteger(key) ? values[key] : dict[key]; 13 | 14 | // Append the lookup value and the next string in the template. 15 | result.push(value); 16 | result.push(templateStrings[i + 1]); 17 | }); 18 | 19 | // Squash 'em all together. 20 | return result.join(''); 21 | }; 22 | }; 23 | 24 | module.exports = { 25 | templatize, 26 | }; 27 | -------------------------------------------------------------------------------- /src/services/naming-conventions.js: -------------------------------------------------------------------------------- 1 | const kebabCase = (input) => { 2 | return input 3 | .trim() 4 | .replace(/[ _]/g, '-') // from snake_case (or spaces) 5 | .replace(/([a-z])([A-Z])/g, '$1-$2') // from PascalCase or camelCase 6 | .replace(/(\d[A-Z]*)([A-Z])/g, '$1-$2') // handling numbers followed by letters 7 | .toLowerCase() 8 | .replace(/-+/g, '-') // remove duplicate dashes 9 | .replace(/^-|-$/g, ''); // remove leading and trailing dashes 10 | }; 11 | 12 | const camelCase = (input) => { 13 | return input 14 | .trim() 15 | .replace(/^[-_]+|[-_]+$/g, '') // remove leading and trailing dashes and underscores 16 | .replace(/^[A-Z]/, (g) => g[0].toLowerCase()) // from PascalCase 17 | .replace(/\.[A-Z]/g, (g) => g.toLowerCase()) // from dot-separated 18 | .replace(/[A-Z]{2,}/g, (g) => g.toLowerCase()) // consecutive caps (e.g. "AWS") TODO: What about AWSRoute53? 19 | .replace(/[-_ ]([a-z])/g, (g) => g[1].toUpperCase()) // from kebab-case or snake_case (or spaces) 20 | .replace(/ /g, ''); // remove any remaining spaces 21 | }; 22 | 23 | const snakeCase = (input) => { 24 | return input 25 | .trim() 26 | .replace(/[ -]/g, '_') // from kebab-case (or spaces) 27 | .replace(/([a-z\d])([A-Z])/g, '$1_$2') // from PascalCase or camelCase 28 | .toLowerCase() 29 | .replace(/_+/g, '_') // remove duplicate underscores 30 | .replace(/^_|_$/g, ''); // remove leading and trailing underscores 31 | }; 32 | 33 | const capitalize = (input) => { 34 | return input.trim().replace(/^[a-z]/, (g) => g[0].toUpperCase()); // upper the first character 35 | }; 36 | 37 | const pascalCase = (input) => { 38 | return camelCase(input) // camelize first 39 | .replace(/(^|\.)[a-z]/g, (g) => g.toUpperCase()); // upper the first character and after each dot 40 | }; 41 | 42 | module.exports = { 43 | kebabCase, 44 | camelCase, 45 | snakeCase, 46 | capitalize, 47 | pascalCase, 48 | }; 49 | -------------------------------------------------------------------------------- /src/services/open-api-client.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | 3 | const { logger } = require('./messaging/logging'); 4 | const { doesObjectHaveProperty } = require('./javascript-utilities'); 5 | const JsonSchemaConverter = require('./api-schema/json-converter'); 6 | 7 | class OpenApiClient { 8 | constructor({ httpClient, apiBrowser, converter }) { 9 | this.httpClient = httpClient; 10 | this.apiBrowser = apiBrowser; 11 | this.converter = converter || new JsonSchemaConverter(); 12 | } 13 | 14 | async request(opts) { 15 | opts = { ...opts }; 16 | 17 | const domain = this.apiBrowser.domains[opts.domain]; 18 | 19 | if (!domain) { 20 | throw new Error(`Domain name not found: ${opts.domain}`); 21 | } 22 | 23 | const path = domain.paths[opts.path]; 24 | 25 | if (!path) { 26 | throw new Error(`Path not found: ${opts.domain}.${opts.path}`); 27 | } 28 | 29 | const operation = path.operations[opts.method]; 30 | 31 | if (!operation) { 32 | throw new Error(`Operation not found: ${opts.domain}.${opts.path}.${opts.method}`); 33 | } 34 | 35 | const isPost = opts.method.toLowerCase() === 'post'; 36 | const params = this.getParams(opts, operation); 37 | 38 | if (!opts.uri) { 39 | opts.uri = this.getUri(opts); 40 | } 41 | 42 | // If the URI is relative, determine the host and prepend it. 43 | if (opts.uri.startsWith('/')) { 44 | if (!opts.host) { 45 | opts.host = path.server; 46 | } 47 | opts.uri = opts.host + opts.uri; 48 | } 49 | 50 | const uri = new url.URL(opts.uri); 51 | uri.hostname = this.getHost(uri.hostname, opts); 52 | opts.uri = uri.href; 53 | 54 | opts.params = isPost ? null : params; 55 | opts.data = isPost ? params : null; 56 | 57 | const response = await this.httpClient.request(opts); 58 | 59 | return this.parseResponse(domain, operation, response, opts); 60 | } 61 | 62 | getParams(opts, operation) { 63 | const params = {}; 64 | (operation.parameters || []).forEach((parameter) => { 65 | /* 66 | * Build the actual request params from the spec's query parameters. This 67 | * effectively drops all params that are not in the spec. 68 | */ 69 | if (parameter.in === 'query' && doesObjectHaveProperty(opts.data, parameter.name)) { 70 | let value = opts.data[parameter.name]; 71 | if (parameter.schema.type === 'boolean') { 72 | value = value.toString(); 73 | } 74 | params[parameter.name] = value; 75 | } 76 | }); 77 | 78 | return params; 79 | } 80 | 81 | getUri(opts) { 82 | /* 83 | * Evaluate the request path by replacing path parameters with their value 84 | * from the request data. 85 | */ 86 | return opts.path.replace(/{(.+?)}/g, (fullMatch, pathNode) => { 87 | let value = ''; 88 | 89 | if (doesObjectHaveProperty(opts.pathParams, pathNode)) { 90 | value = opts.pathParams[pathNode]; 91 | value = encodeURIComponent(value); 92 | } 93 | 94 | logger.debug(`pathNode=${pathNode}, value=${value}`); 95 | 96 | return value; 97 | }); 98 | } 99 | 100 | getHost(host, opts) { 101 | if (opts.region || opts.edge) { 102 | const domain = host.split('.').slice(-2).join('.'); 103 | const prefix = host.split(`.${domain}`)[0]; 104 | 105 | // eslint-disable-next-line prefer-const 106 | let [product, edge, region] = prefix.split('.'); 107 | if (edge && !region) { 108 | region = edge; 109 | edge = undefined; 110 | } 111 | edge = opts.edge || edge; 112 | region = opts.region || region || (opts.edge && 'us1'); 113 | return [product, edge, region, domain].filter((part) => part).join('.'); 114 | } 115 | return host; 116 | } 117 | 118 | parseResponse(domain, operation, response, requestOpts) { 119 | if (response.body) { 120 | const responseSchema = this.getResponseSchema(domain, operation, response.statusCode, requestOpts.headers.Accept); 121 | 122 | // If we were able to find the schema for the response body, convert it. 123 | if (responseSchema) { 124 | response.body = this.convertBody(response.body, responseSchema); 125 | } 126 | } 127 | 128 | return response; 129 | } 130 | 131 | getResponseSchema(domain, operation, statusCode, contentType) { 132 | let response = operation.responses[statusCode]; 133 | 134 | if (!response) { 135 | const statusCodeRange = `${statusCode.toString()[0]}XX`; 136 | response = operation.responses[statusCodeRange]; 137 | 138 | if (!response) { 139 | logger.debug(`Response schema not found for status code ${statusCode} (${statusCodeRange})`); 140 | return undefined; 141 | } 142 | } 143 | 144 | const { schema } = response.content[contentType]; 145 | 146 | return this.evaluateRefs(schema, domain); 147 | } 148 | 149 | convertBody(responseBody, schema) { 150 | return this.converter.convertSchema(schema, responseBody); 151 | } 152 | 153 | evaluateRefs(schema, domain) { 154 | if (!schema || typeof schema !== 'object') { 155 | return schema; 156 | } 157 | 158 | if (doesObjectHaveProperty(schema, '$ref')) { 159 | schema = this.getRef(schema.$ref, domain); 160 | } 161 | 162 | Object.entries(schema).forEach(([key, value]) => { 163 | schema[key] = this.evaluateRefs(value, domain); 164 | }); 165 | 166 | return schema; 167 | } 168 | 169 | getRef(ref, domain) { 170 | // https://swagger.io/docs/specification/using-ref/ 171 | const [remote, local] = ref.split('#'); 172 | 173 | if (remote) { 174 | logger.debug(`Remote refs are not yet supported. Assuming local ref: ${remote}`); 175 | } 176 | 177 | let node = domain; 178 | local 179 | .split('/') 180 | .filter((n) => n) 181 | .forEach((nodeName) => { 182 | if (doesObjectHaveProperty(node, nodeName)) { 183 | node = node[nodeName]; 184 | } 185 | }); 186 | 187 | if (!node) { 188 | logger.debug(`Ref not found: ${ref}`); 189 | } 190 | 191 | return node; 192 | } 193 | } 194 | 195 | module.exports = OpenApiClient; 196 | -------------------------------------------------------------------------------- /src/services/output-formats/columns.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const columnify = require('columnify'); 3 | 4 | const { capitalize } = require('../naming-conventions'); 5 | 6 | function headingTransform(heading) { 7 | const capitalizeWords = ['Id', 'Sid', 'Iso', 'Sms', 'Url']; 8 | 9 | heading = heading.replace(/([A-Z])/g, ' $1'); 10 | heading = capitalize(heading); 11 | heading = heading 12 | .split(' ') 13 | .map((word) => (capitalizeWords.indexOf(word) > -1 ? word.toUpperCase() : word)) 14 | .join(' '); 15 | return chalk.bold(heading); 16 | } 17 | 18 | module.exports = (fullData, limitedData, options) => { 19 | if (limitedData.length === 0) { 20 | return ''; 21 | } 22 | 23 | const columns = Object.keys(limitedData[0]) 24 | .map((key) => ({ key, value: { headingTransform } })) 25 | .reduce((map, obj) => { 26 | map[obj.key] = obj.value; 27 | return map; 28 | }, {}); 29 | 30 | options = options || {}; 31 | return columnify(limitedData, { 32 | columnSplitter: ' ', 33 | config: columns, 34 | ...options, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/services/output-formats/index.js: -------------------------------------------------------------------------------- 1 | const OutputFormats = { 2 | columns: require('./columns'), 3 | json: require('./json'), 4 | tsv: require('./tsv'), 5 | none: undefined, 6 | }; 7 | 8 | module.exports = { 9 | OutputFormats, 10 | }; 11 | -------------------------------------------------------------------------------- /src/services/output-formats/json.js: -------------------------------------------------------------------------------- 1 | module.exports = (data) => JSON.stringify(data, null, 2); 2 | -------------------------------------------------------------------------------- /src/services/output-formats/tsv.js: -------------------------------------------------------------------------------- 1 | const TSV = require('tsv'); 2 | 3 | module.exports = (fullData, limitedData, options) => { 4 | if (limitedData.length === 0) { 5 | return ''; 6 | } 7 | 8 | options = options || { showHeaders: true }; 9 | TSV.header = options.showHeaders; 10 | 11 | return TSV.stringify(limitedData); 12 | }; 13 | -------------------------------------------------------------------------------- /src/services/require-install.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const semver = require('semver'); 4 | const Plugins = require('@oclif/plugin-plugins').default; 5 | 6 | const { TwilioCliError } = require('../services/error'); 7 | const corePJSON = require('../../package.json'); 8 | const { logger } = require('./messaging/logging'); 9 | 10 | /** 11 | * Retrieves the plugin for a given command. 12 | */ 13 | const getCommandPlugin = (command) => { 14 | for (const plugin of command.config.plugins || []) { 15 | for (const pluginCommand of plugin.commands) { 16 | if (pluginCommand.id === command.id || pluginCommand.aliases.includes(command.id)) { 17 | /* 18 | * Check the plugin options/config name first. This will contain the 19 | * name of the top-level plugin in the case of "dynamic" plugins. All 20 | * such plugins should really use the same dependency location. 21 | */ 22 | const match = plugin.options.name ? command.config.plugins.find((p) => p.name === plugin.options.name) : plugin; 23 | 24 | logger.debug(`Found command "${command.id}" plugin: ${match.name}`); 25 | return command.config.plugins.find((p) => p.name === match.name); 26 | } 27 | } 28 | } 29 | 30 | throw new TwilioCliError('No plugin was found'); 31 | }; 32 | 33 | /** 34 | * Retrieves the package version given a path. 35 | */ 36 | const getPackageVersion = (packagePath, errors = null) => { 37 | const pjsonPath = path.join(packagePath, 'package.json'); 38 | 39 | try { 40 | return require(pjsonPath).version; 41 | } catch (error) { 42 | // Failure to read the version is non-fatal. 43 | if (errors === null) { 44 | logger.debug(`Could not determine package version: ${error}`); 45 | } else { 46 | errors.push(error); 47 | } 48 | 49 | return undefined; 50 | } 51 | }; 52 | 53 | /** 54 | * Retrieves the dependency version given a dependency name and package JSON. 55 | */ 56 | const getDependencyVersion = (packageName, pluginPJSON) => { 57 | for (const pjson of [pluginPJSON, corePJSON]) { 58 | // Check the plugin first. 59 | for (const location of ['dependencies', 'optionalDependencies']) { 60 | const version = pjson && pjson[location] && pjson[location][packageName]; 61 | 62 | if (version) { 63 | logger.debug(`Found ${packageName} version in "${pjson.name}" ${location}: ${version}`); 64 | return version; 65 | } 66 | } 67 | } 68 | 69 | return undefined; 70 | }; 71 | 72 | /** 73 | * Checks the given version is in the target version range. Throws an error if the check fails. 74 | */ 75 | const checkVersion = (currentVersion, targetVersion) => { 76 | if (currentVersion && targetVersion && !semver.satisfies(currentVersion, targetVersion)) { 77 | throw new Error(`Version ${currentVersion} does not meet requirement ${targetVersion}`); 78 | } 79 | }; 80 | 81 | /** 82 | * Loads the given package and installs it if missing or not the proper version. 83 | */ 84 | const requireInstall = async (packageName, command) => { 85 | const errors = []; 86 | 87 | // First, try to load the package the old-fashioned way. 88 | try { 89 | return require(packageName); 90 | } catch (error) { 91 | errors.push(error); 92 | } 93 | 94 | const plugin = getCommandPlugin(command); 95 | 96 | // Use a plugin-scoped module directory. 97 | const pluginPath = path.join(command.config.dataDir, 'runtime_modules', plugin.name); 98 | const packagePath = path.join(pluginPath, 'node_modules', packageName); 99 | 100 | const currentVersion = getPackageVersion(packagePath, errors); 101 | const targetVersion = getDependencyVersion(packageName, plugin.pjson); 102 | 103 | // Then, try to load the package from the plugin's runtime modules path. 104 | try { 105 | checkVersion(currentVersion, targetVersion); 106 | 107 | return require(packagePath); 108 | } catch (error) { 109 | errors.push(error); 110 | } 111 | 112 | // If we're here, attempt to install the package in the plugin's runtime modules path. 113 | logger.warn(`Installing ${packageName} ...`); 114 | const plugins = new Plugins({ dataDir: pluginPath, cacheDir: pluginPath }); 115 | 116 | try { 117 | /* 118 | * Init the PJSON in case it doesn't exist. This is required by yarn or it 119 | * moves up the dir tree until it finds one. 120 | */ 121 | await plugins.createPJSON(); 122 | 123 | // Force install the package in case it's a native module that needs rebuilding. 124 | const packageTag = targetVersion ? `${packageName}@${targetVersion}` : packageName; 125 | await plugins.yarn.exec(['add', '--force', packageTag], { cwd: pluginPath, verbose: false }); 126 | } catch (error) { 127 | errors.push(error); 128 | } 129 | 130 | try { 131 | // Finally, re-attempt loading the package from the plugin's runtime modules path. 132 | return require(packagePath); 133 | } catch (error) { 134 | // Debug log any lazy errors we swallowed earlier. 135 | if (errors) { 136 | logger.debug(`Error loading/installing ${packageName}:`); 137 | errors.forEach((lazyError) => logger.debug(lazyError)); 138 | } 139 | 140 | throw error; 141 | } 142 | }; 143 | 144 | module.exports = { 145 | getCommandPlugin, 146 | getPackageVersion, 147 | getDependencyVersion, 148 | checkVersion, 149 | requireInstall, 150 | }; 151 | -------------------------------------------------------------------------------- /src/services/twilio-api/api-browser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const { camelCase } = require('../naming-conventions'); 4 | 5 | let apiSpec; // Lazy-loaded below. 6 | 7 | const OPERATIONS = ['post', 'get', 'delete']; 8 | 9 | class TwilioApiBrowser { 10 | constructor(spec) { 11 | spec = spec || this.loadApiSpecFromDisk(); 12 | spec = this.mergeVersions(spec); 13 | this.domains = this.loadDomains(spec); 14 | } 15 | 16 | mergeVersions(spec) { 17 | // merge the domain_versions into a single domain 18 | const mergedSpec = {}; 19 | for (const domainNameWithVersion in spec) { 20 | if (spec.hasOwnProperty(domainNameWithVersion)) { 21 | const domainName = domainNameWithVersion.split('_')[0]; 22 | if (domainName in mergedSpec) { 23 | const existing = mergedSpec[domainName]; 24 | const current = spec[domainNameWithVersion]; 25 | Object.assign(existing.components.schemas, current.components.schemas); 26 | Object.assign(existing.paths, current.paths); 27 | mergedSpec[domainName] = existing; 28 | } else { 29 | mergedSpec[domainName] = spec[domainNameWithVersion]; 30 | } 31 | } 32 | } 33 | 34 | return mergedSpec; 35 | } 36 | 37 | loadApiSpecFromDisk() { 38 | if (!apiSpec) { 39 | const specPattern = /twilio_(.+)\.json/; 40 | const specNameIndex = 1; 41 | 42 | apiSpec = fs 43 | .readdirSync(__dirname) 44 | .filter((filename) => filename.match(specPattern)) 45 | .map((filename) => { 46 | const domainName = filename.match(specPattern)[specNameIndex]; 47 | 48 | return { [domainName]: require(`./${filename}`) }; 49 | }); 50 | 51 | apiSpec = Object.assign({}, ...apiSpec); 52 | } 53 | 54 | return apiSpec; 55 | } 56 | 57 | updateTwilioVendorExtensionProperty(input) { 58 | Object.entries(input).forEach(([key, value]) => { 59 | if (key === 'x-twilio') { 60 | Object.entries(value).forEach(([subKey, subValue]) => { 61 | input[subKey] = subValue; 62 | }); 63 | delete input[key]; 64 | } 65 | }); 66 | } 67 | 68 | loadDomains(obj) { 69 | // Clone the spec since we'll be modifying it. 70 | const domains = JSON.parse(JSON.stringify(obj)); 71 | 72 | Object.values(domains).forEach((spec) => { 73 | Object.entries(spec.paths).forEach((entry) => { 74 | const key = entry[0]; 75 | const path = entry[1]; 76 | if (key === '/healthcheck') return; 77 | // Naive assumption: The Twilio APIs only have a single server. 78 | if (path.servers === undefined) path.server = spec.servers[0].url; 79 | else path.server = path.servers[0].url; 80 | delete path.servers; 81 | 82 | path.operations = {}; 83 | if (path.description === undefined) path.description = ''; 84 | path.description = path.description.replace(/(\r\n|\n|\r)/gm, ' '); 85 | 86 | // Move the operations into an operations object. 87 | OPERATIONS.forEach((operationName) => { 88 | if (operationName in path) { 89 | const operation = path[operationName]; 90 | this.updateTwilioVendorExtensionProperty(operation); 91 | path.operations[operationName] = operation; 92 | delete path[operationName]; 93 | 94 | /* 95 | * Convert all the request body properties to query parameters for 96 | * simpler parsing downstream. 97 | */ 98 | const parameters = this.requestPropertiesToParameters(operation.requestBody); 99 | 100 | if (parameters.length > 0) { 101 | operation.parameters = operation.parameters ? operation.parameters.concat(parameters) : parameters; 102 | } 103 | } 104 | }); 105 | 106 | // Lift the Twilio vendor extension properties. 107 | this.updateTwilioVendorExtensionProperty(path); 108 | }); 109 | }); 110 | 111 | return domains; 112 | } 113 | 114 | requestPropertiesToParameters(requestBody) { 115 | const parameters = []; 116 | const content = (requestBody || {}).content || {}; 117 | 118 | Object.values(content).forEach((type) => { 119 | const typeSchema = type.schema || {}; 120 | const properties = typeSchema.properties || {}; 121 | const required = typeSchema.required || []; 122 | 123 | Object.entries(properties).forEach(([name, schema]) => { 124 | parameters.push({ 125 | name, 126 | schema, 127 | in: 'query', 128 | required: required.includes(name), 129 | description: schema.description, 130 | }); 131 | }); 132 | }); 133 | 134 | return parameters; 135 | } 136 | } 137 | 138 | module.exports = TwilioApiBrowser; 139 | -------------------------------------------------------------------------------- /src/services/twilio-api/index.js: -------------------------------------------------------------------------------- 1 | const TwilioApiBrowser = require('./api-browser'); 2 | const { TwilioApiClient, TwilioApiFlags } = require('./twilio-client'); 3 | 4 | module.exports = { 5 | TwilioApiBrowser, 6 | TwilioApiClient, 7 | TwilioApiFlags, 8 | }; 9 | -------------------------------------------------------------------------------- /src/services/twilio-api/twilio-client.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../../../package.json'); 2 | const { doesObjectHaveProperty } = require('../javascript-utilities'); 3 | const { logger } = require('../messaging/logging'); 4 | const OpenApiClient = require('../open-api-client'); 5 | const TwilioApiBrowser = require('./api-browser'); 6 | const TwilioSchemaConverter = require('../api-schema/twilio-converter'); 7 | 8 | // Special snowflakes 9 | const TwilioApiFlags = { 10 | ACCOUNT_SID: 'AccountSid', 11 | PAGE_SIZE: 'PageSize', 12 | LIMIT: 'Limit', 13 | NO_LIMIT: 'NoLimit', 14 | }; 15 | 16 | class TwilioApiClient { 17 | constructor(username, password, opts) { 18 | opts = opts || {}; 19 | 20 | this.username = username; 21 | this.password = password; 22 | this.accountSid = opts.accountSid || this.username; 23 | this.edge = opts.edge; 24 | this.region = opts.region; 25 | 26 | this.apiClient = new OpenApiClient({ 27 | httpClient: opts.httpClient, 28 | apiBrowser: new TwilioApiBrowser(), 29 | converter: new TwilioSchemaConverter(), 30 | }); 31 | 32 | if (!this.username) { 33 | throw new Error('username is required'); 34 | } 35 | 36 | if (!this.password) { 37 | throw new Error('password is required'); 38 | } 39 | 40 | if (!this.accountSid.startsWith('AC')) { 41 | throw new Error('accountSid must start with AC'); 42 | } 43 | } 44 | 45 | async create(opts) { 46 | opts.method = 'post'; 47 | 48 | const { body } = await this.request(opts); 49 | 50 | return body; 51 | } 52 | 53 | async fetch(opts) { 54 | opts.method = 'get'; 55 | 56 | const { body } = await this.request(opts); 57 | 58 | return body; 59 | } 60 | 61 | async update(opts) { 62 | opts.method = 'post'; 63 | 64 | const { body } = await this.request(opts); 65 | 66 | return body; 67 | } 68 | 69 | async remove(opts) { 70 | opts.method = 'delete'; 71 | 72 | const { statusCode } = await this.request(opts); 73 | 74 | return statusCode === 204; 75 | } 76 | 77 | async list(opts) { 78 | const items = []; 79 | const limit = this.getLimit(opts.data); 80 | 81 | // eslint-disable-next-line no-constant-condition 82 | while (true) { 83 | opts.method = 'get'; 84 | 85 | // eslint-disable-next-line no-await-in-loop 86 | const { body } = await this.request(opts); 87 | const pageItems = this.getResponseItems(body); 88 | 89 | // Append all the items from the next page. 90 | items.push(...pageItems); 91 | 92 | if (limit !== undefined && items.length >= limit) { 93 | logger.debug(`Limiting result set to ${limit} record(s)`); 94 | return items.slice(0, limit); 95 | } 96 | 97 | // If there's another page of results, "Let's Get It". 98 | const nextPageUri = (body.meta && body.meta.nextPageUrl) || body.nextPageUri; 99 | 100 | if (!nextPageUri) { 101 | break; 102 | } 103 | 104 | opts = { 105 | domain: opts.domain, 106 | host: opts.host, 107 | path: opts.path, 108 | uri: nextPageUri, 109 | }; 110 | } 111 | 112 | return items; 113 | } 114 | 115 | getLimit(options) { 116 | // 'no-limit' outranks 'limit' so begone. 117 | if (!options || options[TwilioApiFlags.NO_LIMIT]) { 118 | return undefined; 119 | } 120 | 121 | const limit = options[TwilioApiFlags.LIMIT]; 122 | 123 | if (limit !== undefined) { 124 | if (options[TwilioApiFlags.PAGE_SIZE] > limit) { 125 | logger.debug(`Reducing page size to ${limit}`); 126 | options[TwilioApiFlags.PAGE_SIZE] = limit; 127 | } 128 | } 129 | 130 | return limit; 131 | } 132 | 133 | getResponseItems(responseBody) { 134 | // Find any properties that are arrays. We expect this to be exactly 1. 135 | const arrayProps = Object.values(responseBody).filter(Array.isArray); 136 | 137 | if (arrayProps.length === 1) { 138 | return arrayProps[0]; 139 | } 140 | 141 | logger.debug(`Response does not contain a single list item: ${Object.keys(responseBody).join(', ')}`); 142 | return []; 143 | } 144 | 145 | /** 146 | * Makes a request to the Twilio API using the configured http client. 147 | * Authentication information is automatically added if none is provided. 148 | * 149 | * @param {object} opts - The options argument 150 | * @param {string} opts.method - The http method 151 | * @param {string} opts.path - The request path 152 | * @param {string} [opts.host] - The request host 153 | * @param {string} [opts.edge] - The request edge. Defaults to none. 154 | * @param {string} [opts.region] - The request region. Default to us1 if edge defined 155 | * @param {string} [opts.uri] - The request uri 156 | * @param {string} [opts.username] - The username used for auth 157 | * @param {string} [opts.password] - The password used for auth 158 | * @param {object} [opts.headers] - The request headers 159 | * @param {object} [opts.data] - The request data 160 | * @param {object} [opts.pathParams] - The request path parameter values 161 | * @param {int} [opts.timeout] - The request timeout in milliseconds 162 | * @param {boolean} [opts.allowRedirects] - Should the client follow redirects 163 | */ 164 | async request(opts) { 165 | opts = { ...opts }; 166 | 167 | opts.username = opts.username || this.username; 168 | opts.password = opts.password || this.password; 169 | opts.headers = opts.headers || {}; 170 | opts.data = opts.data || {}; 171 | opts.pathParams = opts.pathParams || {}; 172 | 173 | opts.headers['User-Agent'] = `twilio-api-client/${pkg.version} (node.js ${process.version})`; 174 | opts.headers['Accept-Charset'] = 'utf-8'; 175 | 176 | if (opts.method.toLowerCase() === 'post' && !opts.headers['Content-Type']) { 177 | opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; 178 | } 179 | 180 | if (!opts.headers.Accept) { 181 | opts.headers.Accept = 'application/json'; 182 | } 183 | 184 | if (!opts.uri) { 185 | if ( 186 | opts.path.includes(TwilioApiFlags.ACCOUNT_SID) && 187 | !doesObjectHaveProperty(opts.pathParams, TwilioApiFlags.ACCOUNT_SID) 188 | ) { 189 | opts.pathParams[TwilioApiFlags.ACCOUNT_SID] = this.accountSid; 190 | } 191 | } 192 | 193 | opts.edge = opts.edge || this.edge; 194 | opts.region = opts.region || this.region; 195 | return this.apiClient.request(opts); 196 | } 197 | } 198 | 199 | module.exports = { 200 | TwilioApiClient, 201 | TwilioApiFlags, 202 | }; 203 | -------------------------------------------------------------------------------- /src/services/twilio-api/twilio_chat_v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "securitySchemes": { 4 | "accountSid_authToken": { 5 | "scheme": "basic", 6 | "type": "http" 7 | } 8 | }, 9 | "schemas": { 10 | "chat.v3.channel": { 11 | "type": "object", 12 | "properties": { 13 | "sid": { 14 | "type": "string", 15 | "minLength": 34, 16 | "maxLength": 34, 17 | "pattern": "^CH[0-9a-fA-F]{32}$", 18 | "nullable": true, 19 | "description": "The unique string that we created to identify the Channel resource." 20 | }, 21 | "account_sid": { 22 | "type": "string", 23 | "minLength": 34, 24 | "maxLength": 34, 25 | "pattern": "^AC[0-9a-fA-F]{32}$", 26 | "nullable": true, 27 | "description": "The SID of the [Account](https://www.twilio.com/docs/iam/api/account) that created the Channel resource." 28 | }, 29 | "service_sid": { 30 | "type": "string", 31 | "minLength": 34, 32 | "maxLength": 34, 33 | "pattern": "^IS[0-9a-fA-F]{32}$", 34 | "nullable": true, 35 | "description": "The SID of the [Service](https://www.twilio.com/docs/chat/rest/service-resource) the Channel resource is associated with." 36 | }, 37 | "friendly_name": { 38 | "type": "string", 39 | "nullable": true, 40 | "description": "The string that you assigned to describe the resource.", 41 | "x-twilio": { 42 | "pii": { 43 | "handling": "standard", 44 | "deleteSla": 30 45 | } 46 | } 47 | }, 48 | "unique_name": { 49 | "type": "string", 50 | "nullable": true, 51 | "description": "An application-defined string that uniquely identifies the resource. It can be used to address the resource in place of the resource's `sid` in the URL.", 52 | "x-twilio": { 53 | "pii": { 54 | "handling": "standard", 55 | "deleteSla": 30 56 | } 57 | } 58 | }, 59 | "attributes": { 60 | "type": "string", 61 | "nullable": true, 62 | "description": "The JSON string that stores application-specific data. If attributes have not been set, `{}` is returned.", 63 | "x-twilio": { 64 | "pii": { 65 | "handling": "sensitive", 66 | "deleteSla": 30 67 | } 68 | } 69 | }, 70 | "type": { 71 | "$ref": "#/components/schemas/channel_enum_channel_type" 72 | }, 73 | "date_created": { 74 | "type": "string", 75 | "format": "date-time", 76 | "nullable": true, 77 | "description": "The date and time in GMT when the resource was created specified in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format." 78 | }, 79 | "date_updated": { 80 | "type": "string", 81 | "format": "date-time", 82 | "nullable": true, 83 | "description": "The date and time in GMT when the resource was last updated specified in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format." 84 | }, 85 | "created_by": { 86 | "type": "string", 87 | "nullable": true, 88 | "description": "The `identity` of the User that created the channel. If the Channel was created by using the API, the value is `system`.", 89 | "x-twilio": { 90 | "pii": { 91 | "handling": "standard", 92 | "deleteSla": 30 93 | } 94 | } 95 | }, 96 | "members_count": { 97 | "type": "integer", 98 | "default": 0, 99 | "description": "The number of Members in the Channel." 100 | }, 101 | "messages_count": { 102 | "type": "integer", 103 | "default": 0, 104 | "description": "The number of Messages that have been passed in the Channel." 105 | }, 106 | "messaging_service_sid": { 107 | "type": "string", 108 | "minLength": 34, 109 | "maxLength": 34, 110 | "pattern": "^MG[0-9a-fA-F]{32}$", 111 | "nullable": true, 112 | "description": "The unique ID of the [Messaging Service](https://www.twilio.com/docs/messaging/api/service-resource) this channel belongs to." 113 | }, 114 | "url": { 115 | "type": "string", 116 | "format": "uri", 117 | "nullable": true, 118 | "description": "The absolute URL of the Channel resource." 119 | } 120 | } 121 | }, 122 | "channel_enum_channel_type": { 123 | "type": "string", 124 | "enum": [ 125 | "public", 126 | "private" 127 | ] 128 | }, 129 | "channel_enum_webhook_enabled_type": { 130 | "type": "string", 131 | "enum": [ 132 | "true", 133 | "false" 134 | ] 135 | } 136 | } 137 | }, 138 | "info": { 139 | "title": "Twilio - Chat", 140 | "description": "This is the public Twilio REST API.", 141 | "termsOfService": "https://www.twilio.com/legal/tos", 142 | "contact": { 143 | "name": "Twilio Support", 144 | "url": "https://support.twilio.com", 145 | "email": "support@twilio.com" 146 | }, 147 | "license": { 148 | "name": "Apache 2.0", 149 | "url": "https://www.apache.org/licenses/LICENSE-2.0.html" 150 | }, 151 | "version": "1.0.0" 152 | }, 153 | "openapi": "3.0.1", 154 | "paths": { 155 | "/v3/Services/{ServiceSid}/Channels/{Sid}": { 156 | "servers": [ 157 | { 158 | "url": "https://chat.twilio.com" 159 | } 160 | ], 161 | "description": "A Channel resource represents a chat/conversation channel with an ordered list of messages and a participant roster.", 162 | "x-twilio": { 163 | "defaultOutputProperties": [ 164 | "sid", 165 | "unique_name", 166 | "friendly_name" 167 | ], 168 | "pathType": "instance" 169 | }, 170 | "post": { 171 | "description": "Update a specific Channel.", 172 | "summary": "Update a specific Channel.", 173 | "tags": [ 174 | "ChatV3Channel" 175 | ], 176 | "parameters": [ 177 | { 178 | "name": "ServiceSid", 179 | "in": "path", 180 | "description": "The unique SID identifier of the Service.", 181 | "schema": { 182 | "type": "string", 183 | "minLength": 34, 184 | "maxLength": 34, 185 | "pattern": "^IS[0-9a-fA-F]{32}$" 186 | }, 187 | "required": true 188 | }, 189 | { 190 | "name": "Sid", 191 | "in": "path", 192 | "description": "A 34 character string that uniquely identifies this Channel.", 193 | "schema": { 194 | "type": "string" 195 | }, 196 | "required": true 197 | }, 198 | { 199 | "name": "X-Twilio-Webhook-Enabled", 200 | "in": "header", 201 | "description": "The X-Twilio-Webhook-Enabled HTTP request header", 202 | "schema": { 203 | "$ref": "#/components/schemas/channel_enum_webhook_enabled_type" 204 | } 205 | } 206 | ], 207 | "responses": { 208 | "200": { 209 | "content": { 210 | "application/json": { 211 | "schema": { 212 | "$ref": "#/components/schemas/chat.v3.channel" 213 | }, 214 | "examples": { 215 | "update": { 216 | "value": { 217 | "sid": "CHaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 218 | "account_sid": "ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 219 | "service_sid": "ISaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 220 | "messaging_service_sid": "MGaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 221 | "friendly_name": "friendly_name", 222 | "unique_name": "unique_name", 223 | "attributes": "{ \"foo\": \"bar\" }", 224 | "type": "public", 225 | "date_created": "2015-12-16T22:18:37Z", 226 | "date_updated": "2015-12-16T22:18:38Z", 227 | "created_by": "username", 228 | "members_count": 0, 229 | "messages_count": 0, 230 | "url": "https://chat.twilio.com/v3/Services/ISaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Channels/CHaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 231 | } 232 | } 233 | } 234 | } 235 | }, 236 | "headers": { 237 | "Access-Control-Allow-Origin": { 238 | "description": "Specify the origin(s) allowed to access the resource", 239 | "schema": { 240 | "type": "string" 241 | }, 242 | "example": "*" 243 | }, 244 | "Access-Control-Allow-Methods": { 245 | "description": "Specify the HTTP methods allowed when accessing the resource", 246 | "schema": { 247 | "type": "string" 248 | }, 249 | "example": "POST, OPTIONS" 250 | }, 251 | "Access-Control-Allow-Headers": { 252 | "description": "Specify the headers allowed when accessing the resource", 253 | "schema": { 254 | "type": "string" 255 | }, 256 | "example": "Content-Type, Authorization" 257 | }, 258 | "Access-Control-Allow-Credentials": { 259 | "description": "Indicates whether the browser should include credentials", 260 | "schema": { 261 | "type": "boolean" 262 | } 263 | }, 264 | "Access-Control-Expose-Headers": { 265 | "description": "Headers exposed to the client", 266 | "schema": { 267 | "type": "string", 268 | "example": "X-Custom-Header1, X-Custom-Header2" 269 | } 270 | } 271 | }, 272 | "description": "OK" 273 | } 274 | }, 275 | "security": [ 276 | { 277 | "accountSid_authToken": [] 278 | } 279 | ], 280 | "operationId": "UpdateChannel", 281 | "requestBody": { 282 | "content": { 283 | "application/x-www-form-urlencoded": { 284 | "schema": { 285 | "type": "object", 286 | "title": "UpdateChannelRequest", 287 | "properties": { 288 | "Type": { 289 | "$ref": "#/components/schemas/channel_enum_channel_type" 290 | }, 291 | "MessagingServiceSid": { 292 | "type": "string", 293 | "minLength": 34, 294 | "maxLength": 34, 295 | "pattern": "^MG[0-9a-fA-F]{32}$", 296 | "description": "The unique ID of the [Messaging Service](https://www.twilio.com/docs/messaging/api/service-resource) this channel belongs to." 297 | } 298 | } 299 | }, 300 | "examples": { 301 | "update": { 302 | "value": { 303 | "Type": "private", 304 | "MessagingServiceSid": "MGaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 305 | } 306 | } 307 | } 308 | } 309 | } 310 | } 311 | } 312 | }, 313 | "/v3/Services/{ServiceSid}/Channels": { 314 | "servers": [ 315 | { 316 | "url": "https://chat.twilio.com" 317 | } 318 | ], 319 | "description": "A Channel resource represents a chat/conversation channel with an ordered list of messages and a participant roster.", 320 | "x-twilio": { 321 | "defaultOutputProperties": [ 322 | "sid", 323 | "unique_name", 324 | "friendly_name" 325 | ], 326 | "pathType": "list" 327 | } 328 | } 329 | }, 330 | "servers": [ 331 | { 332 | "url": "https://chat.twilio.com" 333 | } 334 | ], 335 | "tags": [ 336 | { 337 | "name": "ChatV3Channel" 338 | } 339 | ], 340 | "security": [ 341 | { 342 | "accountSid_authToken": [] 343 | } 344 | ] 345 | } -------------------------------------------------------------------------------- /src/services/twilio-api/twilio_frontline_v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "securitySchemes": { 4 | "accountSid_authToken": { 5 | "scheme": "basic", 6 | "type": "http" 7 | } 8 | }, 9 | "schemas": { 10 | "frontline.v1.user": { 11 | "type": "object", 12 | "properties": { 13 | "sid": { 14 | "type": "string", 15 | "minLength": 34, 16 | "maxLength": 34, 17 | "pattern": "^US[0-9a-fA-F]{32}$", 18 | "nullable": true, 19 | "description": "The unique string that we created to identify the User resource." 20 | }, 21 | "identity": { 22 | "type": "string", 23 | "nullable": true, 24 | "description": "The application-defined string that uniquely identifies the resource's User. This value is often a username or an email address, and is case-sensitive.", 25 | "x-twilio": { 26 | "pii": { 27 | "handling": "standard", 28 | "deleteSla": 30 29 | } 30 | } 31 | }, 32 | "friendly_name": { 33 | "type": "string", 34 | "nullable": true, 35 | "description": "The string that you assigned to describe the User.", 36 | "x-twilio": { 37 | "pii": { 38 | "handling": "standard", 39 | "deleteSla": 30 40 | } 41 | } 42 | }, 43 | "avatar": { 44 | "type": "string", 45 | "nullable": true, 46 | "description": "The avatar URL which will be shown in Frontline application." 47 | }, 48 | "state": { 49 | "$ref": "#/components/schemas/user_enum_state_type" 50 | }, 51 | "is_available": { 52 | "type": "boolean", 53 | "nullable": true, 54 | "description": "Whether the User is available for new conversations. Defaults to `false` for new users." 55 | }, 56 | "url": { 57 | "type": "string", 58 | "format": "uri", 59 | "nullable": true, 60 | "description": "An absolute API resource URL for this user." 61 | } 62 | } 63 | }, 64 | "user_enum_state_type": { 65 | "type": "string", 66 | "enum": [ 67 | "active", 68 | "deactivated" 69 | ] 70 | } 71 | } 72 | }, 73 | "info": { 74 | "title": "Twilio - Frontline", 75 | "description": "This is the public Twilio REST API.", 76 | "termsOfService": "https://www.twilio.com/legal/tos", 77 | "contact": { 78 | "name": "Twilio Support", 79 | "url": "https://support.twilio.com", 80 | "email": "support@twilio.com" 81 | }, 82 | "license": { 83 | "name": "Apache 2.0", 84 | "url": "https://www.apache.org/licenses/LICENSE-2.0.html" 85 | }, 86 | "version": "1.0.0" 87 | }, 88 | "openapi": "3.0.1", 89 | "paths": { 90 | "/v1/Users/{Sid}": { 91 | "servers": [ 92 | { 93 | "url": "https://frontline-api.twilio.com" 94 | } 95 | ], 96 | "description": "A User resource represents a frontline user.", 97 | "x-twilio": { 98 | "defaultOutputProperties": [ 99 | "sid", 100 | "identity" 101 | ], 102 | "pathType": "instance" 103 | }, 104 | "get": { 105 | "description": "Fetch a frontline user", 106 | "summary": "Fetch a frontline user", 107 | "tags": [ 108 | "FrontlineV1User" 109 | ], 110 | "parameters": [ 111 | { 112 | "name": "Sid", 113 | "in": "path", 114 | "description": "The SID of the User resource to fetch. This value can be either the `sid` or the `identity` of the User resource to fetch.", 115 | "schema": { 116 | "type": "string" 117 | }, 118 | "required": true 119 | } 120 | ], 121 | "responses": { 122 | "200": { 123 | "content": { 124 | "application/json": { 125 | "schema": { 126 | "$ref": "#/components/schemas/frontline.v1.user" 127 | }, 128 | "examples": { 129 | "fetch": { 130 | "value": { 131 | "sid": "USaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 132 | "identity": "john@example.com", 133 | "friendly_name": "John Doe", 134 | "avatar": "https://example.com/profile.png", 135 | "state": "active", 136 | "is_available": true, 137 | "url": "https://frontline-api.twilio.com/v1/Users/USaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 138 | } 139 | } 140 | } 141 | } 142 | }, 143 | "headers": { 144 | "Access-Control-Allow-Origin": { 145 | "description": "Specify the origin(s) allowed to access the resource", 146 | "schema": { 147 | "type": "string" 148 | }, 149 | "example": "*" 150 | }, 151 | "Access-Control-Allow-Methods": { 152 | "description": "Specify the HTTP methods allowed when accessing the resource", 153 | "schema": { 154 | "type": "string" 155 | }, 156 | "example": "POST, OPTIONS" 157 | }, 158 | "Access-Control-Allow-Headers": { 159 | "description": "Specify the headers allowed when accessing the resource", 160 | "schema": { 161 | "type": "string" 162 | }, 163 | "example": "Content-Type, Authorization" 164 | }, 165 | "Access-Control-Allow-Credentials": { 166 | "description": "Indicates whether the browser should include credentials", 167 | "schema": { 168 | "type": "boolean" 169 | } 170 | }, 171 | "Access-Control-Expose-Headers": { 172 | "description": "Headers exposed to the client", 173 | "schema": { 174 | "type": "string", 175 | "example": "X-Custom-Header1, X-Custom-Header2" 176 | } 177 | } 178 | }, 179 | "description": "OK" 180 | } 181 | }, 182 | "security": [ 183 | { 184 | "accountSid_authToken": [] 185 | } 186 | ], 187 | "operationId": "FetchUser" 188 | }, 189 | "post": { 190 | "description": "Update an existing frontline user", 191 | "summary": "Update an existing frontline user", 192 | "tags": [ 193 | "FrontlineV1User" 194 | ], 195 | "parameters": [ 196 | { 197 | "name": "Sid", 198 | "in": "path", 199 | "description": "The SID of the User resource to update. This value can be either the `sid` or the `identity` of the User resource to update.", 200 | "schema": { 201 | "type": "string" 202 | }, 203 | "required": true 204 | } 205 | ], 206 | "responses": { 207 | "200": { 208 | "content": { 209 | "application/json": { 210 | "schema": { 211 | "$ref": "#/components/schemas/frontline.v1.user" 212 | }, 213 | "examples": { 214 | "update": { 215 | "value": { 216 | "sid": "USaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 217 | "identity": "john@example.com", 218 | "friendly_name": "John Doe", 219 | "avatar": "https://example.com/profile.png", 220 | "state": "active", 221 | "is_available": true, 222 | "url": "https://frontline-api.twilio.com/v1/Users/USaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 223 | } 224 | } 225 | } 226 | } 227 | }, 228 | "headers": { 229 | "Access-Control-Allow-Origin": { 230 | "description": "Specify the origin(s) allowed to access the resource", 231 | "schema": { 232 | "type": "string" 233 | }, 234 | "example": "*" 235 | }, 236 | "Access-Control-Allow-Methods": { 237 | "description": "Specify the HTTP methods allowed when accessing the resource", 238 | "schema": { 239 | "type": "string" 240 | }, 241 | "example": "POST, OPTIONS" 242 | }, 243 | "Access-Control-Allow-Headers": { 244 | "description": "Specify the headers allowed when accessing the resource", 245 | "schema": { 246 | "type": "string" 247 | }, 248 | "example": "Content-Type, Authorization" 249 | }, 250 | "Access-Control-Allow-Credentials": { 251 | "description": "Indicates whether the browser should include credentials", 252 | "schema": { 253 | "type": "boolean" 254 | } 255 | }, 256 | "Access-Control-Expose-Headers": { 257 | "description": "Headers exposed to the client", 258 | "schema": { 259 | "type": "string", 260 | "example": "X-Custom-Header1, X-Custom-Header2" 261 | } 262 | } 263 | }, 264 | "description": "OK" 265 | } 266 | }, 267 | "security": [ 268 | { 269 | "accountSid_authToken": [] 270 | } 271 | ], 272 | "operationId": "UpdateUser", 273 | "requestBody": { 274 | "content": { 275 | "application/x-www-form-urlencoded": { 276 | "schema": { 277 | "type": "object", 278 | "title": "UpdateUserRequest", 279 | "properties": { 280 | "FriendlyName": { 281 | "type": "string", 282 | "description": "The string that you assigned to describe the User." 283 | }, 284 | "Avatar": { 285 | "type": "string", 286 | "description": "The avatar URL which will be shown in Frontline application." 287 | }, 288 | "State": { 289 | "$ref": "#/components/schemas/user_enum_state_type" 290 | }, 291 | "IsAvailable": { 292 | "type": "boolean", 293 | "description": "Whether the User is available for new conversations. Set to `false` to prevent User from receiving new inbound conversations if you are using [Pool Routing](https://www.twilio.com/docs/frontline/handle-incoming-conversations#3-pool-routing)." 294 | } 295 | } 296 | }, 297 | "examples": { 298 | "update": { 299 | "value": { 300 | "State": "active", 301 | "FriendlyName": "Name", 302 | "Avatar": "https://example.com/avatar.png", 303 | "IsAvailable": true 304 | } 305 | } 306 | } 307 | } 308 | } 309 | } 310 | } 311 | } 312 | }, 313 | "servers": [ 314 | { 315 | "url": "https://frontline-api.twilio.com" 316 | } 317 | ], 318 | "tags": [ 319 | { 320 | "name": "FrontlineV1User" 321 | } 322 | ], 323 | "security": [ 324 | { 325 | "accountSid_authToken": [] 326 | } 327 | ] 328 | } -------------------------------------------------------------------------------- /src/services/twilio-api/twilio_insights_v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "components": { 4 | "securitySchemes": { 5 | "accountSid_authToken": { 6 | "scheme": "basic", 7 | "type": "http" 8 | } 9 | } 10 | }, 11 | "security": [ 12 | { 13 | "accountSid_authToken": [] 14 | } 15 | ], 16 | "servers": [ 17 | { 18 | "url": "https://insights.twilio.com" 19 | } 20 | ], 21 | "info": { 22 | "contact": { 23 | "email": "api-team@twilio.com", 24 | "name": "Twilio API Team" 25 | }, 26 | "version": "1.0.0", 27 | "title": "Sample/reference Twilio API.", 28 | "description": "This is the reference API for the rest-proxy server." 29 | }, 30 | "paths": { 31 | "/v2/Voice/fixed/response/without/body": {} 32 | } 33 | } -------------------------------------------------------------------------------- /src/services/twilio-api/twilio_oauth_v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "securitySchemes": { 4 | "accountSid_authToken": { 5 | "scheme": "basic", 6 | "type": "http" 7 | } 8 | }, 9 | "schemas": { 10 | "oauth.v1.authorize": { 11 | "type": "object", 12 | "properties": { 13 | "redirect_to": { 14 | "type": "string", 15 | "format": "uri", 16 | "nullable": true, 17 | "description": "The callback URL" 18 | } 19 | } 20 | }, 21 | "oauth.v1.token": { 22 | "type": "object", 23 | "properties": { 24 | "access_token": { 25 | "type": "string", 26 | "nullable": true, 27 | "description": "Token which carries the necessary information to access a Twilio resource directly." 28 | }, 29 | "refresh_token": { 30 | "type": "string", 31 | "nullable": true, 32 | "description": "Token which carries the information necessary to get a new access token." 33 | }, 34 | "id_token": { 35 | "type": "string", 36 | "nullable": true, 37 | "description": "Token which carries the information necessary of user profile." 38 | }, 39 | "token_type": { 40 | "type": "string", 41 | "nullable": true, 42 | "description": "Token type" 43 | }, 44 | "expires_in": { 45 | "type": "integer", 46 | "format": "int64", 47 | "nullable": true 48 | } 49 | } 50 | } 51 | } 52 | }, 53 | "info": { 54 | "title": "Twilio - Oauth", 55 | "description": "This is the public Twilio REST API.", 56 | "termsOfService": "https://www.twilio.com/legal/tos", 57 | "contact": { 58 | "name": "Twilio Support", 59 | "url": "https://support.twilio.com", 60 | "email": "support@twilio.com" 61 | }, 62 | "license": { 63 | "name": "Apache 2.0", 64 | "url": "https://www.apache.org/licenses/LICENSE-2.0.html" 65 | }, 66 | "version": "1.0.0" 67 | }, 68 | "openapi": "3.0.1", 69 | "paths": { 70 | "/v1/authorize": { 71 | "servers": [ 72 | { 73 | "url": "https://oauth.twilio.com" 74 | } 75 | ], 76 | "description": "", 77 | "x-twilio": { 78 | "defaultOutputProperties": [ 79 | "redirect_to" 80 | ], 81 | "pathType": "list" 82 | }, 83 | "get": { 84 | "description": "Retrieves authorize uri", 85 | "summary": "Retrieves authorize uri", 86 | "tags": [ 87 | "OauthV1Authorize" 88 | ], 89 | "parameters": [ 90 | { 91 | "name": "ResponseType", 92 | "in": "query", 93 | "description": "Response Type", 94 | "schema": { 95 | "type": "string" 96 | }, 97 | "examples": { 98 | "fetch": { 99 | "value": "code" 100 | } 101 | } 102 | }, 103 | { 104 | "name": "ClientId", 105 | "in": "query", 106 | "description": "The Client Identifier", 107 | "schema": { 108 | "type": "string" 109 | }, 110 | "examples": { 111 | "fetch": { 112 | "value": "OQ7cda1a615f05a95634e643aaaf7081d7" 113 | } 114 | } 115 | }, 116 | { 117 | "name": "RedirectUri", 118 | "in": "query", 119 | "description": "The url to which response will be redirected to", 120 | "schema": { 121 | "type": "string" 122 | }, 123 | "examples": { 124 | "fetch": { 125 | "value": "www.twilio.com" 126 | } 127 | } 128 | }, 129 | { 130 | "name": "Scope", 131 | "in": "query", 132 | "description": "The scope of the access request", 133 | "schema": { 134 | "type": "string" 135 | }, 136 | "examples": { 137 | "fetch": { 138 | "value": "offline_access" 139 | } 140 | } 141 | }, 142 | { 143 | "name": "State", 144 | "in": "query", 145 | "description": "An opaque value which can be used to maintain state between the request and callback", 146 | "schema": { 147 | "type": "string" 148 | }, 149 | "examples": { 150 | "fetch": { 151 | "value": "xvz" 152 | } 153 | } 154 | } 155 | ], 156 | "responses": { 157 | "302": { 158 | "content": { 159 | "application/json": { 160 | "schema": { 161 | "$ref": "#/components/schemas/oauth.v1.authorize" 162 | }, 163 | "examples": { 164 | "fetch": { 165 | "value": { 166 | "redirect_to": "https://www.twilio.com/authorize?response_type=code&client_id=OQ7cda1a615f05a95634e643aaaf7081d7&redirect_uri=www.twilio.com&scope=offline_access&state=xvz" 167 | } 168 | } 169 | } 170 | } 171 | }, 172 | "headers": { 173 | "Access-Control-Allow-Origin": { 174 | "description": "Specify the origin(s) allowed to access the resource", 175 | "schema": { 176 | "type": "string" 177 | }, 178 | "example": "*" 179 | }, 180 | "Access-Control-Allow-Methods": { 181 | "description": "Specify the HTTP methods allowed when accessing the resource", 182 | "schema": { 183 | "type": "string" 184 | }, 185 | "example": "POST, OPTIONS" 186 | }, 187 | "Access-Control-Allow-Headers": { 188 | "description": "Specify the headers allowed when accessing the resource", 189 | "schema": { 190 | "type": "string" 191 | }, 192 | "example": "Content-Type, Authorization" 193 | }, 194 | "Access-Control-Allow-Credentials": { 195 | "description": "Indicates whether the browser should include credentials", 196 | "schema": { 197 | "type": "boolean" 198 | } 199 | }, 200 | "Access-Control-Expose-Headers": { 201 | "description": "Headers exposed to the client", 202 | "schema": { 203 | "type": "string", 204 | "example": "X-Custom-Header1, X-Custom-Header2" 205 | } 206 | } 207 | }, 208 | "description": "Found" 209 | } 210 | }, 211 | "security": [], 212 | "operationId": "FetchAuthorize" 213 | } 214 | }, 215 | "/v1/token": { 216 | "servers": [ 217 | { 218 | "url": "https://oauth.twilio.com" 219 | } 220 | ], 221 | "description": "", 222 | "x-twilio": { 223 | "defaultOutputProperties": [], 224 | "pathType": "list" 225 | }, 226 | "post": { 227 | "description": "Issues a new Access token (optionally identity_token & refresh_token) in exchange of Oauth grant", 228 | "summary": "Issues a new Access token (optionally identity_token & refresh_token) in exchange of Oauth grant", 229 | "tags": [ 230 | "OauthV1Token" 231 | ], 232 | "responses": { 233 | "201": { 234 | "content": { 235 | "application/json": { 236 | "schema": { 237 | "$ref": "#/components/schemas/oauth.v1.token" 238 | }, 239 | "examples": { 240 | "create": { 241 | "value": { 242 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 243 | "refresh_token": "ghjbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 244 | "id_token": "eyJhbdGciOiIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 245 | "expires_in": 1438315200000, 246 | "token_type": "bearer" 247 | } 248 | } 249 | } 250 | } 251 | }, 252 | "headers": { 253 | "Access-Control-Allow-Origin": { 254 | "description": "Specify the origin(s) allowed to access the resource", 255 | "schema": { 256 | "type": "string" 257 | }, 258 | "example": "*" 259 | }, 260 | "Access-Control-Allow-Methods": { 261 | "description": "Specify the HTTP methods allowed when accessing the resource", 262 | "schema": { 263 | "type": "string" 264 | }, 265 | "example": "POST, OPTIONS" 266 | }, 267 | "Access-Control-Allow-Headers": { 268 | "description": "Specify the headers allowed when accessing the resource", 269 | "schema": { 270 | "type": "string" 271 | }, 272 | "example": "Content-Type, Authorization" 273 | }, 274 | "Access-Control-Allow-Credentials": { 275 | "description": "Indicates whether the browser should include credentials", 276 | "schema": { 277 | "type": "boolean" 278 | } 279 | }, 280 | "Access-Control-Expose-Headers": { 281 | "description": "Headers exposed to the client", 282 | "schema": { 283 | "type": "string", 284 | "example": "X-Custom-Header1, X-Custom-Header2" 285 | } 286 | } 287 | }, 288 | "description": "Created" 289 | } 290 | }, 291 | "security": [], 292 | "operationId": "CreateToken", 293 | "requestBody": { 294 | "content": { 295 | "application/x-www-form-urlencoded": { 296 | "schema": { 297 | "type": "object", 298 | "title": "CreateTokenRequest", 299 | "properties": { 300 | "GrantType": { 301 | "type": "string", 302 | "description": "Grant type is a credential representing resource owner's authorization which can be used by client to obtain access token." 303 | }, 304 | "ClientId": { 305 | "type": "string", 306 | "description": "A 34 character string that uniquely identifies this OAuth App." 307 | }, 308 | "ClientSecret": { 309 | "type": "string", 310 | "description": "The credential for confidential OAuth App." 311 | }, 312 | "Code": { 313 | "type": "string", 314 | "description": "JWT token related to the authorization code grant type." 315 | }, 316 | "RedirectUri": { 317 | "type": "string", 318 | "description": "The redirect uri" 319 | }, 320 | "Audience": { 321 | "type": "string", 322 | "description": "The targeted audience uri" 323 | }, 324 | "RefreshToken": { 325 | "type": "string", 326 | "description": "JWT token related to refresh access token." 327 | }, 328 | "Scope": { 329 | "type": "string", 330 | "description": "The scope of token" 331 | } 332 | }, 333 | "required": [ 334 | "GrantType", 335 | "ClientId" 336 | ] 337 | }, 338 | "examples": { 339 | "create": { 340 | "value": { 341 | "ClientId": "OQ7cda1a615f05a95634e643aaaf7081d7", 342 | "ClientSecret": "sUWblrQ4wx_aYkdAWjHXNvHinynkYOgBoiRyEQUeEntpgDEG47qnBFD98yoEzsTh", 343 | "GrantType": "client_credentials", 344 | "RedirectUri": "", 345 | "Audience": "", 346 | "Code": "", 347 | "RefreshToken": "refresh_token", 348 | "Scope": "scope" 349 | } 350 | } 351 | } 352 | } 353 | } 354 | } 355 | } 356 | } 357 | }, 358 | "servers": [ 359 | { 360 | "url": "https://oauth.twilio.com" 361 | } 362 | ], 363 | "tags": [ 364 | { 365 | "name": "OauthV1Authorize" 366 | }, 367 | { 368 | "name": "OauthV1Token" 369 | } 370 | ], 371 | "security": [ 372 | { 373 | "accountSid_authToken": [] 374 | } 375 | ] 376 | } -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "*.test.js", 5 | "rules": { 6 | "no-unused-expressions": "off" 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /test/base-commands/base-command.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { expect, test } = require('@twilio/cli-test'); 4 | 5 | const BaseCommand = require('../../src/base-commands/base-command'); 6 | const { Logger, LoggingLevel } = require('../../src/services/messaging/logging'); 7 | const { OutputFormats } = require('../../src/services/output-formats'); 8 | const { Config } = require('../../src/services/config'); 9 | const { TwilioCliError } = require('../../src/services/error'); 10 | 11 | const baseCommandTest = test.twilioCliEnv().do(async (ctx) => { 12 | ctx.testCmd = new BaseCommand([], ctx.fakeConfig); 13 | await ctx.testCmd.run(); 14 | }); 15 | 16 | const childCommandTest = test.twilioCliEnv().do(async (ctx) => { 17 | class ChildCommand extends BaseCommand {} 18 | 19 | ctx.testCmd = new ChildCommand([], ctx.fakeConfig); 20 | await ctx.testCmd.run(); 21 | }); 22 | 23 | describe('base-commands', () => { 24 | describe('base-command', () => { 25 | baseCommandTest.stderr().it('should initialize properly', (ctx) => { 26 | expect(ctx.testCmd.outputProcessor).to.equal(OutputFormats.columns); 27 | expect(ctx.testCmd.logger).to.be.an.instanceOf(Logger); 28 | expect(ctx.testCmd.logger.config.level).to.equal(LoggingLevel.info); 29 | expect(ctx.testCmd.inquirer).to.not.equal(undefined); 30 | expect(ctx.stderr).to.equal(''); 31 | }); 32 | 33 | childCommandTest.stderr().it('should initialize properly from children', (ctx) => { 34 | expect(ctx.testCmd.outputProcessor).to.equal(OutputFormats.columns); 35 | expect(ctx.testCmd.logger).to.be.an.instanceOf(Logger); 36 | expect(ctx.testCmd.logger.config.level).to.equal(LoggingLevel.info); 37 | expect(ctx.testCmd.inquirer).to.not.equal(undefined); 38 | expect(ctx.stderr).to.equal(''); 39 | }); 40 | 41 | test 42 | .twilioCliEnv(Config) 43 | .stderr() 44 | .do(async (ctx) => { 45 | ctx.testCmd = new BaseCommand(['-l', 'debug'], ctx.fakeConfig); 46 | await ctx.testCmd.run(); 47 | }) 48 | .it('should debug log the config file path', (ctx) => { 49 | expect(ctx.testCmd.logger.config.level).to.equal(LoggingLevel.debug); 50 | const expectedConfigFile = path.join(ctx.fakeConfig.configDir, 'config.json'); 51 | expect(ctx.stderr).to.contain(`[DEBUG] Config File: ${expectedConfigFile}`); 52 | }); 53 | 54 | baseCommandTest 55 | .stderr() 56 | .do((ctx) => ctx.testCmd.catch(new TwilioCliError('oy!'))) 57 | .exit(1) 58 | .it('can catch errors and exit', (ctx) => { 59 | expect(ctx.stderr).to.contain('oy!'); 60 | }); 61 | 62 | test 63 | .twilioCliEnv() 64 | .stdout() 65 | .do(async (ctx) => { 66 | ctx.testCmd = new BaseCommand(['-o', 'json'], ctx.fakeConfig); 67 | await ctx.testCmd.run(); 68 | await ctx.testCmd.catch(new TwilioCliError('oh no', 2003, { errors: [{ message: 'oh no' }] })); 69 | }) 70 | .exit(20) 71 | .it('can correctly cut exit code', (ctx) => { 72 | expect(ctx.stdout).to.contain(`"message": "oh no"`); 73 | }); 74 | 75 | test 76 | .twilioCliEnv() 77 | .stdout() 78 | .do(async (ctx) => { 79 | ctx.testCmd = new BaseCommand(['-o', 'json'], ctx.fakeConfig); 80 | await ctx.testCmd.run(); 81 | await ctx.testCmd.catch(new TwilioCliError('oh no', 1, { errors: [{ message: 'oh no' }] })); 82 | }) 83 | .exit(1) 84 | .it('can correctly display error data', (ctx) => { 85 | expect(ctx.stdout).to.contain(`"message": "oh no"`); 86 | }); 87 | 88 | test 89 | .twilioCliEnv() 90 | .do((ctx) => { 91 | ctx.testCmd = new BaseCommand([], ctx.fakeConfig); 92 | }) 93 | .it('can catch errors before initialization', async (ctx) => { 94 | await expect(ctx.testCmd.catch(new TwilioCliError('hey-o!'))).to.be.rejectedWith(TwilioCliError); 95 | }); 96 | 97 | describe('getIssueUrl', () => { 98 | baseCommandTest.it('follows the proper precedence order', (ctx) => { 99 | const pjson = { 100 | bugs: 'could be', 101 | homepage: 'maybe', 102 | repository: 'nope', 103 | }; 104 | 105 | expect(ctx.testCmd.getIssueUrl({ pjson })).to.equal('could be'); 106 | 107 | delete pjson.bugs; 108 | expect(ctx.testCmd.getIssueUrl({ pjson })).to.equal('maybe'); 109 | 110 | delete pjson.homepage; 111 | expect(ctx.testCmd.getIssueUrl({ pjson })).to.equal('nope'); 112 | }); 113 | 114 | baseCommandTest.it('handles url properties', (ctx) => { 115 | expect(ctx.testCmd.getIssueUrl({ pjson: { bugs: { email: 'me', url: 'you' } } })).to.equal('you'); 116 | }); 117 | 118 | baseCommandTest.it('use the main repo when no url is found', (ctx) => { 119 | expect(ctx.testCmd.getIssueUrl({ pjson: { anything: 'nothing' } })).to.equal( 120 | 'https://github.com/twilio/twilio-cli/issues', 121 | ); 122 | }); 123 | }); 124 | 125 | describe('sanitizeDateString', () => { 126 | baseCommandTest.it('check date is sliced correctly', (ctx) => { 127 | expect(ctx.testCmd.sanitizeDateString('Fri May 24 2019 11:43:11 GMT-0600 (MDT)')).to.equal( 128 | 'May 24 2019 11:43:11 GMT-0600', 129 | ); 130 | }); 131 | baseCommandTest.it('check other timezone date is sliced correctly', (ctx) => { 132 | expect(ctx.testCmd.sanitizeDateString('Fri May 24 2019 11:43:11 GMT-0700 (PDT)')).to.equal( 133 | 'May 24 2019 11:43:11 GMT-0700', 134 | ); 135 | }); 136 | baseCommandTest.it('check output if timezone in parenthesis is not included', (ctx) => { 137 | expect(ctx.testCmd.sanitizeDateString('Fri May 24 2019 11:43:11 GMT-0700')).to.equal( 138 | 'May 24 2019 11:43:11 GMT-0700', 139 | ); 140 | }); 141 | baseCommandTest.it('return empty string if the date is empty', (ctx) => { 142 | expect(ctx.testCmd.sanitizeDateString('')).to.equal(''); 143 | }); 144 | }); 145 | 146 | describe('output', () => { 147 | const outputTest = baseCommandTest.stdout(); 148 | 149 | outputTest.it('should output a single object', (ctx) => { 150 | ctx.testCmd.output({ foo: 'foo', bar: 'bar' }); 151 | expect(ctx.stdout).to.contain('Foo Bar\nfoo bar'); 152 | }); 153 | 154 | outputTest.it('should output an array of objects', (ctx) => { 155 | ctx.testCmd.output([ 156 | { foo: 'foo', bar: 'bar' }, 157 | { foo: '2', bar: '2' }, 158 | ]); 159 | expect(ctx.stdout).to.contain('Foo Bar\nfoo bar\n2 2'); 160 | }); 161 | 162 | outputTest.it('should output requested properties', (ctx) => { 163 | ctx.testCmd.output( 164 | [ 165 | { foo: 'foo', bar: 'bar', baz: 'baz' }, 166 | { foo: '2', bar: '2', baz: '2' }, 167 | ], 168 | 'foo, bar', 169 | ); 170 | expect(ctx.stdout).to.contain('Foo Bar\nfoo bar\n2 2'); 171 | }); 172 | 173 | outputTest.stderr().it('should warn if invalid property name passed', (ctx) => { 174 | ctx.testCmd.output( 175 | [ 176 | { foo: 'foo', bar: 'bar', baz: 'baz' }, 177 | { foo: '2', bar: '2', baz: '2' }, 178 | ], 179 | 'foo, barn', 180 | ); 181 | expect(ctx.stdout).to.contain('Foo\nfoo\n2'); 182 | expect(ctx.stderr).to.contain('"barn" is not a valid property name.'); 183 | }); 184 | 185 | outputTest.it('should output requested object properties', (ctx) => { 186 | ctx.testCmd.output( 187 | [ 188 | { foo: 'foo', bar: { baz: 1, boz: 2 } }, 189 | { foo: '2', bar: { baz: 3, boz: 'four' } }, 190 | ], 191 | 'foo, bar', 192 | ); 193 | expect(ctx.stdout).to.contain('foo {"baz":1,"boz":2}'); 194 | expect(ctx.stdout).to.contain('2 {"baz":3,"boz":"four"}'); 195 | }); 196 | 197 | outputTest.stderr().it('should output a message when the array is empty', (ctx) => { 198 | ctx.testCmd.output([]); 199 | expect(ctx.stdout).to.be.empty; 200 | expect(ctx.stderr).to.contain('No results'); 201 | }); 202 | 203 | test 204 | .twilioCliEnv(Config) 205 | .do(async (ctx) => { 206 | ctx.testCmd = new BaseCommand(['-o', 'json'], ctx.fakeConfig); 207 | await ctx.testCmd.run(); 208 | }) 209 | .stdout() 210 | .it('should output an array of objects as JSON', (ctx) => { 211 | const testData = [ 212 | { foo: 'foo', bar: 'bar' }, 213 | { foo: '2', bar: '2' }, 214 | ]; 215 | ctx.testCmd.output(testData); 216 | const outputObject = JSON.parse(ctx.stdout); 217 | expect(outputObject[0].foo).to.equal(testData[0].foo); 218 | }); 219 | 220 | test 221 | .twilioCliEnv(Config) 222 | .do(async (ctx) => { 223 | ctx.testCmd = new BaseCommand(['-o', 'tsv'], ctx.fakeConfig); 224 | await ctx.testCmd.run(); 225 | }) 226 | .stdout() 227 | .it('should output an array of objects as TSV', (ctx) => { 228 | const testData = [ 229 | { FOO: 'foo', BAR: 'bar' }, 230 | { FOO: '2', BAR: '2' }, 231 | ]; 232 | ctx.testCmd.output(testData); 233 | expect(ctx.stdout).to.contain('FOO\tBAR\nfoo\tbar\n2\t2'); 234 | }); 235 | 236 | test 237 | .twilioCliEnv(Config) 238 | .do(async (ctx) => { 239 | ctx.testCmd = new BaseCommand(['-o', 'none'], ctx.fakeConfig); 240 | await ctx.testCmd.run(); 241 | }) 242 | .stdout() 243 | .it('should not output to stdout with none flag', (ctx) => { 244 | const testData = [ 245 | { foo: 'foo', bar: 'bar' }, 246 | { foo: 'test', bar: 'test' }, 247 | ]; 248 | ctx.testCmd.output(testData); 249 | expect(ctx.stdout).to.be.empty; 250 | expect(ctx.testCmd.outputProcessor).to.be.undefined; 251 | }); 252 | 253 | test 254 | .twilioCliEnv(Config) 255 | .do(async (ctx) => { 256 | ctx.testCmd = new BaseCommand(['--silent'], ctx.fakeConfig); 257 | await ctx.testCmd.run(); 258 | }) 259 | .stdout() 260 | .it('should not output to stdout with silent flag', (ctx) => { 261 | const testData = [{ foo: 'foo', bar: 'bar' }]; 262 | ctx.testCmd.output(testData); 263 | expect(ctx.stdout).to.be.empty; 264 | expect(ctx.testCmd.logger.config.level).to.equal(LoggingLevel.none); 265 | expect(ctx.testCmd.outputProcessor).to.be.undefined; 266 | }); 267 | }); 268 | 269 | describe('getPromptMessage', () => { 270 | baseCommandTest.it('adds a colon to the end of the message', (ctx) => { 271 | expect(ctx.testCmd.getPromptMessage('Name: ')).to.equal('Name:'); 272 | expect(ctx.testCmd.getPromptMessage('Number.')).to.equal('Number:'); 273 | expect(ctx.testCmd.getPromptMessage(' Address ')).to.equal('Address:'); 274 | }); 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /test/helpers/init.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | process.env.TS_NODE_PROJECT = path.resolve('test/tsconfig.json'); 4 | -------------------------------------------------------------------------------- /test/release-scripts/change-log-helper.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const { expect, test } = require('@twilio/cli-test'); 4 | const mock = require('mock-fs'); 5 | 6 | const { ChangeLogHelper } = require('../../.github/scripts/change-log-helper'); 7 | 8 | const defaultVersionRegex = /(\d+)\.(\d+)\.(\d+)/; 9 | const defaultDateRegex = /\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])/; 10 | const cliCoreChangelogFile = 'CHANGES.md'; 11 | const oaiChangelogFile = 'OAI_CHANGES.md'; 12 | 13 | describe('release-scripts', () => { 14 | describe('change-log-helper', () => { 15 | beforeEach(() => { 16 | mock({ 17 | 'CHANGES.md': 18 | '[2021-08-26] Version 2.28.1\n' + 19 | '---------------------------\n' + 20 | '**Test**\n' + 21 | '- Changes for test\n' + 22 | '\n' + 23 | '[2021-08-12] Version 2.28.0\n' + 24 | '---------------------------\n' + 25 | '**Library - Test**\n' + 26 | '- Test changes Library\n' + 27 | '\n' + 28 | '\n' + 29 | '[2021-07-29] Version 2.27.1\n' + 30 | '---------------------------\n' + 31 | '**Test**\n' + 32 | '- Add test changes', 33 | 'OAI_CHANGES.md': 34 | '[2021-09-08] Version 1.21.0\n' + 35 | '---------------------------\n' + 36 | '**OAI**\n' + 37 | '- Latest changes from OAI\n' + 38 | '\n' + 39 | '**Second change**\n' + 40 | '- Added OAI change\n' + 41 | '\n' + 42 | '\n' + 43 | '[2021-08-25] Version 1.20.1\n' + 44 | '---------------------------\n' + 45 | '**Previous OAI change**\n' + 46 | '- Previous OAI\n' + 47 | '- Test Change OAI\n' + 48 | '\n' + 49 | '\n' + 50 | '[2021-08-11] Version 1.20.0\n' + 51 | '---------------------------\n' + 52 | '**Third Version**\n' + 53 | '- Changes in third version\n' + 54 | '\n', 55 | }); 56 | }); 57 | afterEach(() => { 58 | mock.restore(); 59 | }); 60 | test.it('change-log-helper Default', () => { 61 | const changeLogHelper = new ChangeLogHelper(); 62 | expect(changeLogHelper.versionRegex.toString()).to.eq(defaultVersionRegex.toString()); 63 | expect(changeLogHelper.dateRegex.toString()).to.eq(defaultDateRegex.toString()); 64 | expect(changeLogHelper.cliCoreChangelogFilename).to.eq(cliCoreChangelogFile); 65 | expect(changeLogHelper.oaiChangelogFilename).to.eq(oaiChangelogFile); 66 | }); 67 | 68 | test.it('change-log-helper with custom values', () => { 69 | const cliCoreChangelog = 'test-changes.md'; 70 | const changeLogHelper = new ChangeLogHelper(cliCoreChangelog); 71 | expect(changeLogHelper.cliCoreChangelogFilename).to.eq(cliCoreChangelog); 72 | expect(changeLogHelper.oaiChangelogFilename).to.eq(oaiChangelogFile); 73 | expect(changeLogHelper.versionRegex.toString()).to.eq(defaultVersionRegex.toString()); 74 | expect(changeLogHelper.dateRegex.toString()).to.eq(defaultDateRegex.toString()); 75 | }); 76 | 77 | test.it('getAllReleaseVersionsFromGivenDate', async () => { 78 | const changeLogHelper = new ChangeLogHelper(); 79 | let versions = await changeLogHelper.getAllReleaseVersionsFromGivenDate('2021-08-26'); 80 | expect(versions.length).to.eq(2); 81 | versions = await changeLogHelper.getAllReleaseVersionsFromGivenDate('2021-08-12'); 82 | expect(versions.length).to.eq(3); 83 | versions = await changeLogHelper.getAllReleaseVersionsFromGivenDate('2021-09-09'); 84 | expect(versions.length).to.eq(1); 85 | }); 86 | 87 | test.it('getLatestChangelogGeneratedDate', async () => { 88 | const invalidChangeLogHelper = new ChangeLogHelper('invalid-changes.md'); 89 | await expect(invalidChangeLogHelper.getLatestChangelogGeneratedDate()).to.be.rejectedWith( 90 | 'File not found: invalid-changes.md', 91 | ); 92 | const changeLogHelper = new ChangeLogHelper(); 93 | const date = await changeLogHelper.getLatestChangelogGeneratedDate(); 94 | expect(date).to.eq('2021-08-26'); 95 | }); 96 | 97 | test.it('getChangesAfterGivenDate', async () => { 98 | const invalidChangeLogHelper = new ChangeLogHelper('CHANGES.md', 'invalid-oai-changes.md'); 99 | await expect(invalidChangeLogHelper.getChangesAfterGivenDate('2021-08-26')).to.be.rejectedWith( 100 | 'File not found: invalid-oai-changes.md', 101 | ); 102 | const changeLogHelper = new ChangeLogHelper(); 103 | const changes = await changeLogHelper.getChangesAfterGivenDate('2021-08-26'); 104 | expect(changes).to.contain('Latest changes from OAI'); 105 | expect(changes).to.not.contain('Previous OAI change'); 106 | const moreChanges = await changeLogHelper.getChangesAfterGivenDate('2021-08-12'); 107 | expect(moreChanges).to.contain('Latest changes from OAI'); 108 | expect(moreChanges).to.contain('Previous OAI change'); 109 | const invalidChanges = await changeLogHelper.getChangesAfterGivenDate('2020-08-12'); 110 | expect(invalidChanges).to.contain(''); 111 | }); 112 | 113 | test.it('appendChangesToChangelog', async () => { 114 | const invalidChangeLogHelper = new ChangeLogHelper('invalid-changes.md'); 115 | await expect(invalidChangeLogHelper.appendChangesToChangelog()).to.be.rejectedWith( 116 | 'Error: File not found: invalid-changes.md', 117 | ); 118 | expect(fs.existsSync('changeLog.md')).to.eq(false); 119 | const changeLogHelper = new ChangeLogHelper(); 120 | await changeLogHelper.appendChangesToChangelog(); 121 | expect(fs.existsSync('changeLog.md')).to.eq(true); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /test/release-scripts/get-version-type.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | const { expect, test } = require('@twilio/cli-test'); 3 | const proxyquire = require('proxyquire'); 4 | 5 | describe('release-scripts', () => { 6 | describe('get-version-type', () => { 7 | test.it('Get version upgrade type - Major', async () => { 8 | mockChangeLogHelper = { 9 | getLatestChangelogGeneratedDate: async () => '2021-09-08', 10 | getAllReleaseVersionsFromGivenDate: async () => ['2.28.1', '1.21.0'], 11 | }; 12 | 13 | const { getVersionType } = proxyquire('../../.github/scripts/get-version-type', { 14 | './change-log-helper': { 15 | ChangeLogHelper: class { 16 | constructor() { 17 | return mockChangeLogHelper; 18 | } 19 | }, 20 | }, 21 | }); 22 | 23 | const result = await getVersionType(); 24 | 25 | expect(result).to.eq(0); 26 | }); 27 | test.it('Get version upgrade type - Minor', async () => { 28 | mockChangeLogHelper = { 29 | getLatestChangelogGeneratedDate: async () => '2021-09-08', 30 | getAllReleaseVersionsFromGivenDate: async () => ['2.28.0', '2.27.0'], 31 | }; 32 | 33 | const { getVersionType } = proxyquire('../../.github/scripts/get-version-type', { 34 | './change-log-helper': { 35 | ChangeLogHelper: class { 36 | constructor() { 37 | return mockChangeLogHelper; 38 | } 39 | }, 40 | }, 41 | }); 42 | 43 | const result = await getVersionType(); 44 | 45 | expect(result).to.eq(1); 46 | }); 47 | 48 | test.it('Get version upgrade type - Patch', async () => { 49 | mockChangeLogHelper = { 50 | getLatestChangelogGeneratedDate: async () => '2021-09-08', 51 | getAllReleaseVersionsFromGivenDate: async () => ['2.28.1', '2.28.0'], 52 | }; 53 | 54 | const { getVersionType } = proxyquire('../../.github/scripts/get-version-type', { 55 | './change-log-helper': { 56 | ChangeLogHelper: class { 57 | constructor() { 58 | return mockChangeLogHelper; 59 | } 60 | }, 61 | }, 62 | }); 63 | 64 | const result = await getVersionType(); 65 | 66 | expect(result).to.eq(2); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/services/api-schema.test.js: -------------------------------------------------------------------------------- 1 | const { expect, test } = require('@twilio/cli-test'); 2 | 3 | const TwilioSchemaConverter = require('../../src/services/api-schema/twilio-converter'); 4 | const { logger, LoggingLevel } = require('../../src/services/messaging/logging'); 5 | 6 | describe('services', () => { 7 | describe('api-schema', () => { 8 | describe('twilio-converter', () => { 9 | before(() => { 10 | logger.config.level = LoggingLevel.debug; 11 | }); 12 | 13 | after(() => { 14 | logger.config.level = LoggingLevel.info; 15 | }); 16 | 17 | test.stderr().it('handles dates, objects, and strings', (ctx) => { 18 | /* eslint-disable camelcase */ 19 | const schema = { 20 | type: 'object', 21 | properties: { 22 | date_created: { type: 'string', format: 'date-time' }, 23 | date_updated: { type: 'string', format: 'date-time-rfc-2822' }, 24 | message_type: { type: 'array', items: { type: 'string' } }, 25 | free_form_obj: { type: 'object' }, 26 | some_uri: { type: 'string', format: 'uri' }, 27 | }, 28 | }; 29 | 30 | const input = { 31 | date_created: '2008-09-15T15:53:00+05:00', 32 | date_updated: 'Mon, 15 Sep 2008 15:53:00 +0500', 33 | message_type: ['not', 'a', 'message'], 34 | free_form_obj: { first_key: 'first_value', second_key: 'second_value' }, 35 | some_uri: '/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json', 36 | }; 37 | 38 | const expected = { 39 | dateCreated: new Date('September 15, 2008 15:53 GMT+0500'), 40 | dateUpdated: new Date('September 15, 2008 15:53 GMT+0500'), 41 | messageType: ['not', 'a', 'message'], 42 | freeFormObj: { first_key: 'first_value', second_key: 'second_value' }, 43 | someUri: '/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json', 44 | }; 45 | 46 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 47 | 48 | expect(actual).to.eql(expected); 49 | expect(ctx.stderr).to.be.empty; 50 | }); 51 | 52 | test.stderr().it('handles unknown string formats', (ctx) => { 53 | const schema = { type: 'string', format: 'price' }; 54 | const input = '$1.23'; 55 | const expected = input; 56 | 57 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 58 | 59 | expect(actual).to.eql(expected); 60 | expect(ctx.stderr).to.contain('price'); 61 | }); 62 | 63 | test.stderr().it('handles poorly formatted dates', (ctx) => { 64 | const schema = { type: 'string', format: 'date-time' }; 65 | const input = 'abc123'; 66 | const expected = input; 67 | 68 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 69 | 70 | expect(actual).to.eql(expected); 71 | expect(ctx.stderr).to.contain('date-time'); 72 | }); 73 | 74 | test.stderr().it('handles unknown schemas', (ctx) => { 75 | const schema = { type: 'bugs' }; 76 | const input = ['insects']; 77 | const expected = input; 78 | 79 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 80 | 81 | expect(actual).to.eql(expected); 82 | expect(ctx.stderr).to.contain('bugs'); 83 | }); 84 | 85 | test.stderr().it('handles null values when nullable not allowed', (ctx) => { 86 | const schema = { type: 'object' }; 87 | const input = null; 88 | const expected = input; 89 | 90 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 91 | 92 | expect(actual).to.eql(expected); 93 | expect(ctx.stderr).to.contain('nullable'); 94 | }); 95 | 96 | test.stderr().it('handles null values when nullable is allowed', (ctx) => { 97 | const schema = { type: 'array', nullable: true }; 98 | const input = null; 99 | const expected = input; 100 | 101 | const actual = new TwilioSchemaConverter().convertSchema(schema, input); 102 | 103 | expect(actual).to.eql(expected); 104 | expect(ctx.stderr).to.be.empty; 105 | }); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/services/cli-http-client.test.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | 3 | const { expect, test } = require('@twilio/cli-test'); 4 | 5 | const CliRequestClient = require('../../src/services/cli-http-client'); 6 | const { TwilioCliError } = require('../../src/services/error'); 7 | const { Logger, LoggingLevel } = require('../../src/services/messaging/logging'); 8 | const pkg = require('../../package.json'); 9 | 10 | describe('services', () => { 11 | describe('cli-http-client', () => { 12 | const logger = new Logger({ 13 | level: LoggingLevel.none, 14 | }); 15 | 16 | test.it('should make an http request', async () => { 17 | const client = new CliRequestClient( 18 | 'blah', 19 | logger, 20 | (options) => { 21 | expect(options.url).to.equal('https://foo.com/bar'); 22 | return { status: 200, data: 'foo', headers: {} }; 23 | }, 24 | 'blah', 25 | ); 26 | expect(client.commandName).to.equal('blah'); 27 | expect(client.pluginName).to.equal('blah'); 28 | const response = await client.request({ 29 | method: 'POST', 30 | uri: 'https://foo.com/bar', 31 | username: 'AC1234', 32 | password: 'aaaaaaaaa', 33 | headers: { 'User-Agent': 'test' }, 34 | params: { x: 1, y: 2 }, 35 | data: { foo: 'bar' }, 36 | }); 37 | 38 | expect(response.statusCode).to.equal(200); 39 | expect(response.body).to.equal('foo'); 40 | }); 41 | 42 | test.it('should add the correct http agent for proxy', async () => { 43 | process.env.HTTP_PROXY = 'http://someproxy.com:8080'; 44 | const client = new CliRequestClient('blah', logger, { defaults: {} }, 'blah'); 45 | const httpAgent = client.http.defaults.httpsAgent; 46 | expect(client.pluginName).to.equal('blah'); 47 | expect(httpAgent.proxy.host).to.equal('someproxy.com'); 48 | expect(httpAgent.proxy.port).to.equal(8080); 49 | }); 50 | 51 | test 52 | .nock('https://foo.com', (api) => { 53 | api.get('/bar').delay(100).reply(200); 54 | }) 55 | .it('throws a TwilioCliError on response timeouts', async () => { 56 | const client = new CliRequestClient('bleh', logger); 57 | const request = client.request({ method: 'GET', uri: 'https://foo.com/bar', timeout: 1 }); 58 | await expect(request).to.be.rejectedWith(TwilioCliError); 59 | }); 60 | 61 | test 62 | .nock('https://foo.com', (api) => { 63 | api.get('/bar').replyWithError({ code: 'ETIMEDOUT' }); 64 | }) 65 | .it('throws a TwilioCliError on connection timeouts', async () => { 66 | const client = new CliRequestClient('bleh', logger); 67 | const request = client.request({ method: 'GET', uri: 'https://foo.com/bar' }); 68 | await expect(request).to.be.rejectedWith(TwilioCliError); 69 | }); 70 | 71 | test 72 | .nock('https://foo.com', (api) => { 73 | api.get('/bar').reply(200, '', { 74 | 'User-Agent': `twilio-cli/2.27.1 ${pkg.name}\/${pkg.version} \(${os.platform()} ${os.arch()}\) dummyCommand`, 75 | }); 76 | }) 77 | .it('correctly sets user-agent', async () => { 78 | const client = new CliRequestClient('dummyCommand', logger, '', 'twilio-cli/2.27.1'); 79 | const response = await client.request({ 80 | method: 'GET', 81 | uri: 'https://foo.com/bar', 82 | }); 83 | expect(client.lastRequest.headers['User-Agent']).to.equal( 84 | `twilio-cli/2.27.1 ${pkg.name}\/${pkg.version} \(${os.platform()} ${os.arch()}\) dummyCommand`, 85 | ); 86 | expect(response.statusCode).to.equal(200); 87 | }); 88 | 89 | test 90 | .nock('https://foo.com', (api) => { 91 | api.get('/bar').reply(200, '', { 92 | 'User-Agent': ` ${pkg.name}\/${pkg.version} \(${os.platform()} ${os.arch()}\) dummyCommand`, 93 | }); 94 | }) 95 | .it('correctly sets user-agent with empty plugin value', async () => { 96 | const client = new CliRequestClient('dummyCommand', logger, '', ''); 97 | const response = await client.request({ 98 | method: 'GET', 99 | uri: 'https://foo.com/bar', 100 | }); 101 | expect(client.lastRequest.headers['User-Agent']).to.equal( 102 | ` ${pkg.name}\/${pkg.version} \(${os.platform()} ${os.arch()}\) dummyCommand`, 103 | ); 104 | expect(response.statusCode).to.equal(200); 105 | }); 106 | 107 | test 108 | .nock('https://foo.com', (api) => { 109 | api.get('/bar?foo=bar&foo=baz').reply(200); 110 | }) 111 | .it('correctly serializes array parameters', async () => { 112 | const client = new CliRequestClient('bleh', logger); 113 | const response = await client.request({ 114 | method: 'GET', 115 | uri: 'https://foo.com/bar', 116 | params: { 117 | foo: ['bar', 'baz'], 118 | }, 119 | }); 120 | expect(response.statusCode).to.equal(200); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/services/env.test.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | 4 | const { expect, test } = require('@twilio/cli-test'); 5 | 6 | const configureEnv = require('../../src/services/env'); 7 | 8 | const ORIGINAL_ENV = { ...process.env }; 9 | const DEFAULT_DIR = path.join('home', '.twilio-cli'); 10 | 11 | describe('services', () => { 12 | describe('env', () => { 13 | describe('configureEnv', () => { 14 | beforeEach(() => { 15 | process.env.HOME = 'home'; 16 | process.env.USERPROFILE = 'user-profile'; 17 | }); 18 | 19 | afterEach(() => { 20 | process.env = ORIGINAL_ENV; 21 | }); 22 | 23 | test.it('should default all dir vars', () => { 24 | configureEnv(); 25 | 26 | expect(process.env.TWILIO_CACHE_DIR).to.equal(DEFAULT_DIR); 27 | expect(process.env.TWILIO_CONFIG_DIR).to.equal(DEFAULT_DIR); 28 | expect(process.env.TWILIO_DATA_DIR).to.equal(DEFAULT_DIR); 29 | }); 30 | 31 | test.it('should not screw with user-configured vars', () => { 32 | process.env.TWILIO_CACHE_DIR = 'cache-dir'; 33 | process.env.TWILIO_CONFIG_DIR = 'config-dir'; 34 | process.env.TWILIO_DATA_DIR = 'data-dir'; 35 | 36 | configureEnv(); 37 | 38 | expect(process.env.TWILIO_CACHE_DIR).to.equal('cache-dir'); 39 | expect(process.env.TWILIO_CONFIG_DIR).to.equal('config-dir'); 40 | expect(process.env.TWILIO_DATA_DIR).to.equal('data-dir'); 41 | }); 42 | 43 | test.it('should only default unset vars', () => { 44 | process.env.TWILIO_CACHE_DIR = 'uh-dur'; 45 | 46 | configureEnv(); 47 | 48 | expect(process.env.TWILIO_CACHE_DIR).to.equal('uh-dur'); 49 | expect(process.env.TWILIO_CONFIG_DIR).to.equal(DEFAULT_DIR); 50 | expect(process.env.TWILIO_DATA_DIR).to.equal(DEFAULT_DIR); 51 | }); 52 | 53 | test.it('should use the user profile if no home found', () => { 54 | delete process.env.HOME; 55 | 56 | configureEnv(); 57 | 58 | expect(process.env.TWILIO_CACHE_DIR).to.equal(path.join('user-profile', '.twilio-cli')); 59 | }); 60 | 61 | test.it('should fallback to the OS home dir if no home or user profile found', () => { 62 | delete process.env.HOME; 63 | delete process.env.USERPROFILE; 64 | 65 | configureEnv(); 66 | 67 | expect(process.env.TWILIO_CACHE_DIR).to.equal(path.join(os.homedir(), '.twilio-cli')); 68 | }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/services/javascript-utilities.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | const { expect, test } = require('@twilio/cli-test'); 3 | 4 | const { 5 | doesObjectHaveProperty, 6 | translateKeys, 7 | translateValues, 8 | sleep, 9 | splitArray, 10 | instanceOf, 11 | } = require('../../src/services/javascript-utilities'); 12 | 13 | describe('services', () => { 14 | describe('javascript-utilities', () => { 15 | describe('doesObjectHaveProperty', () => { 16 | test.it('should find the foo property on a standard object', () => { 17 | expect(doesObjectHaveProperty({ foo: 'bar' }, 'foo')).to.be.true; 18 | }); 19 | 20 | test.it('should not find the foobar property on a standard object', () => { 21 | expect(doesObjectHaveProperty({ foo: 'bar' }, 'foobar')).to.be.false; 22 | }); 23 | 24 | test.it('should find the foo property on a prototypeless object', () => { 25 | const testObj = Object.create(null); 26 | testObj.foo = 'bar'; 27 | expect(doesObjectHaveProperty(testObj, 'foo')).to.be.true; 28 | }); 29 | 30 | test.it('should not find the foobar property on a prototypeless object', () => { 31 | const testObj = Object.create(null); 32 | testObj.foo = 'bar'; 33 | expect(doesObjectHaveProperty(testObj, 'foobar')).to.be.false; 34 | }); 35 | 36 | test.it('should not crash if object null or undefined', () => { 37 | expect(doesObjectHaveProperty(null, 'foobar')).to.be.false; 38 | expect(doesObjectHaveProperty(undefined, 'foobar')).to.be.false; 39 | }); 40 | }); 41 | 42 | describe('translateKeys', () => { 43 | const keyFunc = (key) => key.toUpperCase(); 44 | 45 | test.it('should translate the keys of a complex object', () => { 46 | const actual = { 47 | nullValue: null, 48 | anArray: ['a', 'b', 'c'], 49 | nested: { level2: { level3: 'value' } }, 50 | custom: { 51 | one: 1, 52 | two: 2, 53 | three: 3, 54 | toJSON() { 55 | return { one: this.one, two: this.two }; 56 | }, 57 | }, 58 | }; 59 | 60 | const expected = { 61 | NULLVALUE: null, 62 | ANARRAY: ['a', 'b', 'c'], 63 | NESTED: { LEVEL2: { LEVEL3: 'value' } }, 64 | CUSTOM: { 65 | ONE: 1, 66 | TWO: 2, 67 | }, 68 | }; 69 | 70 | expect(translateKeys(actual, keyFunc)).to.eql(expected); 71 | }); 72 | }); 73 | 74 | describe('translateValues', () => { 75 | const valueFunc = (key) => key.toUpperCase(); 76 | 77 | test.it('should translate the values of a complex object', () => { 78 | const actual = { 79 | nullValue: null, 80 | anArray: ['a', 'b', 'c'], 81 | nested: { level2: { level3: 'value' } }, 82 | custom: { 83 | one: 'wOn', 84 | two: 'too', 85 | three: 'thr33', 86 | toJSON() { 87 | return { one: this.one, two: this.two }; 88 | }, 89 | }, 90 | }; 91 | 92 | const expected = { 93 | nullValue: null, 94 | anArray: ['A', 'B', 'C'], 95 | nested: { level2: { level3: 'VALUE' } }, 96 | custom: { 97 | one: 'WON', 98 | two: 'TOO', 99 | }, 100 | }; 101 | 102 | expect(translateValues(actual, valueFunc)).to.eql(expected); 103 | }); 104 | }); 105 | 106 | describe('sleep', () => { 107 | test.it('should sleep about as long as it is told', async () => { 108 | const sleepTimeLower = 80; 109 | const sleepTimeTarget = 100; 110 | const sleepTimeUpper = 120; 111 | 112 | const startTime = Date.now(); 113 | await sleep(sleepTimeTarget); 114 | const endTime = Date.now(); 115 | 116 | expect(endTime - startTime).to.be.within(sleepTimeLower, sleepTimeUpper); 117 | }); 118 | }); 119 | 120 | describe('splitArray', () => { 121 | test.it('should split the array in 2', () => { 122 | const testArray = ['a', 'ey!', 'bee', 'b', 'c', 'SEA']; 123 | const isLengthOne = (item) => item.length === 1; 124 | 125 | const [matched, notMatched] = splitArray(testArray, isLengthOne); 126 | 127 | expect(matched).to.deep.equal(['a', 'b', 'c']); 128 | expect(notMatched).to.deep.equal(['ey!', 'bee', 'SEA']); 129 | }); 130 | }); 131 | 132 | describe('instanceOf', () => { 133 | class BaseError extends Error { 134 | // No-op 135 | } 136 | 137 | class ExtendedError extends BaseError { 138 | // No-op 139 | } 140 | 141 | test.it('should return true for instanceOf', () => { 142 | const baseError = new BaseError(); 143 | const extendedError = new ExtendedError(); 144 | 145 | expect(instanceOf(extendedError, ExtendedError)).to.equal(true); 146 | expect(instanceOf(extendedError, BaseError)).to.equal(true); 147 | expect(instanceOf(extendedError, Error)).to.equal(true); 148 | 149 | expect(instanceOf(baseError, BaseError)).to.equal(true); 150 | expect(instanceOf(baseError, Error)).to.equal(true); 151 | }); 152 | 153 | test.it('should return false for instanceOf', () => { 154 | class Foo extends Error {} 155 | 156 | const baseError = new BaseError(); 157 | const extendedError = new ExtendedError(); 158 | 159 | expect(instanceOf(baseError, Foo)).to.equal(false); 160 | expect(instanceOf(extendedError, Foo)).to.equal(false); 161 | }); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /test/services/messaging/logging.test.js: -------------------------------------------------------------------------------- 1 | const { expect, test } = require('@twilio/cli-test'); 2 | 3 | const { logger } = require('../../../src/services/messaging/logging'); 4 | 5 | describe('services', () => { 6 | describe('messaging', () => { 7 | describe('logging.logger', () => { 8 | test.stderr().it('should at least log errors by default', (ctx) => { 9 | logger.error('heir or?'); 10 | expect(ctx.stderr).to.not.be.empty; 11 | }); 12 | 13 | test.stderr().it('should not log debug by default', (ctx) => { 14 | logger.debug('eek!'); 15 | expect(ctx.stderr).to.be.empty; 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/services/messaging/templating.test.js: -------------------------------------------------------------------------------- 1 | const { expect, test } = require('@twilio/cli-test'); 2 | 3 | const { templatize } = require('../../../src/services/messaging/templating'); 4 | 5 | const EXPECTED_MESSAGE = 'Indiana Jones and the Template of Doom'; 6 | 7 | describe('services', () => { 8 | describe('messaging', () => { 9 | describe('templating.templatize', () => { 10 | test.it('should handle a string without params', () => { 11 | const template = templatize`Indiana Jones and the Template of Doom`; 12 | 13 | expect(template()).to.equal(EXPECTED_MESSAGE); 14 | }); 15 | 16 | test.it('should handle index-based params', () => { 17 | const template = templatize`${1} Jones and the ${0} of Doom`; 18 | 19 | expect(template('Template', 'Indiana')).to.equal(EXPECTED_MESSAGE); 20 | }); 21 | 22 | test.it('should handle key-based params', () => { 23 | const template = templatize`${'who'} and the ${'what'}`; 24 | 25 | expect(template({ who: 'Indiana Jones', what: 'Template of Doom' })).to.equal(EXPECTED_MESSAGE); 26 | }); 27 | 28 | test.it('should handle both index- and key-based params', () => { 29 | const template = templatize`${0} and the ${'what'}`; 30 | 31 | expect(template('Indiana Jones', { what: 'Template of Doom' })).to.equal(EXPECTED_MESSAGE); 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/services/naming-conventions.test.js: -------------------------------------------------------------------------------- 1 | const { expect, test } = require('@twilio/cli-test'); 2 | 3 | const { kebabCase, camelCase, pascalCase, snakeCase, capitalize } = require('../../src/services/naming-conventions'); 4 | 5 | describe('services', () => { 6 | describe('namingConventions', () => { 7 | test.it('handles single word', () => { 8 | expect(kebabCase('one')).to.equal('one'); 9 | expect(camelCase('one')).to.equal('one'); 10 | expect(pascalCase('one')).to.equal('One'); 11 | expect(snakeCase('one')).to.equal('one'); 12 | }); 13 | 14 | test.it('handles all caps word', () => { 15 | expect(kebabCase('ONE')).to.equal('one'); 16 | expect(camelCase('ONE')).to.equal('one'); 17 | expect(pascalCase('ONE')).to.equal('One'); 18 | expect(snakeCase('ONE')).to.equal('one'); 19 | }); 20 | 21 | test.it('trims leading and trailing spaces', () => { 22 | expect(kebabCase(' one ')).to.equal('one'); 23 | expect(camelCase(' one ')).to.equal('one'); 24 | expect(pascalCase(' one ')).to.equal('One'); 25 | expect(snakeCase(' one ')).to.equal('one'); 26 | }); 27 | 28 | test.it('trims leading and trailing symbols', () => { 29 | expect(kebabCase('___one___')).to.equal('one'); 30 | expect(camelCase('___one___')).to.equal('one'); 31 | expect(pascalCase('___one___')).to.equal('One'); 32 | expect(snakeCase('___one___')).to.equal('one'); 33 | }); 34 | 35 | test.it('handles dot-separated', () => { 36 | expect(kebabCase('OneOne.TwoTwo.ThreeThree')).to.equal('one-one.two-two.three-three'); 37 | expect(camelCase('OneOne.TwoTwo.ThreeThree')).to.equal('oneOne.twoTwo.threeThree'); 38 | expect(pascalCase('OneOne.TwoTwo.ThreeThree')).to.equal('OneOne.TwoTwo.ThreeThree'); 39 | expect(snakeCase('OneOne.TwoTwo.ThreeThree')).to.equal('one_one.two_two.three_three'); 40 | }); 41 | 42 | test.it('handles words with spaces', () => { 43 | expect(kebabCase('one two')).to.equal('one-two'); 44 | expect(kebabCase('one two three')).to.equal('one-two-three'); 45 | 46 | expect(camelCase('one two')).to.equal('oneTwo'); 47 | expect(camelCase('one two three')).to.equal('oneTwoThree'); 48 | 49 | expect(pascalCase('one two')).to.equal('OneTwo'); 50 | expect(pascalCase('one two three')).to.equal('OneTwoThree'); 51 | 52 | expect(snakeCase('one two')).to.equal('one_two'); 53 | expect(snakeCase('one two three')).to.equal('one_two_three'); 54 | }); 55 | 56 | test.it('handles multiple words with extra space', () => { 57 | expect(kebabCase('one two')).to.equal('one-two'); 58 | expect(kebabCase('one two three')).to.equal('one-two-three'); 59 | 60 | expect(camelCase('one two')).to.equal('oneTwo'); 61 | expect(camelCase('one two three')).to.equal('oneTwoThree'); 62 | 63 | expect(pascalCase('one two')).to.equal('OneTwo'); 64 | expect(pascalCase('one two three')).to.equal('OneTwoThree'); 65 | 66 | expect(snakeCase('one two')).to.equal('one_two'); 67 | expect(snakeCase('one two three')).to.equal('one_two_three'); 68 | }); 69 | 70 | test.it('handles snake_case', () => { 71 | expect(kebabCase('one_two')).to.equal('one-two'); 72 | expect(kebabCase('one_two_three')).to.equal('one-two-three'); 73 | 74 | expect(camelCase('one_two')).to.equal('oneTwo'); 75 | expect(camelCase('one_two_three')).to.equal('oneTwoThree'); 76 | 77 | expect(pascalCase('one_two')).to.equal('OneTwo'); 78 | expect(pascalCase('one_two_three')).to.equal('OneTwoThree'); 79 | 80 | expect(snakeCase('one_two')).to.equal('one_two'); 81 | expect(snakeCase('one_two_three')).to.equal('one_two_three'); 82 | }); 83 | 84 | test.it('handles camelCase', () => { 85 | expect(kebabCase('oneTwo')).to.equal('one-two'); 86 | expect(kebabCase('oneTwoThree')).to.equal('one-two-three'); 87 | 88 | expect(camelCase('oneTwo')).to.equal('oneTwo'); 89 | expect(camelCase('oneTwoThree')).to.equal('oneTwoThree'); 90 | 91 | expect(pascalCase('oneTwo')).to.equal('OneTwo'); 92 | expect(pascalCase('oneTwoThree')).to.equal('OneTwoThree'); 93 | 94 | expect(snakeCase('oneTwo')).to.equal('one_two'); 95 | expect(snakeCase('oneTwoThree')).to.equal('one_two_three'); 96 | }); 97 | 98 | test.it('handles PascalCase', () => { 99 | expect(kebabCase('OneTwo')).to.equal('one-two'); 100 | expect(kebabCase('OneTwoThree')).to.equal('one-two-three'); 101 | 102 | expect(camelCase('OneTwo')).to.equal('oneTwo'); 103 | expect(camelCase('OneTwoThree')).to.equal('oneTwoThree'); 104 | 105 | expect(pascalCase('OneTwo')).to.equal('OneTwo'); 106 | expect(pascalCase('OneTwoThree')).to.equal('OneTwoThree'); 107 | 108 | expect(snakeCase('OneTwo')).to.equal('one_two'); 109 | expect(snakeCase('OneTwoThree')).to.equal('one_two_three'); 110 | }); 111 | 112 | test.it('handles PascalCase with digits', () => { 113 | expect(kebabCase('One1Two')).to.equal('one1-two'); 114 | expect(kebabCase('One1Two2Three')).to.equal('one1-two2-three'); 115 | 116 | expect(camelCase('One1Two')).to.equal('one1Two'); 117 | expect(camelCase('One1Two2Three')).to.equal('one1Two2Three'); 118 | 119 | expect(pascalCase('One1Two')).to.equal('One1Two'); 120 | expect(pascalCase('One1Two2Three')).to.equal('One1Two2Three'); 121 | 122 | expect(snakeCase('One1Two')).to.equal('one1_two'); 123 | expect(snakeCase('One1Two2Three')).to.equal('one1_two2_three'); 124 | }); 125 | 126 | test.it('handles kebab-case', () => { 127 | expect(kebabCase('one-two')).to.equal('one-two'); 128 | expect(kebabCase('one-two-three')).to.equal('one-two-three'); 129 | 130 | expect(camelCase('one-two')).to.equal('oneTwo'); 131 | expect(camelCase('one-two-three')).to.equal('oneTwoThree'); 132 | 133 | expect(pascalCase('one-two')).to.equal('OneTwo'); 134 | expect(pascalCase('one-two-three')).to.equal('OneTwoThree'); 135 | 136 | expect(snakeCase('one-two')).to.equal('one_two'); 137 | expect(snakeCase('one-two-three')).to.equal('one_two_three'); 138 | }); 139 | 140 | describe('capitalize', () => { 141 | test.it('handles single word', () => { 142 | expect(capitalize('one')).to.equal('One'); 143 | }); 144 | 145 | test.it('handles multiple words', () => { 146 | expect(capitalize('one two three')).to.equal('One two three'); 147 | }); 148 | 149 | test.it('trims leading and trailing spaces', () => { 150 | expect(capitalize(' one ')).to.equal('One'); 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/services/require-install.test.js: -------------------------------------------------------------------------------- 1 | const tmp = require('tmp'); 2 | const { expect, test } = require('@twilio/cli-test'); 3 | 4 | const { TwilioCliError } = require('../../src/services/error'); 5 | const { 6 | getCommandPlugin, 7 | getPackageVersion, 8 | getDependencyVersion, 9 | checkVersion, 10 | requireInstall, 11 | } = require('../../src/services/require-install'); 12 | const { logger, LoggingLevel } = require('../../src/services/messaging/logging'); 13 | const corePJSON = require('../../package.json'); 14 | 15 | const TOP_PLUGIN = { 16 | name: 'top-plugin', 17 | options: {}, 18 | commands: [ 19 | { 20 | id: 'top-command', 21 | aliases: [], 22 | }, 23 | ], 24 | }; 25 | 26 | const DYNAMIC_PLUGIN = { 27 | name: 'dynamic-plugin', 28 | options: { name: 'top-plugin' }, 29 | commands: [ 30 | { 31 | id: 'dynamic-command', 32 | aliases: [], 33 | }, 34 | ], 35 | }; 36 | 37 | const INSTALLED_PLUGIN = { 38 | name: 'installed-plugin', 39 | options: { name: 'installed-plugin' }, 40 | commands: [ 41 | { 42 | id: 'installed-command', 43 | aliases: ['alias-installed-command'], 44 | }, 45 | ], 46 | }; 47 | 48 | const config = { 49 | plugins: [TOP_PLUGIN, DYNAMIC_PLUGIN, INSTALLED_PLUGIN], 50 | dataDir: tmp.dirSync({ unsafeCleanup: true }).name, 51 | }; 52 | 53 | /* eslint-disable max-nested-callbacks */ 54 | describe('services', () => { 55 | describe('require-install', () => { 56 | describe('getCommandPlugin', () => { 57 | test.it('can find a plugin', () => { 58 | const command = { id: 'top-command', config }; 59 | expect(getCommandPlugin(command)).to.equal(TOP_PLUGIN); 60 | }); 61 | 62 | test.it('can find commands under dynamic plugin', () => { 63 | const command = { id: 'dynamic-command', config }; 64 | expect(getCommandPlugin(command)).to.equal(TOP_PLUGIN); 65 | }); 66 | 67 | test.it('can find commands under installed plugin', () => { 68 | const command = { id: 'installed-command', config }; 69 | expect(getCommandPlugin(command)).to.equal(INSTALLED_PLUGIN); 70 | }); 71 | 72 | test.it('can find plugins for command aliases', () => { 73 | const command = { id: 'alias-installed-command', config }; 74 | expect(getCommandPlugin(command)).to.equal(INSTALLED_PLUGIN); 75 | }); 76 | 77 | test.it('handles unknown commands', () => { 78 | const command = { id: 'what-command', config }; 79 | expect(() => getCommandPlugin(command)).to.throw(TwilioCliError); 80 | }); 81 | }); 82 | 83 | describe('package-versions', () => { 84 | test.it('can retrieve and check package versions', () => { 85 | const packageVersion = getPackageVersion('chai'); 86 | const dependencyVersion = corePJSON.devDependencies.chai; 87 | 88 | expect(packageVersion).to.not.be.undefined; 89 | expect(dependencyVersion).to.not.be.undefined; 90 | expect(checkVersion(packageVersion, dependencyVersion)).to.not.throw; 91 | }); 92 | 93 | test.it('handles unknown packages', () => { 94 | expect(getPackageVersion('chai-dai')).to.be.undefined; 95 | }); 96 | 97 | test.it('throws for invalid versions', () => { 98 | expect(() => checkVersion('1.0.0', '^2.0.0')).to.throw(Error); 99 | }); 100 | 101 | test.it('can retrieve dependency versions', () => { 102 | const pjson = { 103 | dependencies: { keytar: '1.2.3' }, 104 | optionalDependencies: { tartar: '4.5.6' }, 105 | }; 106 | 107 | expect(getDependencyVersion('keytar', pjson)).to.equal('1.2.3'); 108 | expect(getDependencyVersion('tartar', pjson)).to.equal('4.5.6'); 109 | }); 110 | }); 111 | 112 | describe('requireInstall', () => { 113 | before(() => { 114 | logger.config.level = LoggingLevel.debug; 115 | }); 116 | 117 | after(() => { 118 | logger.config.level = LoggingLevel.info; 119 | }); 120 | 121 | test.it('can load existing packages', () => { 122 | expect(requireInstall('chai')).to.not.be.undefined; 123 | }); 124 | 125 | test.stderr().it('will attempt to install packages', async (ctx) => { 126 | const command = { id: 'top-command', config }; 127 | await expect(requireInstall('chai-dai', command)).to.be.rejected; 128 | expect(ctx.stderr).to.contain('Installing chai-dai'); 129 | expect(ctx.stderr).to.contain('Error loading/installing chai-dai'); 130 | }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/services/twilio-api/api-browser.test.js: -------------------------------------------------------------------------------- 1 | const { expect, test } = require('@twilio/cli-test'); 2 | 3 | const { TwilioApiBrowser } = require('../../../src/services/twilio-api'); 4 | 5 | describe('services', () => { 6 | describe('twilio-api', () => { 7 | describe('TwilioApiBrowser', () => { 8 | test.it('loads the JSON from disk', () => { 9 | const browser = new TwilioApiBrowser(); 10 | // Check some known api endpoints that should be relatively stable 11 | expect(browser.domains.api.paths['/2010-04-01/Accounts/{AccountSid}/Calls.json'].operations.post).to.exist; 12 | expect(browser.domains.api.paths['/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json'].operations.get).to.exist; 13 | }); 14 | /* 15 | * TODO: enable it after one build is done, and the path is loaded. 16 | * test.it('loads the JSON from disk except for preview', () => { 17 | * const browser = new TwilioApiBrowser(); 18 | * expect(browser.domains.api.paths['/BulkExports/Exports/Jobs/{JobSid}']).to.not.exist; 19 | * }); 20 | */ 21 | 22 | test.it('loads JSONs split by version', () => { 23 | const browser = new TwilioApiBrowser(); 24 | const spec = browser.loadApiSpecFromDisk(); 25 | expect(spec).to.not.have.property('studio'); 26 | expect(spec).to.have.property('studio_v1'); 27 | expect(spec).to.have.property('studio_v2'); 28 | expect(spec).to.have.property('api_v2010'); 29 | expect(browser.domains.studio.paths['/v1/Flows'].operations.get).to.exist; 30 | expect(browser.domains.studio.paths['/v2/Flows'].operations.get).to.exist; 31 | }); 32 | 33 | test.it('merges the domains into one', () => { 34 | const browser = new TwilioApiBrowser(); 35 | let spec = browser.loadApiSpecFromDisk(); 36 | expect(spec).to.have.property('studio_v1'); 37 | spec = browser.mergeVersions(spec); 38 | expect(spec).to.not.have.property('studio_v1'); 39 | expect(spec).to.have.property('studio'); 40 | }); 41 | 42 | test.it('loads a specific api spec', () => { 43 | const browser = new TwilioApiBrowser({ 44 | api: { 45 | paths: { 46 | '/2010-04-01/Widgets.json': { 47 | servers: [ 48 | { 49 | url: 'https://api.twilio.com', 50 | }, 51 | ], 52 | description: 'Widgets here\nsecond line of text', 53 | 'x-twilio': { defaultOutputProperties: ['sid'] }, 54 | }, 55 | }, 56 | tags: [ 57 | { 58 | name: 'Beta', 59 | description: 'Betamax!', 60 | }, 61 | ], 62 | }, 63 | neato: { 64 | paths: { 65 | '/v1/Gadgets.json': { 66 | servers: [ 67 | { 68 | url: 'https://neato.twilio.com', 69 | }, 70 | ], 71 | description: 'v1 Gadgets here', 72 | 'x-twilio': { defaultOutputProperties: ['sid'] }, 73 | }, 74 | '/v2/Gadgets.json': { 75 | servers: [ 76 | { 77 | url: 'https://neato.twilio.com', 78 | }, 79 | ], 80 | post: { createStuff: '' }, 81 | get: { listStuff: '' }, 82 | description: 'v2 list Gadgets here', 83 | 'x-twilio': { defaultOutputProperties: ['sid', 'name'] }, 84 | }, 85 | '/v2/Gadgets/{Sid}.json': { 86 | servers: [ 87 | { 88 | url: 'https://neato.twilio.com', 89 | }, 90 | ], 91 | post: { updateStuff: '' }, 92 | get: { fetchStuff: '' }, 93 | delete: { removeStuff: '' }, 94 | description: 'v2 instance Gadgets here', 95 | 'x-twilio': { defaultOutputProperties: ['sid', 'description'] }, 96 | }, 97 | }, 98 | tags: [ 99 | { 100 | name: 'GA', 101 | description: 'Generally Available!', 102 | }, 103 | ], 104 | }, 105 | }); 106 | 107 | expect(browser.domains).to.deep.equal({ 108 | api: { 109 | tags: [ 110 | { 111 | name: 'Beta', 112 | description: 'Betamax!', 113 | }, 114 | ], 115 | paths: { 116 | '/2010-04-01/Widgets.json': { 117 | operations: {}, 118 | server: 'https://api.twilio.com', 119 | description: 'Widgets here second line of text', 120 | defaultOutputProperties: ['sid'], 121 | }, 122 | }, 123 | }, 124 | neato: { 125 | tags: [ 126 | { 127 | name: 'GA', 128 | description: 'Generally Available!', 129 | }, 130 | ], 131 | paths: { 132 | '/v1/Gadgets.json': { 133 | operations: {}, 134 | server: 'https://neato.twilio.com', 135 | description: 'v1 Gadgets here', 136 | defaultOutputProperties: ['sid'], 137 | }, 138 | '/v2/Gadgets.json': { 139 | operations: { 140 | post: { createStuff: '' }, 141 | get: { listStuff: '' }, 142 | }, 143 | server: 'https://neato.twilio.com', 144 | description: 'v2 list Gadgets here', 145 | defaultOutputProperties: ['sid', 'name'], 146 | }, 147 | '/v2/Gadgets/{Sid}.json': { 148 | operations: { 149 | get: { fetchStuff: '' }, 150 | post: { updateStuff: '' }, 151 | delete: { removeStuff: '' }, 152 | }, 153 | server: 'https://neato.twilio.com', 154 | description: 'v2 instance Gadgets here', 155 | defaultOutputProperties: ['sid', 'description'], 156 | }, 157 | }, 158 | }, 159 | }); 160 | }); 161 | 162 | test.it('lift twilio vendor extension property', () => { 163 | const browser = new TwilioApiBrowser({ 164 | api: { 165 | paths: { 166 | '/v2/Services/{ServiceSid}/Entities/{Identity}/Factors.json': { 167 | servers: [ 168 | { 169 | url: 'https://api.twilio.com', 170 | }, 171 | ], 172 | get: { 173 | listStuff: '', 174 | }, 175 | post: { 176 | createStuff: '', 177 | 'x-twilio': { defaultOutputProperties: ['sid', 'status', 'binding'] }, 178 | }, 179 | description: '', 180 | 'x-twilio': { defaultOutputProperties: ['sid', 'status'] }, 181 | }, 182 | }, 183 | }, 184 | }); 185 | 186 | expect(browser.domains).to.deep.equal({ 187 | api: { 188 | paths: { 189 | '/v2/Services/{ServiceSid}/Entities/{Identity}/Factors.json': { 190 | operations: { 191 | post: { createStuff: '', defaultOutputProperties: ['sid', 'status', 'binding'] }, 192 | get: { listStuff: '' }, 193 | }, 194 | server: 'https://api.twilio.com', 195 | description: '', 196 | defaultOutputProperties: ['sid', 'status'], 197 | }, 198 | }, 199 | }, 200 | }); 201 | }); 202 | }); 203 | }); 204 | }); 205 | --------------------------------------------------------------------------------