Skip to content

Commit

Permalink
refactor: migrate apps:transfer to oclif/core (#2767)
Browse files Browse the repository at this point in the history
* refactor: initial migration of apps:transfer to oclif/core

* refactor: move access utils since they are used by apps command

* refactor: add isValidEmail to teamUtils

* refactor: migrate AppTransfer lib class

* refactor: fix types and update getAppsToTransfer

* refactor: add confirm flag and construct app name

* refactor: print error message and update lock.run call

* refactor: initial refactor of apps:transfer tests

* refactor: replace AppTransfer class with function and fix message output

* refactor: test fixes

* refactor: remove .only

* refactor: add await to getConfig for test setup

* fix: add app flag to AppsLock.run call and fix test

* fix: add async await for commands that need the config

* fix: add nock for addons:wait test api call

* fix: fix apps:transfer test accounting for mac and windows formatting

* fix: one more windows test fix for apps:transfer

* refactor: remove orgs-v5 package directory

* fix: update yarn.lock

* refactor: remove import of orgs-v5 plugin from cli
  • Loading branch information
k80bowman authored Apr 8, 2024
1 parent d55255b commit 9506a9c
Show file tree
Hide file tree
Showing 33 changed files with 338 additions and 1,681 deletions.
2 changes: 0 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"@heroku-cli/notifications": "^1.2.4",
"@heroku-cli/plugin-certs-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-ci-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-orgs-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-pg-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-ps": "^8.1.7",
"@heroku-cli/plugin-ps-exec": "^2.4.0",
Expand Down Expand Up @@ -182,7 +181,6 @@
"plugins": [
"@oclif/plugin-legacy",
"@heroku-cli/plugin-certs-v5",
"@heroku-cli/plugin-orgs-v5",
"@heroku-cli/plugin-pg-v5",
"@heroku-cli/plugin-ps-exec",
"@heroku-cli/plugin-spaces",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {isTeamApp, getOwner} from '../../lib/access/utils'
import {isTeamApp, getOwner} from '../../lib/teamUtils'
import * as _ from 'lodash'
export default class AccessAdd extends Command {
static description = 'add new users to your app'
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Command, flags} from '@heroku-cli/command'
import {ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import * as _ from 'lodash'
import {isTeamApp, getOwner} from '../../lib/access/utils'
import {isTeamApp, getOwner} from '../../lib/teamUtils'

type MemberData = {
email: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {isTeamApp} from '../../lib/access/utils'
import {isTeamApp} from '../../lib/teamUtils'

export default class Update extends Command {
static topic = 'access';
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/src/commands/apps/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {sortBy} from 'lodash'
import * as inquirer from 'inquirer'
import {getOwner, isTeamApp, isValidEmail} from '../../lib/teamUtils'
import AppsLock from './lock'
import {appTransfer} from '../../lib/apps/app-transfer'
import confirmApp from '../../lib/apps/confirm-app'

function getAppsToTransfer(apps: Heroku.App[]) {
return inquirer.prompt([{
type: 'checkbox',
name: 'choices',
pageSize: 20,
message: 'Select applications you would like to transfer',
choices: apps.map(function (app) {
return {
name: `${app.name} (${getOwner(app.owner?.email)})`, value: {name: app.name, owner: app.owner?.email},
}
}),
}])
}

export default class AppsTransfer extends Command {
static topic = 'apps';
static description = 'transfer applications to another user or team';
static flags = {
locked: flags.boolean({char: 'l', required: false, description: 'lock the app upon transfer'}),
bulk: flags.boolean({required: false, description: 'transfer applications in bulk'}),
app: flags.app(),
remote: flags.remote({char: 'r'}),
confirm: flags.string({char: 'c', hidden: true}),
};

static args = {
recipient: Args.string({description: 'user or team to transfer applications to', required: true}),
};

static examples = [`$ heroku apps:transfer collaborator@example.com
Transferring example to collaborator@example.com... done
$ heroku apps:transfer acme-widgets
Transferring example to acme-widgets... done
$ heroku apps:transfer --bulk acme-widgets
...`]

public async run() {
const {flags, args} = await this.parse(AppsTransfer)
const {app, bulk, locked, confirm} = flags
const recipient = args.recipient
if (bulk) {
const {body: allApps} = await this.heroku.get<Heroku.App[]>('/apps')
const selectedApps = await getAppsToTransfer(sortBy(allApps, 'name'))
ux.warn(`Transferring applications to ${color.magenta(recipient)}...\n`)
for (const app of selectedApps.choices) {
try {
await appTransfer({
heroku: this.heroku,
appName: app.name,
recipient: recipient,
personalToPersonal: isValidEmail(recipient) && !isTeamApp(app.owner),
bulk: true,
})
} catch (error) {
const {message} = error as {message: string}
ux.error(message)
}
}
} else {
const {body: appInfo} = await this.heroku.get<Heroku.App>(`/apps/${app}`)
const appName = appInfo.name ?? app ?? ''
if (isValidEmail(recipient) && isTeamApp(appInfo.owner?.email)) {
await confirmApp(appName, confirm, 'All collaborators will be removed from this app')
}

await appTransfer({
heroku: this.heroku,
appName,
recipient,
personalToPersonal: isValidEmail(recipient) && !isTeamApp(appInfo.owner?.email),
bulk,
})
if (locked) {
await AppsLock.run(['--app', appName], this.config)
}
}
}
}
45 changes: 45 additions & 0 deletions packages/cli/src/lib/apps/app-transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {APIClient} from '@heroku-cli/command'
import {color} from '@heroku-cli/color'
import * as Heroku from '@heroku-cli/schema'
import {ux} from '@oclif/core'

type Options = {
heroku: APIClient,
appName: string,
recipient: string,
personalToPersonal: boolean,
bulk: boolean,
}

const getRequestOpts = (options: Options) => {
const {appName, bulk, recipient, personalToPersonal} = options
const isPersonalToPersonal = personalToPersonal || personalToPersonal === undefined
const requestOpts = isPersonalToPersonal ?
{
body: {app: appName, recipient},
transferMsg: `Initiating transfer of ${color.app(appName)}`,
path: '/account/app-transfers',
method: 'POST',
} : {
body: {owner: recipient},
transferMsg: `Transferring ${color.app(appName)}`,
path: `/teams/apps/${appName}`,
method: 'PATCH',
}
if (!bulk) requestOpts.transferMsg += ` to ${color.magenta(recipient)}`
return requestOpts
}

export const appTransfer = async (options: Options) => {
const {body, transferMsg, path, method} = getRequestOpts(options)
ux.action.start(transferMsg)
const {body: request} = await options.heroku.request<Heroku.TeamApp>(
path,
{
method,
body,
},
)
const message = request.state === 'pending' ? 'email sent' : undefined
ux.action.stop(message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ export const getOwner = function (owner: string | undefined) {

return owner
}

export const isValidEmail = function (email: string) {
return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email)
}
5 changes: 3 additions & 2 deletions packages/cli/test/helpers/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const stopMock = () => {
stderr.stop()
}

const runCommand = (Cmd: GenericCmd, args: string[] = [], printStd = false) => {
const instance = new Cmd(args, getConfig())
const runCommand = async (Cmd: GenericCmd, args: string[] = [], printStd = false) => {
const conf = await getConfig()
const instance = new Cmd(args, conf)
if (printStd) {
stdout.print = true
stderr.print = true
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/test/helpers/testInstances.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {APIClient} from '@heroku-cli/command'
import {Config} from '@oclif/core'

export const getConfig = () => new Config({root: '../../package.json'})
export const getConfig = async () => {
const pjsonPath = require.resolve('../../package.json')
const conf = new Config({root: pjsonPath})
await conf.load()
return conf
}

export const getHerokuAPI = () => new APIClient(getConfig())
export const getHerokuAPI = async () => {
const conf = await getConfig()
return new APIClient(conf)
}
2 changes: 2 additions & 0 deletions packages/cli/test/unit/commands/addons/wait.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ Created www-redis as REDIS_URL
})
it('shows that it failed to provision', function () {
nock('https://api.heroku.com')
.post('/actions/addons/resolve', {app: null, addon: 'www-redis'})
.reply(200, [fixtures.addons['www-redis']])
.get('/addons/www-redis')
.reply(200, fixtures.addons['www-redis'])
const deprovisionedAddon = _.clone(fixtures.addons['www-redis'])
Expand Down
146 changes: 146 additions & 0 deletions packages/cli/test/unit/commands/apps/transfer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {stdout, stderr} from 'stdout-stderr'
import * as nock from 'nock'
import * as proxyquire from 'proxyquire'
import {expect} from 'chai'
import runCommand, {GenericCmd} from '../../../helpers/runCommand'
import {apps, personalApp, teamApp} from '../../../helpers/stubs/get'
import {teamAppTransfer} from '../../../helpers/stubs/patch'
import {personalToPersonal} from '../../../helpers/stubs/post'

let Cmd: GenericCmd
let inquirer: {prompt?: (prompts: { choices: any }[]) => void} = {}

describe('heroku apps:transfer', () => {
beforeEach(() => {
inquirer = {}
const {default: proxyCmd} = proxyquire('../../../../src/commands/apps/transfer', {
inquirer,
'@noCallThru': true,
})
Cmd = proxyCmd
})
afterEach(() => nock.cleanAll())
context('when transferring in bulk', () => {
beforeEach(() => {
apps()
})
it('transfers selected apps to a team', async () => {
inquirer.prompt = (prompts: { choices: any }[]) => {
const choices = prompts[0].choices
expect(choices).to.eql([
{
name: 'my-team-app (team)', value: {name: 'my-team-app', owner: 'team@herokumanager.com'},
}, {
name: 'myapp (foo@foo.com)', value: {name: 'myapp', owner: 'foo@foo.com'},
},
])
return Promise.resolve({choices: [{name: 'myapp', owner: 'foo@foo.com'}]})
}

const api = teamAppTransfer()
await runCommand(Cmd, [
'--bulk',
'team',
])
api.done()
expect(stderr.output).to.include('Warning: Transferring applications to team...\n')
expect(stderr.output).to.include('\nTransferring ⬢ myapp...\nTransferring ⬢ myapp... done\n')
})
it('transfers selected apps to a personal account', async () => {
inquirer.prompt = (prompts: { choices: any }[]) => {
const choices = prompts[0].choices
expect(choices).to.eql([
{
name: 'my-team-app (team)', value: {name: 'my-team-app', owner: 'team@herokumanager.com'},
}, {
name: 'myapp (foo@foo.com)', value: {name: 'myapp', owner: 'foo@foo.com'},
},
])
return Promise.resolve({choices: [{name: 'myapp', owner: 'foo@foo.com'}]})
}

const api = personalToPersonal()
await runCommand(Cmd, [
'--bulk',
'raulb@heroku.com',
])
api.done()
expect(stderr.output).to.include('Warning: Transferring applications to raulb@heroku.com...\n')
expect(stderr.output).to.include('\nInitiating transfer of ⬢ myapp...\nInitiating transfer of ⬢ myapp... email sent\n')
})
})
context('when it is a personal app', () => {
beforeEach(() => {
personalApp()
})
it('transfers the app to a personal account', async () => {
const api = personalToPersonal()
await runCommand(Cmd, [
'--app',
'myapp',
'raulb@heroku.com',
])
expect('').to.eq(stdout.output)
expect('Initiating transfer of ⬢ myapp to raulb@heroku.com...\nInitiating transfer of ⬢ myapp to raulb@heroku.com... email sent\n').to.eq(stderr.output)
api.done()
})
it('transfers the app to a team', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
})
context('when it is an org app', () => {
beforeEach(() => {
teamApp()
})
it('transfers the app to a personal account confirming app name', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'--confirm',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
it('transfers the app to a team', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
it('transfers and locks the app if --locked is passed', async () => {
const api = teamAppTransfer()
const lockedAPI = nock('https://api.heroku.com:443')
.get('/teams/apps/myapp')
.reply(200, {name: 'myapp', locked: false})
.patch('/teams/apps/myapp', {locked: true})
.reply(200)
await runCommand(Cmd, [
'--app',
'myapp',
'--locked',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\nLocking myapp...\nLocking myapp... done\n').to.eq(stderr.output)
api.done()
lockedAPI.done()
})
})
})
Loading

0 comments on commit 9506a9c

Please sign in to comment.