Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ed3c524
deps: update heroku-cli/command to v13.0.0-beta.1 (#3660)
erika-wallace Apr 15, 2026
781e99e
feat: implement heroku git:credentials as a git credential helper (#3…
erika-wallace Apr 29, 2026
5f86e4c
chore: update CLI analytics to use heroku credential manager (#3685)
erika-wallace Apr 30, 2026
4fde394
test: update analytics tests to use the credential manager (#3688)
erika-wallace May 4, 2026
e753d06
feat: update accounts and accounts:current commands to use the creden…
k80bowman May 8, 2026
5479fa5
fix: restore git configuration on logout (#3697)
erika-wallace May 8, 2026
49e3938
feat: update accounts:add to use the credential manager (#3699)
erika-wallace May 8, 2026
89d7b14
feat: update accounts:remove to work with credential manager (#3701)
k80bowman May 12, 2026
1f896aa
feat: update accounts:set to work with keychain managers (#3696)
k80bowman May 12, 2026
b07137b
feat: remove cached netrc account on logout (#3710)
k80bowman May 14, 2026
7db768f
fix: fix whoami and update heroku-cli-command and heroku-cli-util (#3…
k80bowman May 21, 2026
40df61c
deps: update versions of heroku-cli/command and heroku/heroku-cli-uti…
k80bowman May 29, 2026
5eebe2a
Merge branch 'main' into k80/merge-main-5-29
k80bowman Jun 1, 2026
391b6a6
test: fix accounts, apps, auth, buildpacks, container, git, and ps-ex…
k80bowman Jun 2, 2026
7df4bf0
chore: fix linting errors
k80bowman Jun 2, 2026
cb751e6
chore: merge main (#3737)
k80bowman Jun 2, 2026
e18a4c9
Merge remote-tracking branch 'origin' into feat/credential-mgr-integr…
michaelmalave Jun 4, 2026
8c100fd
chore: merge in main to feature branch (#3747)
michaelmalave Jun 4, 2026
e1c572c
feat: add alias files for keychain accounts
k80bowman Jun 4, 2026
0d3b417
feat: remove alias files for keychain and netrc accounts
k80bowman Jun 4, 2026
bb53b87
feat: add getAliasEmail helper method
k80bowman Jun 5, 2026
01da5e4
feat: add listAliasFiles helper method
k80bowman Jun 5, 2026
940bb6f
feat: update list() to merge keychain accounts with alias files
k80bowman Jun 5, 2026
114d3a7
feat: update set() to handle aliased keychain accounts
k80bowman Jun 5, 2026
92e2fe1
feat: update remove() to resolve alias before keychain removal
k80bowman Jun 5, 2026
efff0f5
refactor: add accountsDir() helper to reduce code duplication
k80bowman Jun 5, 2026
dae3901
refactor: extract Ruby symbol conversion to helper method
k80bowman Jun 5, 2026
b15db3a
refactor: simplify add() method with writeAccountFile helper
k80bowman Jun 5, 2026
c540bec
refactor: simplify account lookup in set command
k80bowman Jun 5, 2026
4b0e92d
fix: prevent password from being written to keychain alias files
k80bowman Jun 5, 2026
c094aa6
feat: prevent duplicate aliases for same email in accounts:add
k80bowman Jun 5, 2026
386bd16
fix: allow switching to netrc mode with keychain-created aliases
k80bowman Jun 5, 2026
efa00a3
fix: prevent cross-mode account removal with helpful error messages
k80bowman Jun 5, 2026
a34d687
feat: update accounts:add description for clarity
k80bowman Jun 5, 2026
a8368d6
fix: fix linting errors in accounts lib file
k80bowman Jun 5, 2026
0f5b958
fix: make getAliasEmail test cross-platform compatible
k80bowman Jun 5, 2026
969afab
fix: update accounts:remove to check for email or alias to validate c…
k80bowman Jun 8, 2026
c5d1a45
feat: adds alias support for keychain-based accounts (#3752)
k80bowman Jun 8, 2026
a15d8bd
fix: update error text for accounts commands
k80bowman Jun 8, 2026
35350e8
fix: update error text for accounts commands (#3756)
k80bowman Jun 8, 2026
9679ca2
Merge branch 'main' into k80/merge-main-6-8-26
k80bowman Jun 8, 2026
07a78a3
chore: merge main (#3757)
k80bowman Jun 8, 2026
9509171
fix: remove support for non-aliased accounts from heroku accounts and…
k80bowman Jun 10, 2026
5be6ed5
refactor: simplify accounts add to always write username and password
k80bowman Jun 10, 2026
aba7fa5
refactor: remove cross-mode validation from accounts management
k80bowman Jun 10, 2026
b2619ac
refactor: clean up accounts commands and lib function
k80bowman Jun 10, 2026
1a6fdb1
refactor: always write to netrc and remove non-aliased account suppor…
k80bowman Jun 10, 2026
76d24b8
fix: improves consistency between keychain and netrc accounts actions…
k80bowman Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
branches:
- main
- v11.0.0
- feat/credential-mgr-integration
# Add any long-lived feature branches here
pull_request:
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions cspell-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ autoloaded
autoscale
autovacuum
awscli
badheaders
barsize
baseport
bindkey
Expand Down
2,312 changes: 250 additions & 2,062 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
"bin": "./bin/run.js",
"bugs": "https://github.com/heroku/cli/issues",
"dependencies": {
"@heroku-cli/command": "^12.3.3",
"@heroku-cli/command": "12.4.0",
"@heroku-cli/notifications": "^1.2.6",
"@heroku-cli/schema": "^1.0.25",
"@heroku/buildpack-registry": "^1.0.1",
"@heroku/heroku-cli-util": "^10.8.0",
"@heroku/heroku-cli-util": "10.8.1",
"@heroku/http-call": "^5.5.1",
"@heroku/mcp-server": "^1.2.0",
"@heroku/socksv5": "^0.0.9",
Expand Down
21 changes: 9 additions & 12 deletions src/commands/accounts/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,29 @@ import AccountsModule from '../../lib/accounts/accounts.js'

export default class Add extends Command {
static args = {
name: Args.string({description: 'name of Heroku account to add', required: true}),
name: Args.string({description: 'alias for Heroku account to add', required: true}),
}
static description = 'add a Heroku account to your cache'
static description = 'add the current Heroku account to your accounts cache'
static example = `${color.command('heroku accounts:add my-account')}`

async run() {
const {args} = await this.parse(Add)
const {name} = args
const logInMessage = 'You must be logged in to run this command.'
const accounts = await AccountsModule.list()

if (AccountsModule.list().some(a => a.name === name)) {
if (accounts.some(account => account.name === name)) {
ux.error(`${name} already exists`)
}

const {body: account} = await this.heroku.get<Heroku.Account>('/account')
const email = account.email || ''
const email = account.email!

const token = this.heroku.auth || ''

if (token === '') {
ux.error(logInMessage)
const existingAlias = accounts.find(account => account.name && account.username === email)
if (existingAlias) {
ux.error(`Account ${email} already has an alias of ${existingAlias.name}.`)
}

if (email === '') {
ux.error(logInMessage)
}
const token = this.heroku.auth!

AccountsModule.add(name, email, token)
}
Expand Down
5 changes: 3 additions & 2 deletions src/commands/accounts/current.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ export default class Current extends Command {
static promptFlagActive = false

async run() {
const accountName = await AccountsModule.current()
const accountName = await AccountsModule.current(this.heroku)
if (accountName) {
hux.styledHeader(`Current account is ${color.user(accountName)}`)
} else {
ux.error(`You haven't set an account. Run ${color.code('heroku accounts:add <account-name>')} to add an account to your cache or ${color.code('heroku accounts:set <account-name>')} to set the current account.`)
ux.error(`You haven't set an account.\n
Run ${color.code('heroku accounts:add <account-name>')} to add an account to your cache or ${color.code('heroku accounts:set <account-name>')} to set the current account.`)
}
}
}
9 changes: 5 additions & 4 deletions src/commands/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ export default class AccountsIndex extends Command {
static promptFlagActive = false

async run() {
const accounts = accountsModule.list()
const accounts = await accountsModule.list()
if (accounts.length === 0) {
ux.error('You don\'t have any accounts in your cache.')
}

const current = await accountsModule.current(this.heroku)
for (const account of accounts) {
if (account.name === await accountsModule.current()) {
ux.stdout(`* ${account.name}`)
if (account.name === current || account.username === current) {
ux.stdout(`* ${account.name ?? account.username}`)
} else {
ux.stdout(` ${account.name}`)
ux.stdout(` ${account.name ?? account.username}`)
}
}
}
Expand Down
13 changes: 9 additions & 4 deletions src/commands/accounts/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@ export default class Remove extends Command {
const {args} = await this.parse(Remove)
const {name} = args

if (!AccountsModule.list().some(a => a.name === name)) {
const accounts = await AccountsModule.list()
const account = accounts.find(a => a.name === name || a.username === name)

if (!account) {
ux.error(`${name} doesn't exist in your accounts cache.`)
}

if (await AccountsModule.current() === name) {
ux.error(`${name} is the current account.`)
const currentAccount = await AccountsModule.current(this.heroku)
// Check both alias (name) and email (username) against current account
if (currentAccount === name || currentAccount === account.username) {
ux.error(`${name} is the current account. To log out, run ${color.command('heroku logout')}.`)
}

AccountsModule.remove(name)
await AccountsModule.remove(name)
}
}
13 changes: 8 additions & 5 deletions src/commands/accounts/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ 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'
static example = `${color.command('heroku accounts:set my-account')}`

async run() {
const {args} = await this.parse(Set)
const {name} = args

if (!AccountsModule.list().some(a => a.name === name)) {
ux.error(`${name} does not exist in your accounts cache.`)
const accounts = await AccountsModule.list()
const account = accounts.find(account => account.name === name || account.username === name)

if (!account) {
ux.error(`${name} doesn't exist in your accounts cache.`)
}

AccountsModule.set(name)
await AccountsModule.set(account, this.config.dataDir)
}
}
1 change: 1 addition & 0 deletions src/commands/apps/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async function configureGitRemote(context: Interfaces.ParserOutput, app: Heroku.
const remoteUrl = git.httpGitUrl(app.name || '')
if (!context.flags['no-remote'] && git.inGitRepo()) {
await git.createRemote(context.flags.remote || 'heroku', remoteUrl)
await git.configureCredentialHelper()
}

return remoteUrl
Expand Down
11 changes: 11 additions & 0 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import * as color from '@heroku/heroku-cli-util/color'

import Git from '../../lib/git/git.js'

export default class Login extends Command {
static aliases = ['login']
static description = 'login with your Heroku credentials'
Expand All @@ -19,6 +21,15 @@ export default class Login extends Command {
await this.heroku.login({browser: flags.browser, expiresIn: flags['expires-in'], method})
const {body: account} = await this.heroku.get<Heroku.Account>('/account', {retryAuth: false})
this.log(`Logged in as ${color.user(account.email!)}`)

const git = new Git()
try {
await git.configureCredentialHelper()
await git.eraseCredentials()
} catch {
// ignore
}

await this.config.runHook('recache', {type: 'login'})
}
}
20 changes: 18 additions & 2 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@

import {Command} from '@heroku-cli/command'
import {ux} from '@oclif/core/ux'

import AccountsModule from '../../lib/accounts/accounts.js'
import Git from '../../lib/git/git.js'

export default class Logout extends Command {
static aliases = ['logout']
static baseFlags = Command.baseFlagsWithoutPrompt()
static description = 'clears local login credentials and invalidates API session'
static promptFlagActive = false

async run() {
this.parse(Logout)
await this.parse(Logout)

ux.action.start('Logging out')
const cachedNetrcAccount = await AccountsModule.currentNetrc()
await this.heroku.logout()

const git = new Git()
try {
await git.removeCredentialHelper()
await git.eraseCredentials()
} catch {
// ignore
}

if (cachedNetrcAccount) {
await AccountsModule.remove(cachedNetrcAccount)
}

await this.config.runHook('recache', {type: 'logout'})
ux.action.stop()
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class AuthWhoami extends Command {
const {body: account} = await this.heroku.get<Heroku.Account>('/account', {retryAuth: false})
this.log(account.email)
} catch (error: any) {
if (error.statusCode === 401) this.notloggedin()
if (error.http.statusCode === 401) this.notloggedin()
throw error
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/ci/config/unset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class CiConfigUnset extends Command {

await ux.action.start(`Unsetting ${Object.keys(vars).join(', ')}`)

setPipelineConfigVars(this.heroku, pipeline.id, vars)
await setPipelineConfigVars(this.heroku, pipeline.id, vars)

ux.action.stop()
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/git/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ remote: Counting objects: 42, done.
const directory = args.DIRECTORY || (app.name as string)
const remote = flags.remote || 'heroku'
await git.spawn(['clone', '-o', remote, git.url(app.name!), directory])
await git.configureCredentialHelper()
}
}
52 changes: 48 additions & 4 deletions src/commands/git/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Command} from '@heroku-cli/command'
import {Command, vars} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as readline from 'node:readline'

export class GitCredentials extends Command {
static args = {
Expand All @@ -14,14 +15,24 @@ export class GitCredentials extends Command {
case 'erase':
// eslint-ignore-next-line no-fallthrough
case 'store': {
// ignore
// ignore
break
}

case 'get': {
if (!this.heroku.auth) throw new Error('not logged in')
const {host, protocol} = await this.readInput()

const {httpGitHost} = vars
if (protocol !== 'https' || host !== httpGitHost) {
return
}

if (!this.heroku.auth) {
throw new Error('not logged in')
}

ux.stdout(`protocol=https
host=git.heroku.com
host=${httpGitHost}
username=heroku
password=${this.heroku.auth}`)
break
Expand All @@ -32,4 +43,37 @@ password=${this.heroku.auth}`)
}
}
}

/**
* Reads git-credential input from stdin
* Format: key=value pairs, one per line, terminated by blank line
* Returns parsed object with protocol, host, username, and path
*/
private async readInput(): Promise<{host?: string; path?: string; protocol?: string; username?: string;}> {
return new Promise(resolve => {
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
})

const input: Record<string, string> = {}

rl.on('line', (line: string) => {
if (!line.trim()) {
rl.close()
return
}

const [key, value] = line.split('=', 2)
if (key && value) {
input[key] = value
}
})

rl.on('close', () => {
process.stdin.pause()
resolve(input)
})
})
}
}
2 changes: 2 additions & 0 deletions src/commands/git/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ ${color.command('heroku git:remote --remote heroku-staging -a example-staging')}

const newRemote = await git.remoteUrl(remote)
this.log(`set git remote ${color.cyan(remote)} to ${color.cyan(newRemote)}`)

await git.configureCredentialHelper()
}
}
Loading
Loading