From 5d0722220e79f5b802b3cafc6fcb9d77d24438ff Mon Sep 17 00:00:00 2001 From: Katy Bowman Date: Fri, 8 May 2026 12:23:54 -0400 Subject: [PATCH 1/4] feat: update accounts:set to allow for keychains --- src/commands/accounts/set.ts | 13 ++++++++----- src/lib/accounts/accounts.ts | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/commands/accounts/set.ts b/src/commands/accounts/set.ts index e2bb76f8a5..fcd49d7b48 100644 --- a/src/commands/accounts/set.ts +++ b/src/commands/accounts/set.ts @@ -6,10 +6,10 @@ import AccountsModule from '../../lib/accounts/accounts.js' export default class Set extends Command { static args = { - name: Args.string({description: 'name of account to set', required: true}), + name: Args.string({description: 'name or username of account to set', required: true}), } - static description = 'set the current Heroku account from your cache' + static description = 'set the current Heroku account from your accounts cache or system keychain' static example = `${color.command('heroku accounts:set my-account')}` @@ -17,10 +17,13 @@ export default class Set extends Command { const {args} = await this.parse(Set) const {name} = args - if (!(await AccountsModule.list()).some(account => account.name === name)) { - ux.error(`${name} does not exist in your accounts cache.`) + const accounts = await AccountsModule.list() + const accountExists = accounts.some(account => account.name === name || account.username === name) + + if (!(accountExists)) { + ux.error(`${name} does not exist in your accounts cache or system keychain.`) } - AccountsModule.set(name) + await AccountsModule.set(name, this.config.dataDir) } } diff --git a/src/lib/accounts/accounts.ts b/src/lib/accounts/accounts.ts index 7efa0e421d..06bb980341 100644 --- a/src/lib/accounts/accounts.ts +++ b/src/lib/accounts/accounts.ts @@ -1,4 +1,4 @@ -import {APIClient, listKeychainAccounts, getStorageConfig} from '@heroku-cli/command' +import {APIClient, listKeychainAccounts, getStorageConfig, writeLoginState} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import fs from 'node:fs' import os from 'node:os' @@ -15,7 +15,8 @@ export interface IAccountsWrapper { current(heroku: APIClient): Promise add(name: string, username: string, password: string): void remove(name: string): void - set(name: string): Promise + set(name: string, dataDir: string): Promise + writeLoginState(configDir: string, name: string): Promise } export class AccountsWrapper implements IAccountsWrapper { @@ -64,6 +65,10 @@ export class AccountsWrapper implements IAccountsWrapper { return getStorageConfig() } + async writeLoginState(dataDir: string, name: string): Promise { + return writeLoginState(dataDir, name) + } + async list(): Promise { const config = this.getStorageConfig() if (config.credentialStore) { @@ -123,7 +128,13 @@ export class AccountsWrapper implements IAccountsWrapper { fs.unlinkSync(path.join(basedir, name)) } - async set(name: string): Promise { + async set(name: string, dataDir: string): Promise { + const config = this.getStorageConfig() + if (config.credentialStore) { + await this.writeLoginState(dataDir, name) + return + } + const netrcInstance = await this.initNetrc() const current = this.account(name) netrcInstance.machines['git.heroku.com'] = {login: current.username, password: current.password} From 06f30dd0d705d24f058811464aaef487bfc8dffa Mon Sep 17 00:00:00 2001 From: Katy Bowman Date: Fri, 8 May 2026 12:24:15 -0400 Subject: [PATCH 2/4] test: update tests for accounts:set and set function --- test/unit/commands/accounts/set.unit.test.ts | 33 ++++++++----- test/unit/lib/accounts/accounts.unit.test.ts | 51 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/test/unit/commands/accounts/set.unit.test.ts b/test/unit/commands/accounts/set.unit.test.ts index d149e8b2ac..af8208a211 100644 --- a/test/unit/commands/accounts/set.unit.test.ts +++ b/test/unit/commands/accounts/set.unit.test.ts @@ -1,33 +1,44 @@ import {expect} from 'chai' -import runCommand from '../../../helpers/runCommand.js' -import * as sinon from 'sinon' +import {restore, SinonStub, stub} from 'sinon' + import Cmd from '../../../../src/commands/accounts/set.js' import AccountsModule from '../../../../src/lib/accounts/accounts.js' +import runCommand from '../../../helpers/runCommand.js' describe('accounts:set', function () { - let listStub: sinon.SinonStub - let setStub: sinon.SinonStub + let listStub: SinonStub + let setStub: SinonStub beforeEach(function () { - listStub = sinon.stub(AccountsModule, 'list') - setStub = sinon.stub(AccountsModule, 'set') + listStub = stub(AccountsModule, 'list') + setStub = stub(AccountsModule, 'set').resolves() }) afterEach(function () { - sinon.restore() + restore() }) - it('calls the set function with the account name when the account exists', async function () { + it('calls set with the account name and dataDir when matched by name', async function () { listStub.resolves([{name: 'test-account', username: 'user1'}, {name: 'test-account-2', username: 'user2'}]) await runCommand(Cmd, ['test-account-2']) - expect(setStub.calledWith('test-account-2')) + expect(setStub.calledOnce).to.be.true + expect(setStub.firstCall.args[0]).to.equal('test-account-2') + expect(setStub.firstCall.args[1]).to.contain('.local/share/heroku') + }) + + it('calls set with the account name and dataDir when matched by username', async function () { + listStub.resolves([{username: 'user1@example.com'}, {username: 'user2@example.com'}]) + await runCommand(Cmd, ['user1@example.com']) + expect(setStub.calledOnce).to.be.true + expect(setStub.firstCall.args[0]).to.equal('user1@example.com') + expect(setStub.firstCall.args[1]).to.contain('.local/share/heroku') }) - it('should return an error if the selected account name is not included in the account list', async function () { + it('returns an error if the account is not in the list', async function () { listStub.resolves([{name: 'test-account', username: 'user1'}, {name: 'test-account-2', username: 'user2'}]) await runCommand(Cmd, ['test-account-3']) .catch((error: Error) => { - expect(error.message).to.contain('test-account-3 does not exist in your accounts cache.') + expect(error.message).to.contain('test-account-3 does not exist in your accounts cache or system keychain.') }) }) }) diff --git a/test/unit/lib/accounts/accounts.unit.test.ts b/test/unit/lib/accounts/accounts.unit.test.ts index 43f45dab14..1fbf1dcdc2 100644 --- a/test/unit/lib/accounts/accounts.unit.test.ts +++ b/test/unit/lib/accounts/accounts.unit.test.ts @@ -162,6 +162,57 @@ describe('accounts', function () { }) }) + describe('set()', function () { + describe('with credentialStore', function () { + let writeLoginStateStub: sinon.SinonStub + + beforeEach(function () { + sinon.stub(AccountsModule, 'getStorageConfig').returns({credentialStore: 'keychain' as any, useNetrc: false}) + writeLoginStateStub = sinon.stub(AccountsModule, 'writeLoginState').resolves() + }) + + it('calls writeLoginState with the dataDir and account name', async function () { + await AccountsModule.set('my-account', '/data/heroku') + + expect(writeLoginStateStub.calledOnce).to.be.true + expect(writeLoginStateStub.firstCall.args[0]).to.equal('/data/heroku') + expect(writeLoginStateStub.firstCall.args[1]).to.equal('my-account') + }) + }) + + describe('without credentialStore', function () { + let fakeNetrc: {machines: Record, save: sinon.SinonStub} + + function setNetrc(value: typeof fakeNetrc | undefined) { + (AccountsModule as unknown as {netrc: typeof fakeNetrc | undefined}).netrc = value + } + + beforeEach(function () { + fakeNetrc = {machines: {}, save: sinon.stub().resolves()} + setNetrc(fakeNetrc) + fsReadFileStub.withArgs(sinon.match(/my-account$/), 'utf8') + .returns('username: user@example.com\npassword: secret\n') + }) + + afterEach(function () { + setNetrc(null as unknown as typeof fakeNetrc) + }) + + it('writes credentials to api.heroku.com and git.heroku.com machines', async function () { + await AccountsModule.set('my-account', '/data/heroku') + + expect(fakeNetrc.machines['api.heroku.com']).to.deep.equal({login: 'user@example.com', password: 'secret'}) + expect(fakeNetrc.machines['git.heroku.com']).to.deep.equal({login: 'user@example.com', password: 'secret'}) + }) + + it('saves the netrc file', async function () { + await AccountsModule.set('my-account', '/data/heroku') + + expect(fakeNetrc.save.calledOnce).to.be.true + }) + }) + }) + describe('remove', function () { let unlinkStub: sinon.SinonStub let osHomeStub: sinon.SinonStub From 322a848b7f9e7e39d9d6a15b58e0aef6049890a6 Mon Sep 17 00:00:00 2001 From: Katy Bowman Date: Fri, 8 May 2026 13:57:44 -0400 Subject: [PATCH 3/4] test: update tests to work with Windows --- test/unit/commands/accounts/set.unit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/commands/accounts/set.unit.test.ts b/test/unit/commands/accounts/set.unit.test.ts index af8208a211..328c5d3efd 100644 --- a/test/unit/commands/accounts/set.unit.test.ts +++ b/test/unit/commands/accounts/set.unit.test.ts @@ -23,7 +23,8 @@ describe('accounts:set', function () { await runCommand(Cmd, ['test-account-2']) expect(setStub.calledOnce).to.be.true expect(setStub.firstCall.args[0]).to.equal('test-account-2') - expect(setStub.firstCall.args[1]).to.contain('.local/share/heroku') + expect(setStub.firstCall.args[1].toLowerCase()).to.contain('local') + expect(setStub.firstCall.args[1]).to.contain('heroku') }) it('calls set with the account name and dataDir when matched by username', async function () { @@ -31,7 +32,8 @@ describe('accounts:set', function () { await runCommand(Cmd, ['user1@example.com']) expect(setStub.calledOnce).to.be.true expect(setStub.firstCall.args[0]).to.equal('user1@example.com') - expect(setStub.firstCall.args[1]).to.contain('.local/share/heroku') + expect(setStub.firstCall.args[1].toLowerCase()).to.contain('local') + expect(setStub.firstCall.args[1]).to.contain('heroku') }) it('returns an error if the account is not in the list', async function () { From 968997096794eca755221f4ffaa66032448a3f71 Mon Sep 17 00:00:00 2001 From: Katy Bowman Date: Fri, 8 May 2026 17:04:38 -0400 Subject: [PATCH 4/4] test: fix git credentials tests --- src/commands/git/credentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/git/credentials.ts b/src/commands/git/credentials.ts index 8a6a30a2fe..4523538d1d 100644 --- a/src/commands/git/credentials.ts +++ b/src/commands/git/credentials.ts @@ -38,6 +38,7 @@ export class GitCredentials extends Command { }) rl.on('close', () => { + process.stdin.pause() resolve(input) }) })