Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/scripts/update-release.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
const core = require('@actions/core');
const { Octokit } = require("@octokit/core");

/**
* Functionality from tubone24/update_release.
* Link: https://github.com/tubone24/update_release
*/
const updateRelease = async () => {
try {
const { Octokit } = await import('@octokit/core');
const octokit = new Octokit({
auth: process.env.REPO_ACCESS_TOKEN
})
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
This module contains core functionality for the twilio-cli.

## Requirements
Currently, Node 20+ is supported. We support the [LTS versions](https://nodejs.org/en/about/releases) of Node.
Currently, Node 22+ is supported. We support the [LTS versions](https://nodejs.org/en/about/releases) of Node.

## Base commands

Expand Down
510 changes: 301 additions & 209 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@oclif/plugin-help": "^5.1.3",
"@oclif/plugin-plugins": "2.1.0",
"@octokit/rest": "^21.1.1",
"axios": "^1.12.0",
"axios": "^1.19.0",
"chalk": "^4.1.2",
"columnify": "^1.5.4",
"fs-extra": "^9.0.1",
Expand All @@ -42,7 +42,7 @@
"qs": "^6.9.4",
"semver": "^7.5.2",
"tsv": "^0.2.0",
"twilio": "^5.3.0",
"twilio": "^6.0.0",
"proxyquire": "^2.1.3"
},
"devDependencies": {
Expand All @@ -64,6 +64,6 @@
"tmp": "^0.2.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
}
12 changes: 10 additions & 2 deletions src/base-commands/base-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,13 @@ class BaseCommand extends Command {
return;
}

const limitedData = properties ? this.getLimitedData(dataArray, properties) : null;
// Wrap primitive arrays (e.g. ["id1", "id2"]) into objects for columnar display.
const hasPrimitives = dataArray.length > 0 && typeof dataArray[0] !== 'object';
const displayArray = hasPrimitives ? dataArray.map((item) => ({ value: item })) : dataArray;

process.stdout.write(`${this.outputProcessor(dataArray, limitedData || dataArray, options)}\n`);
const limitedData = properties ? this.getLimitedData(displayArray, properties) : null;

process.stdout.write(`${this.outputProcessor(dataArray, limitedData || displayArray, options)}\n`);
}

getLimitedData(dataArray, properties) {
Expand Down Expand Up @@ -172,6 +176,10 @@ class BaseCommand extends Command {
});

if (invalidPropertyNames.size > 0) {
if (invalidPropertyNames.size === propNames.length) {
// All requested properties are invalid — display all columns instead.
return null;
}
const warn = this.logger.warn.bind(this.logger);
invalidPropertyNames.forEach((p) => {
warn(`"${p}" is not a valid property name.`);
Expand Down
75 changes: 52 additions & 23 deletions src/services/cli-http-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,7 @@ class CliRequestClient {
* @param {boolean} [opts.allowRedirects] - Should the client follow redirects
* @param {boolean} [opts.forever] - Set to true to use the forever-agent
*/
async request(opts) {
opts = opts || {};
if (!opts.method) {
throw new Error('http method is required');
}

if (!opts.uri) {
throw new Error('uri is required');
}

buildHeaders(opts) {
const headers = opts.headers || {};

if (!headers.Connection && !headers.connection) {
Expand All @@ -64,15 +55,15 @@ class CliRequestClient {
const b64Auth = Buffer.from(`${opts.username}:${opts.password}`).toString('base64');
headers.Authorization = `Basic ${b64Auth}`;
}
// User-Agent will have these info : <plugin/version> <core-api-lib>/<core-api-lib-version> (<os-name> <os-arch>) <extensions>
const componentInfo = [];
componentInfo.push(`(${os.platform()} ${os.arch()})`); // (<os-name> <os-arch>)
const userAgentArr = (headers['User-Agent'] || ' ').split(' '); // contains twilio-node/version (darwin x64) node/v16.4.2
componentInfo.push(userAgentArr[0]); // Api client version
componentInfo.push(userAgentArr[3]); // nodejs version
componentInfo.push(this.commandName); // cli-command

const userAgentArr = (headers['User-Agent'] || ' ').split(' ');
const componentInfo = [`(${os.platform()} ${os.arch()})`, userAgentArr[0], userAgentArr[3], this.commandName];
headers['User-Agent'] = `${this.pluginName} ${pkg.name}/${pkg.version} ${componentInfo.filter(Boolean).join(' ')}`;

return headers;
}

buildOptions(opts, headers) {
const options = {
timeout: opts.timeout || 30000,
maxRedirects: opts.allowRedirects ? 10 : 0,
Expand All @@ -87,7 +78,10 @@ class CliRequestClient {
};

if (opts.data) {
options.data = qs.stringify(opts.data, { arrayFormat: 'repeat' });
const contentType = headers['Content-Type'] || headers['content-type'] || '';
options.data = contentType.includes('application/json')
? JSON.stringify(opts.data)
: qs.stringify(opts.data, { arrayFormat: 'repeat' });
}

if (opts.params) {
Expand All @@ -97,6 +91,22 @@ class CliRequestClient {
};
}

return options;
}

async request(opts) {
opts = opts || {};
if (!opts.method) {
throw new Error('http method is required');
}

if (!opts.uri) {
throw new Error('uri is required');
}

const headers = this.buildHeaders(opts);
const options = this.buildOptions(opts, headers);

this.lastRequest = options;
this.logRequest(options);

Expand Down Expand Up @@ -153,11 +163,30 @@ class CliRequestClient {

/* eslint-disable camelcase */
// In the rare event parameters are missing, display a readable message
formatErrorMessage({ code, message, more_info, details }) {
const moreInfoMessage = more_info ? `See ${more_info} for more info.` : '';
let errorMessage = `Error code ${code || 'N/A'} from Twilio: ${
message || 'No message provided'
}. ${moreInfoMessage}`;
formatErrorMessage({ code, message, more_info, details, httpStatusCode, userError, params }) {
let errorMessage;

if (httpStatusCode === undefined) {
const moreInfoMessage = more_info ? `See ${more_info} for more info.` : '';
errorMessage = `Error code ${code || 'N/A'} from Twilio: ${message || 'No message provided'}. ${moreInfoMessage}`;
} else {
errorMessage = `Error code ${code || 'N/A'} from Twilio: ${message || 'No message provided'}.`;
errorMessage += ` HTTP ${httpStatusCode}.`;

if (userError !== undefined) {
errorMessage += userError ? ' This is a user error.' : ' This is a system error.';
}

if (params) {
const paramEntries = Object.entries(params);
if (paramEntries.length > 0) {
errorMessage += '\n\nAdditional details:';
paramEntries.forEach(([key, value]) => {
errorMessage += `\n ${key}: ${value}`;
});
}
}
}

// Add hint for regional authentication failures
if ((code === 20003 || code === '20003') && message && message.toLowerCase().includes('authenticate')) {
Expand Down
56 changes: 51 additions & 5 deletions src/services/open-api-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,25 @@ class OpenApiClient {
throw new Error(`Path not found: ${opts.domain}.${opts.path}`);
}

const operation = path.operations[opts.method];
/*
* api-browser.js stores PUT as 'update' and PATCH as 'patch' in path.operations.
* GET, POST, DELETE keep their HTTP method name as the key.
*/
const METHOD_TO_OPERATION_KEY = { put: 'update', patch: 'patch' };
const operationKey = METHOD_TO_OPERATION_KEY[opts.method] || opts.method;
const operation = path.operations[operationKey];

if (!operation) {
throw new Error(`Operation not found: ${opts.domain}.${opts.path}.${opts.method}`);
}

const isPost = opts.method.toLowerCase() === 'post';
const requestBodyContent = (operation.requestBody || {}).content || {};
if ('application/json' in requestBodyContent) {
opts.headers = opts.headers || {};
opts.headers['Content-Type'] = 'application/json';
}

const isBodyMethod = ['post', 'put', 'patch'].includes(opts.method.toLowerCase());
const params = this.getParams(opts, operation);

if (!opts.uri) {
Expand All @@ -51,8 +63,8 @@ class OpenApiClient {
uri.hostname = this.getHost(uri.hostname, opts);
opts.uri = uri.href;

opts.params = isPost ? null : params;
opts.data = isPost ? params : null;
opts.params = isBodyMethod ? null : params;
opts.data = isBodyMethod ? params : null;

const response = await this.httpClient.request(opts);

Expand All @@ -69,12 +81,45 @@ class OpenApiClient {
if (parameter.in === 'query' && doesObjectHaveProperty(opts.data, parameter.name)) {
let value = opts.data[parameter.name];
if (parameter.schema.type === 'boolean') {
value = value.toString();
value = value === 'true' || value === true;
} else if (parameter.schema.type === 'object' && typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
logger.debug(`Could not parse value for "${parameter.name}" as JSON: ${e.message}`);
}
} else if (parameter.schema.type === 'array') {
// oclif multiple:true gives an array of strings; parse each element that looks like JSON
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
logger.debug(`Could not parse value for "${parameter.name}" as JSON: ${e.message}`);
}
} else if (Array.isArray(value)) {
value = value.map((item) => {
if (typeof item !== 'string') return item;
try {
return JSON.parse(item);
} catch (e) {
return item;
}
});
// Unwrap when a single --flag '[...]' produces [[...]] (parsed JSON array inside oclif array)
if (value.length === 1 && Array.isArray(value[0])) {
value = value[0];
}
}
}
params[parameter.name] = value;
}
});

// Pass through pagination token if present.
if (doesObjectHaveProperty(opts.data, 'pageToken')) {
params.pageToken = opts.data.pageToken;
}

return params;
}

Expand Down Expand Up @@ -117,6 +162,7 @@ class OpenApiClient {

parseResponse(domain, operation, response, requestOpts) {
if (response.body) {
response.rawBody = response.body;
const responseSchema = this.getResponseSchema(domain, operation, response.statusCode, requestOpts.headers.Accept);

// If we were able to find the schema for the response body, convert it.
Expand Down
Loading