Skip to content

Commit 1f896aa

Browse files
authored
feat: update accounts:set to work with keychain managers (#3696)
* feat: update accounts:set to allow for keychains * test: update tests for accounts:set and set function * test: update tests to work with Windows * test: fix git credentials tests * refactor: update logic to be more specific about keychain vs netrc accounts * test: update accounts:set tests * fix: revert changes to the accounts wrapper
1 parent 89d7b14 commit 1f896aa

5 files changed

Lines changed: 133 additions & 27 deletions

File tree

src/commands/accounts/set.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,27 @@ import AccountsModule from '../../lib/accounts/accounts.js'
66

77
export default class Set extends Command {
88
static args = {
9-
name: Args.string({description: 'name of account to set', required: true}),
9+
name: Args.string({description: 'name or username of account to set', required: true}),
1010
}
1111

12-
static description = 'set the current Heroku account from your cache'
12+
static description = 'set the current Heroku account from your accounts cache or system keychain'
1313

1414
static example = `${color.command('heroku accounts:set my-account')}`
1515

1616
async run() {
1717
const {args} = await this.parse(Set)
1818
const {name} = args
1919

20-
if (!(await AccountsModule.list()).some(account => account.name === name)) {
21-
ux.error(`${name} does not exist in your accounts cache.`)
20+
const accounts = await AccountsModule.list()
21+
const netrcAccount = accounts.find(account => account.name === name)
22+
const keychainAccount = accounts.find(account => !account.name && account.username === name)
23+
24+
if (!netrcAccount && !keychainAccount) {
25+
ux.error(`${name} does not exist in your accounts cache or system keychain.`)
2226
}
2327

24-
AccountsModule.set(name)
28+
const account = netrcAccount ?? keychainAccount
29+
30+
await AccountsModule.set(account!, this.config.dataDir)
2531
}
2632
}

src/commands/git/credentials.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class GitCredentials extends Command {
3838
})
3939

4040
rl.on('close', () => {
41+
process.stdin.pause()
4142
resolve(input)
4243
})
4344
})

src/lib/accounts/accounts.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {APIClient, listKeychainAccounts, getStorageConfig} from '@heroku-cli/command'
1+
import {APIClient, listKeychainAccounts, getStorageConfig, writeLoginState} from '@heroku-cli/command'
22
import {removeAuth} from '@heroku-cli/command/lib/credential-manager.js'
33
import * as Heroku from '@heroku-cli/schema'
44
import fs from 'node:fs'
@@ -16,7 +16,9 @@ export interface IAccountsWrapper {
1616
current(heroku: APIClient): Promise<string | null>
1717
add(name: string, username: string, password: string): void
1818
remove(name: string): void
19-
set(name: string): Promise<void>
19+
set(account: AccountEntry, dataDir: string): Promise<void>
20+
getStorageConfig(): ReturnType<typeof getStorageConfig>
21+
writeLoginState(dataDir: string, name: string): Promise<void>
2022
}
2123

2224
export class AccountsWrapper implements IAccountsWrapper {
@@ -65,6 +67,10 @@ export class AccountsWrapper implements IAccountsWrapper {
6567
return getStorageConfig()
6668
}
6769

70+
async writeLoginState(dataDir: string, name: string): Promise<void> {
71+
return writeLoginState(dataDir, name)
72+
}
73+
6874
async list(): Promise<AccountEntry[]> {
6975
const config = this.getStorageConfig()
7076
if (config.credentialStore) {
@@ -130,12 +136,20 @@ export class AccountsWrapper implements IAccountsWrapper {
130136
fs.unlinkSync(path.join(basedir, name))
131137
}
132138

133-
async set(name: string): Promise<void> {
134-
const netrcInstance = await this.initNetrc()
135-
const current = this.account(name)
136-
netrcInstance.machines['git.heroku.com'] = {login: current.username, password: current.password}
137-
netrcInstance.machines['api.heroku.com'] = {login: current.username, password: current.password}
138-
await netrcInstance.save()
139+
async set(account: AccountEntry, dataDir: string): Promise<void> {
140+
const config = this.getStorageConfig()
141+
if (config.credentialStore && !account.name) {
142+
await this.writeLoginState(dataDir, account.username)
143+
return
144+
}
145+
146+
if (config.useNetrc && account.name) {
147+
const netrcInstance = await this.initNetrc()
148+
const current = this.account(account.name)
149+
netrcInstance.machines['git.heroku.com'] = {login: current.username, password: current.password}
150+
netrcInstance.machines['api.heroku.com'] = {login: current.username, password: current.password}
151+
await netrcInstance.save()
152+
}
139153
}
140154
}
141155

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,49 @@
11
import {expect} from 'chai'
2-
import runCommand from '../../../helpers/runCommand.js'
3-
import * as sinon from 'sinon'
2+
import {restore, SinonStub, stub} from 'sinon'
3+
44
import Cmd from '../../../../src/commands/accounts/set.js'
55
import AccountsModule from '../../../../src/lib/accounts/accounts.js'
6+
import runCommand from '../../../helpers/runCommand.js'
67

78
describe('accounts:set', function () {
8-
let listStub: sinon.SinonStub
9-
let setStub: sinon.SinonStub
9+
let listStub: SinonStub
10+
let setStub: SinonStub
1011

1112
beforeEach(function () {
12-
listStub = sinon.stub(AccountsModule, 'list')
13-
setStub = sinon.stub(AccountsModule, 'set')
13+
listStub = stub(AccountsModule, 'list')
14+
setStub = stub(AccountsModule, 'set').resolves()
1415
})
1516

1617
afterEach(function () {
17-
sinon.restore()
18+
restore()
1819
})
1920

20-
it('calls the set function with the account name when the account exists', async function () {
21+
it('calls set with the account name and dataDir when matched by name', async function () {
2122
listStub.resolves([{name: 'test-account', username: 'user1'}, {name: 'test-account-2', username: 'user2'}])
2223
await runCommand(Cmd, ['test-account-2'])
23-
expect(setStub.calledWith('test-account-2'))
24+
expect(setStub.calledOnce).to.be.true
25+
expect(setStub.firstCall.args[0]).to.deep.equal({name: 'test-account-2', username: 'user2'})
26+
expect(setStub.firstCall.args[1].toLowerCase()).to.contain('local')
27+
expect(setStub.firstCall.args[1]).to.contain('heroku')
2428
})
2529

26-
it('should return an error if the selected account name is not included in the account list', async function () {
30+
it('calls set with the account name and dataDir when matched by username', async function () {
31+
listStub.resolves([{username: 'user1@example.com'}, {username: 'user2@example.com'}])
32+
await runCommand(Cmd, ['user1@example.com'])
33+
expect(setStub.calledOnce).to.be.true
34+
expect(setStub.firstCall.args[0]).to.deep.equal({username: 'user1@example.com'})
35+
expect(setStub.firstCall.args[1].toLowerCase()).to.contain('local')
36+
expect(setStub.firstCall.args[1]).to.contain('heroku')
37+
})
38+
39+
it('returns an error if the account is not in the list', async function () {
2740
listStub.resolves([{name: 'test-account', username: 'user1'}, {name: 'test-account-2', username: 'user2'}])
28-
await runCommand(Cmd, ['test-account-3'])
29-
.catch((error: Error) => {
30-
expect(error.message).to.contain('test-account-3 does not exist in your accounts cache.')
31-
})
41+
42+
try {
43+
await runCommand(Cmd, ['test-account-3'])
44+
expect.fail('Expected command to throw error')
45+
} catch (error: any) {
46+
expect(error.message).to.contain('test-account-3 does not exist in your accounts cache or system keychain.')
47+
}
3248
})
3349
})

test/unit/lib/accounts/accounts.unit.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,75 @@ describe('accounts', function () {
163163
})
164164
})
165165

166+
describe('set()', function () {
167+
describe('with credentialStore and no account name', function () {
168+
let writeLoginStateStub: sinon.SinonStub
169+
170+
beforeEach(function () {
171+
sinon.stub(AccountsModule, 'getStorageConfig').returns({credentialStore: 'keychain' as any, useNetrc: false})
172+
writeLoginStateStub = sinon.stub(AccountsModule, 'writeLoginState').resolves()
173+
})
174+
175+
it('calls writeLoginState with the dataDir and account username', async function () {
176+
const account = {username: 'user@example.com'}
177+
await AccountsModule.set(account, '/data/heroku')
178+
179+
expect(writeLoginStateStub.calledOnce).to.be.true
180+
expect(writeLoginStateStub.firstCall.args[0]).to.equal('/data/heroku')
181+
expect(writeLoginStateStub.firstCall.args[1]).to.equal('user@example.com')
182+
})
183+
184+
it('does not call writeLoginState when account has a name', async function () {
185+
const account = {name: 'my-account', username: 'user@example.com'}
186+
await AccountsModule.set(account, '/data/heroku')
187+
188+
expect(writeLoginStateStub.called).to.be.false
189+
})
190+
})
191+
192+
describe('with useNetrc and account name', function () {
193+
let fakeNetrc: {machines: Record<string, {login: string, password: string}>, save: sinon.SinonStub}
194+
195+
function setNetrc(value: typeof fakeNetrc | undefined) {
196+
(AccountsModule as unknown as {netrc: typeof fakeNetrc | undefined}).netrc = value
197+
}
198+
199+
beforeEach(function () {
200+
sinon.stub(AccountsModule, 'getStorageConfig').returns({credentialStore: null, useNetrc: true})
201+
fakeNetrc = {machines: {}, save: sinon.stub().resolves()}
202+
setNetrc(fakeNetrc)
203+
fsReadFileStub.withArgs(sinon.match(/my-account$/), 'utf8')
204+
.returns('username: user@example.com\npassword: secret\n')
205+
})
206+
207+
afterEach(function () {
208+
setNetrc(null as unknown as typeof fakeNetrc)
209+
})
210+
211+
it('writes credentials to api.heroku.com and git.heroku.com machines', async function () {
212+
const account = {name: 'my-account', username: 'user@example.com'}
213+
await AccountsModule.set(account, '/data/heroku')
214+
215+
expect(fakeNetrc.machines['api.heroku.com']).to.deep.equal({login: 'user@example.com', password: 'secret'})
216+
expect(fakeNetrc.machines['git.heroku.com']).to.deep.equal({login: 'user@example.com', password: 'secret'})
217+
})
218+
219+
it('saves the netrc file', async function () {
220+
const account = {name: 'my-account', username: 'user@example.com'}
221+
await AccountsModule.set(account, '/data/heroku')
222+
223+
expect(fakeNetrc.save.calledOnce).to.be.true
224+
})
225+
226+
it('does not update netrc when account has no name', async function () {
227+
const account = {username: 'user@example.com'}
228+
await AccountsModule.set(account, '/data/heroku')
229+
230+
expect(fakeNetrc.save.called).to.be.false
231+
})
232+
})
233+
})
234+
166235
describe('remove', function () {
167236
let unlinkStub: sinon.SinonStub
168237
let osHomeStub: sinon.SinonStub

0 commit comments

Comments
 (0)