From 4adfecbba7e14b8f8d7c61faa18c437170d05be0 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Wed, 12 Jun 2024 19:58:19 -0300 Subject: [PATCH] Porting delinquency work back to v10.x --- package.json | 3 +- src/api-client.ts | 92 ++++++++++- src/deps.ts | 4 + src/login.ts | 4 +- src/particleboard-client.ts | 79 ++++++++++ src/vars.ts | 13 ++ test/api-client.test.ts | 305 ++++++++++++++++++++++++++++++++++++ test/vars.test.ts | 13 ++ yarn.lock | 32 ++-- 9 files changed, 529 insertions(+), 16 deletions(-) create mode 100644 src/particleboard-client.ts diff --git a/package.json b/package.json index e11ad0e..1a110ea 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "heroku-client": "^3.1.0", "http-call": "^5.2.4", "netrc-parser": "^3.1.6", - "open": "^6.2.0", + "open": "^8.4.2", "uuid": "^8.3.0" }, "devDependencies": { @@ -36,6 +36,7 @@ "nock": "^10.0.6", "proxyquire": "^2.1.0", "sinon": "^9.0.3", + "stdout-stderr": "^0.1.13", "ts-node": "^8.1.0", "tslint": "^6.1.3", "typescript": "^4.8.4" diff --git a/src/api-client.ts b/src/api-client.ts index 3e042bf..7f6da31 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -7,6 +7,7 @@ import * as url from 'url' import deps from './deps' import {Login} from './login' import {Mutex} from './mutex' +import {IDelinquencyConfig, IDelinquencyInfo, ParticleboardClient} from './particleboard-client' import {RequestId, requestIdHeader} from './request-id' import {vars} from './vars' @@ -55,9 +56,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 +78,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 +111,95 @@ 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 + } + + static notifyDelinquency(delinquencyInfo: IDelinquencyInfo): void { + 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) { + warn(`This ${resource} is delinquent with payment and we‘ll suspend it on ${new Date(suspension)}.`) + delinquencyConfig.warning_shown = true + return + } + + if (deletion) + 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) + warn(`This ${resource} is delinquent with payment and we‘ll delete it on ${new Date(deletion)}.`) + + delinquencyConfig.warning_shown = true + } + 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 || {} + this.notifyDelinquency(delinquencyInfo) + this.trackRequestIds(response) return response } catch (error) { @@ -145,6 +227,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..06058ac 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -8,6 +8,7 @@ import file = require('./file') import flags = require('./flags') import git = require('./git') import mutex = require('./mutex') +import particleboardClient = require('./particleboard-client') import yubikey = require('./yubikey') const {ux} = oclif @@ -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 f425511..a6ed49d 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..a712523 --- /dev/null +++ b/src/particleboard-client.ts @@ -0,0 +1,79 @@ +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 interface IDelinquencyConfig { + fetch_delinquency: boolean + warning_shown: boolean + resource_type?: 'account' | 'team' + fetch_url?: string +} + +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 e3111a5..17589fb 100644 --- a/test/api-client.test.ts +++ b/test/api-client.test.ts @@ -3,6 +3,7 @@ import base, {expect} from 'fancy-test' import nock from 'nock' import {resolve} from 'path' import * as sinon from 'sinon' +import {stderr} from 'stdout-stderr' import {Command as CommandBase} from '../src/command' import {RequestId, requestIdHeader} from '../src/request-id' @@ -110,6 +111,310 @@ 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 date if account is delinquent and suspension is in the future', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const now = Date.now() + const suspensionTime = new Date(now + (10 * 60 * 60 * 24 * 1000)) // 10 days in the future + const deletionTime = new Date(now + (30 * 60 * 60 * 24 * 1000)) // 30 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This account is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + stderr.stop() + particleboard.done() + }) + + test + .it('shows a delinquency warning with deletion date if account is delinquent and suspension is in the past', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/account').reply(200, [{id: 'myid'}]) + const now = Date.now() + const suspensionTime = new Date(now - (60 * 60 * 24 * 1000)) // 1 day in the past + const deletionTime = new Date(now + (20 * 60 * 60 * 24 * 1000)) // 20 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This account is delinquent with payment and we suspended it on ${suspensionTime}. If the account is still delinquent, 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 now = Date.now() + const suspensionTime = new Date(now + (10 * 60 * 60 * 24 * 1000)) // 10 days in the future + const deletionTime = new Date(now + (30 * 60 * 60 * 24 * 1000)) // 30 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This account is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + stderr.stop() + + stderr.start() + await cmd.heroku.get('/account') + 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 date if team is delinquent and suspension is in the future', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const now = Date.now() + const suspensionTime = new Date(now + (10 * 60 * 60 * 24 * 1000)) // 10 days in the future + const deletionTime = new Date(now + (30 * 60 * 60 * 24 * 1000)) // 30 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This team is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + stderr.stop() + particleboard.done() + }) + + test + .it('shows a delinquency warning with deletion date if team is delinquent and suspension is in the past', async ctx => { + api = nock('https://api.heroku.com', { + reqheaders: {authorization: 'Bearer mypass'}, + }) + api.get('/teams/my_team/members').reply(200, [{id: 'member_id'}]) + const now = Date.now() + const suspensionTime = new Date(now - (60 * 60 * 24 * 1000)) // 1 day in the past + const deletionTime = new Date(now + (20 * 60 * 60 * 24 * 1000)) // 20 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This team is delinquent with payment and we suspended it on ${suspensionTime}. If the team is still delinquent, 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 now = Date.now() + const suspensionTime = new Date(now + (10 * 60 * 60 * 24 * 1000)) // 10 days in the future + const deletionTime = new Date(now + (30 * 60 * 60 * 24 * 1000)) // 30 days in the future + 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') + + const stderrOutput = stderr.output.replace(/ *[»›] */g, '').replace(/ *\n */g, ' ') + expect(stderrOutput).to.include(`This team is delinquent with payment and we‘ll suspend it on ${suspensionTime}`) + stderr.stop() + + stderr.start() + await cmd.heroku.get('/teams/my_team/members') + + 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 5432ffc..4cb3ac5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -731,6 +731,11 @@ deep-equal@^1.0.0: resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= +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.1.2, define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -1168,7 +1173,7 @@ is-date-object@^1.0.1: resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== -is-docker@^2.0.0: +is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== @@ -1239,11 +1244,6 @@ is-symbol@^1.0.2: dependencies: has-symbols "^1.0.1" -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= - is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -1581,12 +1581,14 @@ once@^1.3.0: dependencies: wrappy "1" -open@^6.2.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/open/-/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9" - integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== +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: - is-wsl "^1.1.0" + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" p-finally@^1.0.0: version "1.0.0" @@ -1828,6 +1830,14 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" 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.yarnpkg.com/stdout-stderr/-/stdout-stderr-0.1.9.tgz#9b48ee04eff955ee07776e27125d5524d9d02f57"