@heroku-cli/command
Version:
base class for Heroku CLI commands
328 lines (327 loc) • 14.5 kB
JavaScript
import { HTTP, HTTPError } from '@heroku/http-call';
import { Errors } from '@oclif/core';
import debug from 'debug';
import inquirer from 'inquirer';
import { Netrc } from 'netrc-parser';
import * as url from 'node:url';
import { Login } from './login.js';
import { Mutex } from './mutex.js';
import { ParticleboardClient } from './particleboard-client.js';
import { RequestId, requestIdHeader } from './request-id.js';
import { vars } from './vars.js';
import { yubikey } from './yubikey.js';
const netrc = new Netrc();
export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com']);
export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1']);
export class HerokuAPIError extends Errors.CLIError {
body;
http;
constructor(httpError) {
if (!httpError)
throw new Error('invalid error');
const options = httpError.body;
if (!options || !options.message)
throw httpError;
const info = [];
if (options.id)
info.push(`Error ID: ${options.id}`);
if (options.app && options.app.name)
info.push(`App: ${options.app.name}`);
if (options.url)
info.push(`See ${options.url} for more information.`);
if (info.length > 0)
super([options.message, '', ...info].join('\n'));
else
super(options.message);
this.http = httpError;
this.body = options;
}
}
export class APIClient {
config;
options;
authPromise;
http;
preauthPromises;
_auth;
_login;
_particleboard;
_twoFactorMutex;
constructor(config, options = {}) {
this.config = config;
this.options = options;
this.config = config;
this._login = new Login(this.config, this);
if (options.required === undefined)
options.required = true;
options.preauth = options.preauth !== false;
if (options.debug)
debug.enable('http');
if (options.debug && options.debugHeaders)
debug.enable('http,http:headers');
this.options = options;
const apiUrl = new url.URL(vars.apiUrl);
const envHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}');
this.preauthPromises = {};
const self = this;
const opts = {
headers: {
accept: 'application/vnd.heroku+json; version=3',
'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}`,
...envHeaders,
},
host: apiUrl.hostname,
port: apiUrl.port,
protocol: apiUrl.protocol,
};
const delinquencyConfig = { fetch_delinquency: false, warning_shown: false };
this.http = class APIHTTPClient extends HTTP.create(opts) {
static configDelinquency(url, opts) {
if (opts.method?.toUpperCase() !== 'GET' || (opts.hostname && opts.hostname !== apiUrl.hostname)) {
delinquencyConfig.fetch_delinquency = false;
return;
}
if (/^\/account$/i.test(url)) {
delinquencyConfig.fetch_url = '/account';
delinquencyConfig.fetch_delinquency = true;
delinquencyConfig.resource_type = 'account';
return;
}
const match = url.match(/^\/teams\/([^#/?]+)/i);
if (match) {
delinquencyConfig.fetch_url = `/teams/${match[1]}`;
delinquencyConfig.fetch_delinquency = true;
delinquencyConfig.resource_type = 'team';
return;
}
delinquencyConfig.fetch_delinquency = false;
}
static notifyDelinquency(delinquencyInfo) {
const suspension = delinquencyInfo.scheduled_suspension_time ? Date.parse(delinquencyInfo.scheduled_suspension_time).valueOf() : undefined;
const deletion = delinquencyInfo.scheduled_deletion_time ? Date.parse(delinquencyInfo.scheduled_deletion_time).valueOf() : undefined;
if (!suspension && !deletion)
return;
const resource = delinquencyConfig.resource_type;
if (suspension) {
const now = Date.now();
if (suspension > now) {
Errors.warn(`This ${resource} is delinquent with payment and we'll suspend it on ${new Date(suspension)}.`);
delinquencyConfig.warning_shown = true;
return;
}
if (deletion)
Errors.warn(`This ${resource} is delinquent with payment and we suspended it on ${new Date(suspension)}. If the ${resource} is still delinquent, we'll delete it on ${new Date(deletion)}.`);
}
else if (deletion)
Errors.warn(`This ${resource} is delinquent with payment and we'll delete it on ${new Date(deletion)}.`);
delinquencyConfig.warning_shown = true;
}
// eslint-disable-next-line complexity
static async request(url, opts = {}, retries = 3) {
opts.headers = opts.headers || {};
const currentRequestId = RequestId.create() && RequestId.headerValue;
// Accumulation of requestIds in the header
// causes a header overflow error. Headers have been
// observed to be larger than 8k (Node default max)
// in long running poll operations such as pg:wait
// We limit the Request-Id header to 7k to allow some
// room fo other headers.
if (Buffer.from(currentRequestId).byteLength > 1024 * 7) {
RequestId.empty();
opts.headers[requestIdHeader] = RequestId.create();
}
else {
opts.headers[requestIdHeader] = currentRequestId;
}
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
// Handle both relative and absolute URLs for validation
let targetUrl;
try {
// Try absolute URL first
targetUrl = new URL(url);
}
catch {
// If that fails, assume it's relative and prepend the API base URL
targetUrl = new URL(url, vars.apiUrl);
}
const isHerokuApi = ALLOWED_HEROKU_DOMAINS.some(domain => targetUrl.hostname.endsWith(`.${domain}`));
const isLocalhost = LOCALHOST_DOMAINS.includes(targetUrl.hostname);
if (isHerokuApi || isLocalhost) {
opts.headers.authorization = `Bearer ${self.auth}`;
}
}
this.configDelinquency(url, opts);
retries--;
try {
let response;
let particleboardResponse;
const particleboardClient = self.particleboard;
if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
self._particleboard.auth = self.auth;
const settledResponses = await Promise.allSettled([
super.request(url, opts),
particleboardClient.get(delinquencyConfig.fetch_url),
]);
// Platform API request
if (settledResponses[0].status === 'fulfilled')
response = settledResponses[0].value;
else
throw settledResponses[0].reason;
// Particleboard request (ignore errors)
if (settledResponses[1].status === 'fulfilled') {
particleboardResponse = settledResponses[1].value;
}
}
else {
response = await super.request(url, opts);
}
const delinquencyInfo = particleboardResponse?.body || {};
this.notifyDelinquency(delinquencyInfo);
this.trackRequestIds(response);
this.showWarnings(response);
return response;
}
catch (error) {
if (!(error instanceof HTTPError))
throw error;
if (retries > 0) {
if (opts.retryAuth !== false && error.http.statusCode === 401 && error.body.id === 'unauthorized') {
if (process.env.HEROKU_API_KEY) {
throw new Error('The token provided to HEROKU_API_KEY is invalid. Please double-check that you have the correct token, or run `heroku login` without HEROKU_API_KEY set.');
}
if (!self.authPromise)
self.authPromise = self.login();
await self.authPromise;
opts.headers.authorization = `Bearer ${self.auth}`;
return this.request(url, opts, retries);
}
if (error.http.statusCode === 403 && error.body.id === 'two_factor') {
return this.twoFactorRetry(error, url, opts, retries);
}
}
throw new HerokuAPIError(error);
}
}
static showWarnings(response) {
const warnings = response.headers['x-heroku-warning'] || response.headers['warning-message'];
if (Array.isArray(warnings))
warnings.forEach(warning => Errors.warn(`${warning}\n`));
else if (typeof warnings === 'string')
Errors.warn(`${warnings}\n`);
}
static trackRequestIds(response) {
const responseRequestIdHeader = response.headers[requestIdHeader] || response.headers[requestIdHeader.toLocaleLowerCase()];
if (responseRequestIdHeader) {
const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',');
RequestId.track(...requestIds);
}
}
static async twoFactorRetry(err, url, opts = {}, retries = 3) {
const app = err.body.app ? err.body.app.name : null;
if (!app || !options.preauth) {
opts.headers = opts.headers || {};
opts.headers['Heroku-Two-Factor-Code'] = await self.twoFactorPrompt();
return this.request(url, opts, retries);
}
// if multiple requests are run in parallel for the same app, we should
// only preauth for the first so save the fact we already preauthed
if (!self.preauthPromises[app]) {
self.preauthPromises[app] = self.twoFactorPrompt().then((factor) => self.preauth(app, factor));
}
await self.preauthPromises[app];
return this.request(url, opts, retries);
}
};
}
get auth() {
if (!this._auth) {
if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY)
Errors.warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY');
this._auth = process.env.HEROKU_API_KEY;
if (!this._auth) {
netrc.loadSync();
this._auth = netrc.machines[vars.apiHost] && netrc.machines[vars.apiHost].password;
}
}
return this._auth;
}
set auth(token) {
delete this.authPromise;
this._auth = token;
}
get defaults() {
return this.http.defaults;
}
get particleboard() {
if (this._particleboard)
return this._particleboard;
this._particleboard = new ParticleboardClient(this.config);
return this._particleboard;
}
get twoFactorMutex() {
if (!this._twoFactorMutex) {
this._twoFactorMutex = new Mutex();
}
return this._twoFactorMutex;
}
delete(url, options = {}) {
return this.http.delete(url, options);
}
get(url, options = {}) {
return this.http.get(url, options);
}
login(opts = {}) {
return this._login.login(opts);
}
async logout() {
try {
await this._login.logout();
}
catch (error) {
if (error instanceof Errors.CLIError)
Errors.warn(error);
}
delete netrc.machines['api.heroku.com'];
delete netrc.machines['git.heroku.com'];
await netrc.save();
}
patch(url, options = {}) {
return this.http.patch(url, options);
}
post(url, options = {}) {
return this.http.post(url, options);
}
preauth(app, factor) {
return this.put(`/apps/${app}/pre-authorizations`, {
headers: { 'Heroku-Two-Factor-Code': factor },
});
}
put(url, options = {}) {
return this.http.put(url, options);
}
request(url, options = {}) {
return this.http.request(url, options);
}
stream(url, options = {}) {
return this.http.stream(url, options);
}
twoFactorPrompt() {
yubikey.enable();
return this.twoFactorMutex.synchronize(async () => {
try {
const { factor } = await inquirer.prompt([{
mask: '*',
message: 'Two-factor code',
name: 'factor',
type: 'password',
}]);
yubikey.disable();
return factor;
}
catch (error) {
yubikey.disable();
throw error;
}
});
}
}