diff --git a/package.json b/package.json index 921d559..ef318cb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "heroku-client": "^3.1.0", "http-call": "^5.3.0", "netrc-parser": "^3.1.6", - "open": "^6.2.0", + "open": "^8.4.2", "uuid": "^8.3.0" }, "devDependencies": { @@ -43,6 +43,7 @@ "np": "^7.7.0", "proxyquire": "^2.1.3", "sinon": "^14.0.2", + "stdout-stderr": "^0.1.13", "ts-node": "^10.9.1", "tslint": "^6.1.3", "typescript": "^4.8.4" diff --git a/src/api-client.ts b/src/api-client.ts index 3e042bf..f66ab1f 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -9,6 +9,7 @@ import {Login} from './login' import {Mutex} from './mutex' import {RequestId, requestIdHeader} from './request-id' import {vars} from './vars' +import {ParticleboardClient, IDelinquencyInfo} from './particleboard-client' export namespace APIClient { export interface Options extends HTTPRequestOptions { @@ -29,6 +30,13 @@ export interface IHerokuAPIErrorOptions { url?: string } +interface IDelinquencyConfig { + fetch_delinquency: boolean + warning_shown: boolean + resource_type?: 'account' | 'team' + fetch_url?: string +} + export class HerokuAPIError extends CLIError { http: HTTPError body: IHerokuAPIErrorOptions @@ -55,9 +63,11 @@ export class APIClient { private readonly _login = new Login(this.config, this) private _twoFactorMutex: Mutex | undefined private _auth?: string + private _particleboard!: ParticleboardClient constructor(protected config: Interfaces.Config, public options: IOptions = {}) { this.config = config + if (options.required === undefined) options.required = true options.preauth = options.preauth !== false this.options = options @@ -75,6 +85,7 @@ export class APIClient { ...envHeaders, }, } + const delinquencyConfig: IDelinquencyConfig = {fetch_delinquency: false, warning_shown: false} this.http = class APIHTTPClient extends deps.HTTP.HTTP.create(opts) { static async twoFactorRetry( err: HTTPError, @@ -107,17 +118,79 @@ export class APIClient { } } + static configDelinquency(url: string, opts: APIClient.Options): void { + 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 + } + + // eslint-disable-next-line complexity static async request(url: string, opts: APIClient.Options = {}, retries = 3): Promise> { opts.headers = opts.headers || {} opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue - if (!Object.keys(opts.headers).find(h => h.toLowerCase() === 'authorization')) { + if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) { opts.headers.authorization = `Bearer ${self.auth}` } + this.configDelinquency(url, opts) + retries-- try { - const response = await super.request(url, opts) + let response: HTTP + let particleboardResponse: HTTP | undefined + const particleboardClient: 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 as string), + ]) + + // 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: IDelinquencyInfo = particleboardResponse?.body || {} + if (delinquencyInfo.scheduled_suspension_time) { + warn(`This ${delinquencyConfig.resource_type} is delinquent with payment and we‘ll suspend it on ${new Date(delinquencyInfo.scheduled_suspension_time)}.`) + delinquencyConfig.warning_shown = true + } + + if (delinquencyInfo.scheduled_deletion_time) { + warn(`This ${delinquencyConfig.resource_type} is delinquent with payment and we‘ll delete it on ${new Date(delinquencyInfo.scheduled_deletion_time)}.`) + delinquencyConfig.warning_shown = true + } + this.trackRequestIds(response) return response } catch (error) { @@ -145,6 +218,12 @@ export class APIClient { } } + get particleboard(): ParticleboardClient { + if (this._particleboard) return this._particleboard + this._particleboard = new deps.ParticleboardClient(this.config) + return this._particleboard + } + get twoFactorMutex(): Mutex { if (!this._twoFactorMutex) { this._twoFactorMutex = new deps.Mutex() diff --git a/src/deps.ts b/src/deps.ts index ae29884..0ad6f6e 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -4,6 +4,7 @@ import HTTP = require('http-call') import netrc = require('netrc-parser') import apiClient = require('./api-client') +import particleboardClient = require('./particleboard-client') import file = require('./file') import flags = require('./flags') import git = require('./git') @@ -34,6 +35,9 @@ export const deps = { get APIClient(): typeof apiClient.APIClient { return fetch('./api-client').APIClient }, + get ParticleboardClient(): typeof particleboardClient.ParticleboardClient { + return fetch('./particleboard-client').ParticleboardClient + }, get file(): typeof file { return fetch('./file') }, diff --git a/src/login.ts b/src/login.ts index 43b3005..94aa6e2 100644 --- a/src/login.ts +++ b/src/login.ts @@ -3,7 +3,7 @@ import * as Heroku from '@heroku-cli/schema' import {Interfaces, ux} from '@oclif/core' import HTTP from 'http-call' import Netrc from 'netrc-parser' -import open = require('open') +import open from 'open' import * as os from 'os' import {APIClient, HerokuAPIError} from './api-client' @@ -152,7 +152,7 @@ export class Login { } // ux.warn(`If browser does not open, visit ${color.greenBright(url)}`) - const cp = await open(url, {app: browser, wait: false}) + const cp = await open(url, {wait: false, ...(browser ? {app: {name: browser}} : {})}) cp.on('error', err => { ux.warn(err) showUrl() diff --git a/src/particleboard-client.ts b/src/particleboard-client.ts new file mode 100644 index 0000000..8072b46 --- /dev/null +++ b/src/particleboard-client.ts @@ -0,0 +1,72 @@ +import {Interfaces} from '@oclif/core' +import {HTTP, HTTPRequestOptions} from 'http-call' +import * as url from 'url' + +import deps from './deps' +import {RequestId, requestIdHeader} from './request-id' +import {vars} from './vars' + +export interface IDelinquencyInfo { + scheduled_suspension_time?: string | null + scheduled_deletion_time?: string | null +} + +export class ParticleboardClient { + http: typeof HTTP + private _auth?: string + + constructor(protected config: Interfaces.Config) { + this.config = config + const particleboardUrl = url.URL ? new url.URL(vars.particleboardUrl) : url.parse(vars.particleboardUrl) + const self = this as any + const envParticleboardHeaders = JSON.parse(process.env.HEROKU_PARTICLEBOARD_HEADERS || '{}') + const particleboardOpts = { + host: particleboardUrl.hostname, + port: particleboardUrl.port, + protocol: particleboardUrl.protocol, + headers: { + accept: 'application/vnd.heroku+json; version=3', + 'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}`, + ...envParticleboardHeaders, + }, + } + this.http = class ParticleboardHTTPClient extends deps.HTTP.HTTP.create(particleboardOpts) { + static trackRequestIds(response: HTTP) { + const responseRequestIdHeader = response.headers[requestIdHeader] + if (responseRequestIdHeader) { + const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',') + RequestId.track(...requestIds) + } + } + + static async request(url: string, opts: HTTPRequestOptions = {}): Promise> { + opts.headers = opts.headers || {} + opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue + + if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) { + opts.headers.authorization = `Bearer ${self.auth}` + } + + const response = await super.request(url, opts) + this.trackRequestIds(response) + return response + } + } + } + + get auth(): string | undefined { + return this._auth + } + + set auth(token: string | undefined) { + this._auth = token + } + + get(url: string, options: HTTPRequestOptions = {}) { + return this.http.get(url, options) + } + + get defaults(): typeof HTTP.defaults { + return this.http.defaults + } +} diff --git a/src/vars.ts b/src/vars.ts index 1fa0f22..d34a5b0 100644 --- a/src/vars.ts +++ b/src/vars.ts @@ -49,6 +49,19 @@ export class Vars { get gitPrefixes(): string[] { return [`git@${this.gitHost}:`, `ssh://git@${this.gitHost}/`, `https://${this.httpGitHost}/`] } + + get envParticleboardUrl(): string | undefined { + return process.env.HEROKU_PARTICLEBOARD_URL + } + + // This should be fixed after we make our staging hostnames consistent throughout all services + // changing the staging cloud URL to `particleboard.staging.herokudev.com`. + get particleboardUrl(): string { + if (this.envParticleboardUrl) return this.envParticleboardUrl + return process.env.HEROKU_CLOUD === 'staging' ? + 'https://particleboard-staging-cloud.herokuapp.com' : + 'https://particleboard.heroku.com' + } } export const vars = new Vars() diff --git a/test/api-client.test.ts b/test/api-client.test.ts index d04be05..39a3359 100644 --- a/test/api-client.test.ts +++ b/test/api-client.test.ts @@ -1,3 +1,4 @@ +import {stderr} from 'stdout-stderr' import {Config, ux} from '@oclif/core' import base, {expect} from 'fancy-test' import nock from 'nock' @@ -109,6 +110,253 @@ describe('api_client', () => { }) }) + describe('request for Account Info endpoint', () => { + test + .it('sends requests to Platform API and Particleboard', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/account').reply(200, {id: 'acct_id'}) + + const cmd = new Command([], ctx.config) + const {body} = await cmd.heroku.get('/account') + expect(body).to.deep.equal([{id: 'myid'}]) + particleboard.done() + }) + + test + .it('doesn‘t fail or show delinquency warnings if Particleboard request fails', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/account').reply(401, {id: 'unauthorized', message: 'Unauthorized'}) + + stderr.start() + const cmd = new Command([], ctx.config) + const {body} = await cmd.heroku.get('/account') + + expect(body).to.deep.equal([{id: 'myid'}]) + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + + test + .it('doesn‘t show delinquency warnings if account isn‘t delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/account').reply(200, { + scheduled_suspension_time: null, + scheduled_deletion_time: null, + }) + + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/account') + + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + + test + .it('shows a delinquency warning with suspension and deletion dates if account is delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const suspensionTime = new Date('2024-06-01T12:00:00.000Z') + const deletionTime = new Date('2024-06-15T23:59:59.999Z') + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/account').reply(200, { + scheduled_suspension_time: suspensionTime.toISOString(), + scheduled_deletion_time: deletionTime.toISOString(), + }) + + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/account') + + expect(stderr.output).to.include(`This account is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + expect(stderr.output).to.include(`This account is delinquent with payment and we‘ll delete it on ${deletionTime}`) + stderr.stop() + particleboard.done() + }) + + test + .it('it doesn‘t send a Particleboard request or show duplicated delinquency warnings with multiple matching requests when delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + api.get('/account').reply(200, [{id: 'myid'}]) + const suspensionTime = new Date('2024-06-01T12:00:00.000Z') + const deletionTime = new Date('2024-06-15T23:59:59.999Z') + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard + .get('/account').reply(200, { + scheduled_suspension_time: suspensionTime.toISOString(), + scheduled_deletion_time: deletionTime.toISOString(), + }) + + stderr.print = true + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/account') + expect(stderr.output).to.include(`This account is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + expect(stderr.output).to.include(`This account is delinquent with payment and we‘ll delete it on ${deletionTime}`) + stderr.stop() + + stderr.start() + await cmd.heroku.get('/account') + expect(stderr.output).to.eq('') + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + }) + + describe('team namespaced requests', () => { + test + .it('sends requests to Platform API and Particleboard', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/teams/my_team').reply(200, {id: 'my_team_id', name: 'my_team'}) + + const cmd = new Command([], ctx.config) + const {body} = await cmd.heroku.get('/teams/my_team/members') + + expect(body).to.deep.equal([{id: 'member_id'}]) + particleboard.done() + }) + + test + .it('doesn‘t fail or show delinquency warnings if Particleboard request fails', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/teams/my_team').reply(404, {id: 'not_found', message: 'Team not found', resource: 'team'}) + + stderr.start() + const cmd = new Command([], ctx.config) + const {body} = await cmd.heroku.get('/teams/my_team/members') + + expect(body).to.deep.equal([{id: 'member_id'}]) + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + + test + .it('doesn‘t show delinquency warnings if team isn‘t delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/teams/my_team').reply(200, { + scheduled_suspension_time: null, + scheduled_deletion_time: null, + }) + + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/teams/my_team/members') + + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + + test + .it('shows a delinquency warning with suspension and deletion dates if team is delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const suspensionTime = new Date('2024-06-01T12:00:00.000Z') + const deletionTime = new Date('2024-06-15T23:59:59.999Z') + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard.get('/teams/my_team').reply(200, { + scheduled_suspension_time: suspensionTime.toISOString(), + scheduled_deletion_time: deletionTime.toISOString(), + }) + + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/teams/my_team/members') + + expect(stderr.output).to.include(`This team is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + expect(stderr.output).to.include(`This team is delinquent with payment and we‘ll delete it on ${deletionTime}`) + stderr.stop() + particleboard.done() + }) + + test + .it('it doesn‘t send a Particleboard request or show duplicated delinquency warnings with multiple matching requests when delinquent', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const suspensionTime = new Date('2024-06-01T12:00:00.000Z') + const deletionTime = new Date('2024-06-15T23:59:59.999Z') + const particleboard = nock('https://particleboard.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + particleboard + .get('/teams/my_team').reply(200, { + scheduled_suspension_time: suspensionTime.toISOString(), + scheduled_deletion_time: deletionTime.toISOString(), + }) + + stderr.print = true + stderr.start() + const cmd = new Command([], ctx.config) + await cmd.heroku.get('/teams/my_team/members') + expect(stderr.output).to.include(`This team is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + expect(stderr.output).to.include(`This team is delinquent with payment and we‘ll delete it on ${deletionTime}`) + stderr.stop() + + stderr.start() + await cmd.heroku.get('/teams/my_team/members') + expect(stderr.output).to.eq('') + expect(stderr.output).to.eq('') + stderr.stop() + particleboard.done() + }) + }) + test .it('2fa no preauth', async ctx => { api = nock('https://api.heroku.com') diff --git a/test/vars.test.ts b/test/vars.test.ts index 5477b4d..d74ac74 100644 --- a/test/vars.test.ts +++ b/test/vars.test.ts @@ -25,6 +25,7 @@ describe('vars', () => { expect(vars.gitHost).to.equal('heroku.com') expect(vars.httpGitHost).to.equal('git.heroku.com') expect(vars.gitPrefixes).to.deep.equal(['git@heroku.com:', 'ssh://git@heroku.com/', 'https://git.heroku.com/']) + expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com') }) it('respects HEROKU_HOST', () => { @@ -35,6 +36,7 @@ describe('vars', () => { expect(vars.host).to.equal('customhost') expect(vars.httpGitHost).to.equal('git.customhost') expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://git.customhost/']) + expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com') }) it('respects HEROKU_HOST as url', () => { @@ -45,5 +47,16 @@ describe('vars', () => { expect(vars.gitHost).to.equal('customhost') expect(vars.httpGitHost).to.equal('customhost') expect(vars.gitPrefixes).to.deep.equal(['git@customhost:', 'ssh://git@customhost/', 'https://customhost/']) + expect(vars.particleboardUrl).to.equal('https://particleboard.heroku.com') + }) + + it('respects HEROKU_PARTICLEBOARD_URL', () => { + process.env.HEROKU_PARTICLEBOARD_URL = 'https://customhost' + expect(vars.particleboardUrl).to.equal('https://customhost') + }) + + it('respects HEROKU_CLOUD', () => { + process.env.HEROKU_CLOUD = 'staging' + expect(vars.particleboardUrl).to.equal('https://particleboard-staging-cloud.herokuapp.com') }) }) diff --git a/yarn.lock b/yarn.lock index c6a90b9..9913db5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1704,6 +1704,11 @@ define-data-property@^1.0.1, define-data-property@^1.1.4: es-errors "^1.3.0" gopd "^1.0.1" +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + define-properties@^1.2.0, define-properties@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz" @@ -3069,7 +3074,7 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" -is-docker@^2.0.0: +is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== @@ -3273,11 +3278,6 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= - is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" @@ -4094,13 +4094,6 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" -open@^6.2.0: - version "6.4.0" - resolved "https://registry.npmjs.org/open/-/open-6.4.0.tgz" - integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== - dependencies: - is-wsl "^1.1.0" - open@^7.3.0: version "7.4.2" resolved "https://registry.npmjs.org/open/-/open-7.4.2.tgz" @@ -4109,6 +4102,15 @@ open@^7.3.0: is-docker "^2.0.0" is-wsl "^2.1.1" +open@^8.4.2: + version "8.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + optionator@^0.9.1: version "0.9.4" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" @@ -4865,6 +4867,14 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +stdout-stderr@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/stdout-stderr/-/stdout-stderr-0.1.13.tgz#54e3450f3d4c54086a49c0c7f8786a44d1844b6f" + integrity sha512-Xnt9/HHHYfjZ7NeQLvuQDyL1LnbsbddgMFKCuaQKwGCdJm8LnstZIXop+uOY36UR1UXXoHXfMbC1KlVdVd2JLA== + dependencies: + debug "^4.1.1" + strip-ansi "^6.0.0" + stdout-stderr@^0.1.9: version "0.1.9" resolved "https://registry.npmjs.org/stdout-stderr/-/stdout-stderr-0.1.9.tgz"