heroku 11.5.0-beta.0 → 11.6.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,74 @@ All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
6
 
7
- ## [11.5.0-beta.0](https://github.com/heroku/cli/compare/v11.4.0...v11.5.0-beta.0) (2026-06-11)
7
+ ## [11.6.0-beta.0](https://github.com/heroku/cli/compare/v11.5.0...v11.6.0-beta.0) (2026-06-15)
8
+
9
+
10
+ ### Features
11
+
12
+ * add alias files for keychain accounts ([e1c572c](https://github.com/heroku/cli/commit/e1c572c942386b519b6ab63aac52c5ed56060d05))
13
+ * add getAliasEmail helper method ([bb53b87](https://github.com/heroku/cli/commit/bb53b87479d3009518c02420027da1dc7b8ddace))
14
+ * add listAliasFiles helper method ([01da5e4](https://github.com/heroku/cli/commit/01da5e44ae9c7257fddb27566fb3b3c02f7c8444))
15
+ * adds alias support for keychain-based accounts ([#3752](https://github.com/heroku/cli/issues/3752)) ([c5d1a45](https://github.com/heroku/cli/commit/c5d1a450601405b44b23648593b498bf61de2b20))
16
+ * adds credential manager functionality to the CLI ([#3758](https://github.com/heroku/cli/issues/3758)) ([ad0466e](https://github.com/heroku/cli/commit/ad0466e1512b902ee454fc55d4a5abdf87eaab12))
17
+ * implement heroku git:credentials as a git credential helper ([#3683](https://github.com/heroku/cli/issues/3683)) ([781e99e](https://github.com/heroku/cli/commit/781e99e1b9c56339b1245e30087aa9b312c10e69))
18
+ * prevent duplicate aliases for same email in accounts:add ([c094aa6](https://github.com/heroku/cli/commit/c094aa61670a0beaa15d3c8a31e0839f082c50d5))
19
+ * remove alias files for keychain and netrc accounts ([0d3b417](https://github.com/heroku/cli/commit/0d3b4175391b48ae84a712f9f86743a85ff2efbd))
20
+ * remove cached netrc account on logout ([#3710](https://github.com/heroku/cli/issues/3710)) ([b07137b](https://github.com/heroku/cli/commit/b07137bed5282a1618ae7e2b1e7f603eeb9a70be))
21
+ * update accounts and accounts:current commands to use the credential manager ([#3689](https://github.com/heroku/cli/issues/3689)) ([e753d06](https://github.com/heroku/cli/commit/e753d06407fe8ebf6f87b25131d6198d78e824e8))
22
+ * update accounts:add description for clarity ([a34d687](https://github.com/heroku/cli/commit/a34d687ecee13d4db831ef39011c9698d676bcb3))
23
+ * update accounts:add to use the credential manager ([#3699](https://github.com/heroku/cli/issues/3699)) ([49e3938](https://github.com/heroku/cli/commit/49e3938f44ec52451870cf24c692d440a7a69701))
24
+ * update accounts:remove to work with credential manager ([#3701](https://github.com/heroku/cli/issues/3701)) ([89d7b14](https://github.com/heroku/cli/commit/89d7b148df950429f902691c8348afc5bba07454))
25
+ * update accounts:set to work with keychain managers ([#3696](https://github.com/heroku/cli/issues/3696)) ([1f896aa](https://github.com/heroku/cli/commit/1f896aab4b23fb312d444420a06ed9d839ec8cf4))
26
+ * update list() to merge keychain accounts with alias files ([940bb6f](https://github.com/heroku/cli/commit/940bb6f89326e26e176b199db8fe0defa141c29d))
27
+ * update remove() to resolve alias before keychain removal ([92e2fe1](https://github.com/heroku/cli/commit/92e2fe133ca2d52f4c0b744c0096be2196fb106b))
28
+ * update set() to handle aliased keychain accounts ([114d3a7](https://github.com/heroku/cli/commit/114d3a7740c7f1c10e1425c5d9c7613811182658))
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * allow switching to netrc mode with keychain-created aliases ([386bd16](https://github.com/heroku/cli/commit/386bd163b31cb2899d7d17332e79b0e68d63a5c0))
34
+ * fix linting errors in accounts lib file ([a8368d6](https://github.com/heroku/cli/commit/a8368d696dc91552ffef901da286e003a3091596))
35
+ * fix whoami and update heroku-cli-command and heroku-cli-util ([#3719](https://github.com/heroku/cli/issues/3719)) ([7db768f](https://github.com/heroku/cli/commit/7db768f21f0dbe04d8d422f93919bc4f6f9156c5))
36
+ * improves consistency between keychain and netrc accounts actions ([#3762](https://github.com/heroku/cli/issues/3762)) ([76d24b8](https://github.com/heroku/cli/commit/76d24b88247091325a59d37d77683a512e6f1724))
37
+ * make getAliasEmail test cross-platform compatible ([0f5b958](https://github.com/heroku/cli/commit/0f5b958f4782cf2ec5b5cb53812bdd636cb436e9))
38
+ * prevent cross-mode account removal with helpful error messages ([efa00a3](https://github.com/heroku/cli/commit/efa00a3e473eb2bb9be5a96f0412dced0851ec89))
39
+ * prevent password from being written to keychain alias files ([4b0e92d](https://github.com/heroku/cli/commit/4b0e92d1045dc649c5b5d92b649fa9d8d613e267))
40
+ * remove support for non-aliased accounts from heroku accounts and accounts:current commands ([9509171](https://github.com/heroku/cli/commit/950917138576dfae869bf5ef2930d9a159e7808b))
41
+ * restore git configuration on logout ([#3697](https://github.com/heroku/cli/issues/3697)) ([5479fa5](https://github.com/heroku/cli/commit/5479fa53c75b152aece718999deee50b94b1f5ce))
42
+ * update accounts:remove to check for email or alias to validate current account ([969afab](https://github.com/heroku/cli/commit/969afabbfaa46bfdaf37cac7eab854440fbcd601))
43
+ * update error text for accounts commands ([a15d8bd](https://github.com/heroku/cli/commit/a15d8bd1f8b86b802b9c74efccf3de1136cc3483))
44
+ * update error text for accounts commands ([#3756](https://github.com/heroku/cli/issues/3756)) ([35350e8](https://github.com/heroku/cli/commit/35350e89b86080fd415ad0c4c20928695fbac42b))
45
+ * use HEROKU_CLI_CHANNEL in Direwolf test trigger ([#3765](https://github.com/heroku/cli/issues/3765)) ([62be38b](https://github.com/heroku/cli/commit/62be38b378e29a97246cc41c500d633fa8bf407c))
46
+
47
+
48
+ ### Miscellaneous Chores
49
+
50
+ * fix linting errors ([7df4bf0](https://github.com/heroku/cli/commit/7df4bf076497d70c4c0ab3ae672598853f761247))
51
+ * merge in main to feature branch ([#3747](https://github.com/heroku/cli/issues/3747)) ([8c100fd](https://github.com/heroku/cli/commit/8c100fd4667a88e414a4c9379ff896792ef07498))
52
+ * merge main ([#3737](https://github.com/heroku/cli/issues/3737)) ([cb751e6](https://github.com/heroku/cli/commit/cb751e6a23d11baf44cd6111f7a1741e8477c06d))
53
+ * merge main ([#3757](https://github.com/heroku/cli/issues/3757)) ([07a78a3](https://github.com/heroku/cli/commit/07a78a3d8023bd56c9b2935d98ffad61925be803))
54
+ * update CLI analytics to use heroku credential manager ([#3685](https://github.com/heroku/cli/issues/3685)) ([5f86e4c](https://github.com/heroku/cli/commit/5f86e4cf9a6b8ca74d6ef0650abbae1f940fab7d))
55
+
56
+
57
+ ### Code Refactoring
58
+
59
+ * add accountsDir() helper to reduce code duplication ([efff0f5](https://github.com/heroku/cli/commit/efff0f5acd9cd4554ba74eb96d611abd9f44c830))
60
+ * always write to netrc and remove non-aliased account support from accounts:set ([1a6fdb1](https://github.com/heroku/cli/commit/1a6fdb1fd70d37fb40bd44e8761b1868cc7df59f))
61
+ * clean up accounts commands and lib function ([b2619ac](https://github.com/heroku/cli/commit/b2619ac45d8ee85469851c90274105515c7c9f09))
62
+ * extract Ruby symbol conversion to helper method ([dae3901](https://github.com/heroku/cli/commit/dae390196a0c3654cc8be22126bb48bedb022ed0))
63
+ * remove cross-mode validation from accounts management ([aba7fa5](https://github.com/heroku/cli/commit/aba7fa5cb213ef6b54a078b1d28beafefe9b79b3))
64
+ * simplify account lookup in set command ([c540bec](https://github.com/heroku/cli/commit/c540bec1ab5829e683f5296baa0806b427439605))
65
+ * simplify accounts add to always write username and password ([5be6ed5](https://github.com/heroku/cli/commit/5be6ed5987146486e18bda5570d92ea461686b80))
66
+ * simplify add() method with writeAccountFile helper ([b15db3a](https://github.com/heroku/cli/commit/b15db3ae0abf1ad92b20d14fb62c72039b1488c2))
67
+
68
+
69
+ ### Tests
70
+
71
+ * fix accounts, apps, auth, buildpacks, container, git, and ps-exec tests ([391b6a6](https://github.com/heroku/cli/commit/391b6a6cff1cb754697db642f1c11464a8de6de3))
72
+ * update analytics tests to use the credential manager ([#3688](https://github.com/heroku/cli/issues/3688)) ([4fde394](https://github.com/heroku/cli/commit/4fde394ac2351504a727829fd86fd647136afbc8))
73
+
74
+ ## [11.5.0](https://github.com/heroku/cli/compare/v11.4.0...v11.5.0) (2026-06-11)
8
75
 
9
76
 
10
77
  ### Features
@@ -4,26 +4,24 @@ import { Args, ux } from '@oclif/core';
4
4
  import AccountsModule from '../../lib/accounts/accounts.js';
5
5
  export default class Add extends Command {
6
6
  static args = {
7
- name: Args.string({ description: 'name of Heroku account to add', required: true }),
7
+ name: Args.string({ description: 'alias for Heroku account to add', required: true }),
8
8
  };
9
- static description = 'add a Heroku account to your cache';
9
+ static description = 'add the current Heroku account to your accounts cache';
10
10
  static example = `${color.command('heroku accounts:add my-account')}`;
11
11
  async run() {
12
12
  const { args } = await this.parse(Add);
13
13
  const { name } = args;
14
- const logInMessage = 'You must be logged in to run this command.';
15
- if (AccountsModule.list().some(a => a.name === name)) {
14
+ const accounts = await AccountsModule.list();
15
+ if (accounts.some(account => account.name === name)) {
16
16
  ux.error(`${name} already exists`);
17
17
  }
18
18
  const { body: account } = await this.heroku.get('/account');
19
- const email = account.email || '';
20
- const token = this.heroku.auth || '';
21
- if (token === '') {
22
- ux.error(logInMessage);
23
- }
24
- if (email === '') {
25
- ux.error(logInMessage);
19
+ const email = account.email;
20
+ const existingAlias = accounts.find(account => account.name && account.username === email);
21
+ if (existingAlias) {
22
+ ux.error(`Account ${email} already has an alias of ${existingAlias.name}.`);
26
23
  }
24
+ const token = this.heroku.auth;
27
25
  AccountsModule.add(name, email, token);
28
26
  }
29
27
  }
@@ -8,12 +8,13 @@ export default class Current extends Command {
8
8
  static example = `${color.command('heroku accounts:current')}`;
9
9
  static promptFlagActive = false;
10
10
  async run() {
11
- const accountName = await AccountsModule.current();
11
+ const accountName = await AccountsModule.current(this.heroku);
12
12
  if (accountName) {
13
13
  hux.styledHeader(`Current account is ${color.user(accountName)}`);
14
14
  }
15
15
  else {
16
- 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.`);
16
+ ux.error(`You haven't set an account.\n
17
+ 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.`);
17
18
  }
18
19
  }
19
20
  }
@@ -8,16 +8,17 @@ export default class AccountsIndex extends Command {
8
8
  static example = `${color.command('heroku accounts')}`;
9
9
  static promptFlagActive = false;
10
10
  async run() {
11
- const accounts = accountsModule.list();
11
+ const accounts = await accountsModule.list();
12
12
  if (accounts.length === 0) {
13
13
  ux.error('You don\'t have any accounts in your cache.');
14
14
  }
15
+ const current = await accountsModule.current(this.heroku);
15
16
  for (const account of accounts) {
16
- if (account.name === await accountsModule.current()) {
17
- ux.stdout(`* ${account.name}`);
17
+ if (account.name === current || account.username === current) {
18
+ ux.stdout(`* ${account.name ?? account.username}`);
18
19
  }
19
20
  else {
20
- ux.stdout(` ${account.name}`);
21
+ ux.stdout(` ${account.name ?? account.username}`);
21
22
  }
22
23
  }
23
24
  }
@@ -11,12 +11,16 @@ export default class Remove extends Command {
11
11
  async run() {
12
12
  const { args } = await this.parse(Remove);
13
13
  const { name } = args;
14
- if (!AccountsModule.list().some(a => a.name === name)) {
14
+ const accounts = await AccountsModule.list();
15
+ const account = accounts.find(a => a.name === name || a.username === name);
16
+ if (!account) {
15
17
  ux.error(`${name} doesn't exist in your accounts cache.`);
16
18
  }
17
- if (await AccountsModule.current() === name) {
18
- ux.error(`${name} is the current account.`);
19
+ const currentAccount = await AccountsModule.current(this.heroku);
20
+ // Check both alias (name) and email (username) against current account
21
+ if (currentAccount === name || currentAccount === account.username) {
22
+ ux.error(`${name} is the current account. To log out, run ${color.command('heroku logout')}.`);
19
23
  }
20
- AccountsModule.remove(name);
24
+ await AccountsModule.remove(name);
21
25
  }
22
26
  }
@@ -4,16 +4,18 @@ import { Args, ux } from '@oclif/core';
4
4
  import AccountsModule from '../../lib/accounts/accounts.js';
5
5
  export default class Set extends Command {
6
6
  static args = {
7
- name: Args.string({ description: 'name of account to set', required: true }),
7
+ name: Args.string({ description: 'name or username of account to set', required: true }),
8
8
  };
9
- static description = 'set the current Heroku account from your cache';
9
+ static description = 'set the current Heroku account from your accounts cache';
10
10
  static example = `${color.command('heroku accounts:set my-account')}`;
11
11
  async run() {
12
12
  const { args } = await this.parse(Set);
13
13
  const { name } = args;
14
- if (!AccountsModule.list().some(a => a.name === name)) {
15
- ux.error(`${name} does not exist in your accounts cache.`);
14
+ const accounts = await AccountsModule.list();
15
+ const account = accounts.find(account => account.name === name || account.username === name);
16
+ if (!account) {
17
+ ux.error(`${name} doesn't exist in your accounts cache.`);
16
18
  }
17
- AccountsModule.set(name);
19
+ await AccountsModule.set(account, this.config.dataDir);
18
20
  }
19
21
  }
@@ -69,6 +69,7 @@ async function configureGitRemote(context, app) {
69
69
  const remoteUrl = git.httpGitUrl(app.name || '');
70
70
  if (!context.flags['no-remote'] && git.inGitRepo()) {
71
71
  await git.createRemote(context.flags.remote || 'heroku', remoteUrl);
72
+ await git.configureCredentialHelper();
72
73
  }
73
74
  return remoteUrl;
74
75
  }
@@ -1,5 +1,6 @@
1
1
  import { Command, flags } from '@heroku-cli/command';
2
2
  import * as color from '@heroku/heroku-cli-util/color';
3
+ import Git from '../../lib/git/git.js';
3
4
  export default class Login extends Command {
4
5
  static aliases = ['login'];
5
6
  static description = 'login with your Heroku credentials';
@@ -17,6 +18,14 @@ export default class Login extends Command {
17
18
  await this.heroku.login({ browser: flags.browser, expiresIn: flags['expires-in'], method });
18
19
  const { body: account } = await this.heroku.get('/account', { retryAuth: false });
19
20
  this.log(`Logged in as ${color.user(account.email)}`);
21
+ const git = new Git();
22
+ try {
23
+ await git.configureCredentialHelper();
24
+ await git.eraseCredentials();
25
+ }
26
+ catch {
27
+ // ignore
28
+ }
20
29
  await this.config.runHook('recache', { type: 'login' });
21
30
  }
22
31
  }
@@ -1,14 +1,28 @@
1
1
  import { Command } from '@heroku-cli/command';
2
2
  import { ux } from '@oclif/core/ux';
3
+ import AccountsModule from '../../lib/accounts/accounts.js';
4
+ import Git from '../../lib/git/git.js';
3
5
  export default class Logout extends Command {
4
6
  static aliases = ['logout'];
5
7
  static baseFlags = Command.baseFlagsWithoutPrompt();
6
8
  static description = 'clears local login credentials and invalidates API session';
7
9
  static promptFlagActive = false;
8
10
  async run() {
9
- this.parse(Logout);
11
+ await this.parse(Logout);
10
12
  ux.action.start('Logging out');
13
+ const cachedNetrcAccount = await AccountsModule.currentNetrc();
11
14
  await this.heroku.logout();
15
+ const git = new Git();
16
+ try {
17
+ await git.removeCredentialHelper();
18
+ await git.eraseCredentials();
19
+ }
20
+ catch {
21
+ // ignore
22
+ }
23
+ if (cachedNetrcAccount) {
24
+ await AccountsModule.remove(cachedNetrcAccount);
25
+ }
12
26
  await this.config.runHook('recache', { type: 'logout' });
13
27
  ux.action.stop();
14
28
  }
@@ -17,7 +17,7 @@ export default class AuthWhoami extends Command {
17
17
  this.log(account.email);
18
18
  }
19
19
  catch (error) {
20
- if (error.statusCode === 401)
20
+ if (error.http.statusCode === 401)
21
21
  this.notloggedin();
22
22
  throw error;
23
23
  }
@@ -27,7 +27,7 @@ export default class CiConfigUnset extends Command {
27
27
  vars[iAmStr] = null;
28
28
  }
29
29
  await ux.action.start(`Unsetting ${Object.keys(vars).join(', ')}`);
30
- setPipelineConfigVars(this.heroku, pipeline.id, vars);
30
+ await setPipelineConfigVars(this.heroku, pipeline.id, vars);
31
31
  ux.action.stop();
32
32
  }
33
33
  }
@@ -24,5 +24,6 @@ remote: Counting objects: 42, done.
24
24
  const directory = args.DIRECTORY || app.name;
25
25
  const remote = flags.remote || 'heroku';
26
26
  await git.spawn(['clone', '-o', remote, git.url(app.name), directory]);
27
+ await git.configureCredentialHelper();
27
28
  }
28
29
  }
@@ -6,4 +6,10 @@ export declare class GitCredentials extends Command {
6
6
  static description: string;
7
7
  static hidden: boolean;
8
8
  run(): Promise<void>;
9
+ /**
10
+ * Reads git-credential input from stdin
11
+ * Format: key=value pairs, one per line, terminated by blank line
12
+ * Returns parsed object with protocol, host, username, and path
13
+ */
14
+ private readInput;
9
15
  }
@@ -1,5 +1,6 @@
1
- import { Command } from '@heroku-cli/command';
1
+ import { Command, vars } from '@heroku-cli/command';
2
2
  import { Args, ux } from '@oclif/core';
3
+ import * as readline from 'node:readline';
3
4
  export class GitCredentials extends Command {
4
5
  static args = {
5
6
  command: Args.string({ description: 'command name of the git credentials', required: true }),
@@ -16,10 +17,16 @@ export class GitCredentials extends Command {
16
17
  break;
17
18
  }
18
19
  case 'get': {
19
- if (!this.heroku.auth)
20
+ const { host, protocol } = await this.readInput();
21
+ const { httpGitHost } = vars;
22
+ if (protocol !== 'https' || host !== httpGitHost) {
23
+ return;
24
+ }
25
+ if (!this.heroku.auth) {
20
26
  throw new Error('not logged in');
27
+ }
21
28
  ux.stdout(`protocol=https
22
- host=git.heroku.com
29
+ host=${httpGitHost}
23
30
  username=heroku
24
31
  password=${this.heroku.auth}`);
25
32
  break;
@@ -29,4 +36,32 @@ password=${this.heroku.auth}`);
29
36
  }
30
37
  }
31
38
  }
39
+ /**
40
+ * Reads git-credential input from stdin
41
+ * Format: key=value pairs, one per line, terminated by blank line
42
+ * Returns parsed object with protocol, host, username, and path
43
+ */
44
+ async readInput() {
45
+ return new Promise(resolve => {
46
+ const rl = readline.createInterface({
47
+ input: process.stdin,
48
+ terminal: false,
49
+ });
50
+ const input = {};
51
+ rl.on('line', (line) => {
52
+ if (!line.trim()) {
53
+ rl.close();
54
+ return;
55
+ }
56
+ const [key, value] = line.split('=', 2);
57
+ if (key && value) {
58
+ input[key] = value;
59
+ }
60
+ });
61
+ rl.on('close', () => {
62
+ process.stdin.pause();
63
+ resolve(input);
64
+ });
65
+ });
66
+ }
32
67
  }
@@ -34,5 +34,6 @@ ${color.command('heroku git:remote --remote heroku-staging -a example-staging')}
34
34
  : git.exec(['remote', 'add', remote, url].concat(argv)));
35
35
  const newRemote = await git.remoteUrl(remote);
36
36
  this.log(`set git remote ${color.cyan(remote)} to ${color.cyan(newRemote)}`);
37
+ await git.configureCredentialHelper();
37
38
  }
38
39
  }
@@ -1,21 +1,35 @@
1
- import * as Heroku from '@heroku-cli/schema';
1
+ import { APIClient, getStorageConfig } from '@heroku-cli/command';
2
+ export interface AccountEntry {
3
+ name?: string;
4
+ username: string;
5
+ }
2
6
  export interface IAccountsWrapper {
3
7
  add(name: string, username: string, password: string): void;
4
- current(): Promise<null | string>;
5
- list(): [] | Heroku.Account[];
8
+ current(heroku: APIClient): Promise<null | string>;
9
+ currentNetrc(): Promise<null | string>;
10
+ getStorageConfig(): ReturnType<typeof getStorageConfig>;
11
+ list(): AccountEntry[];
6
12
  remove(name: string): void;
7
- set(name: string): Promise<void>;
13
+ set(account: AccountEntry, dataDir: string): Promise<void>;
14
+ writeLoginState(dataDir: string, name: string): Promise<void>;
8
15
  }
9
16
  export declare class AccountsWrapper implements IAccountsWrapper {
10
17
  private netrc;
11
18
  add(name: string, username: string, password: string): void;
12
- current(): Promise<null | string>;
13
- list(): [] | Heroku.Account[];
14
- remove(name: string): void;
15
- set(name: string): Promise<void>;
19
+ current(heroku: APIClient): Promise<null | string>;
20
+ currentNetrc(): Promise<null | string>;
21
+ getStorageConfig(): import("@heroku-cli/command").StorageConfig;
22
+ list(): AccountEntry[];
23
+ remove(name: string): Promise<void>;
24
+ set(account: AccountEntry, dataDir: string): Promise<void>;
25
+ writeLoginState(dataDir: string, name: string): Promise<void>;
16
26
  private account;
27
+ private accountsDir;
17
28
  private configDir;
29
+ private convertRubySymbols;
30
+ private getAliasEmail;
18
31
  private initNetrc;
32
+ private writeAccountFile;
19
33
  }
20
34
  declare const _default: AccountsWrapper;
21
35
  export default _default;
@@ -1,3 +1,5 @@
1
+ import { getStorageConfig, writeLoginState, } from '@heroku-cli/command';
2
+ import { removeAuth } from '@heroku-cli/command/lib/credential-manager.js';
1
3
  import fs from 'node:fs';
2
4
  import os from 'node:os';
3
5
  import path from 'node:path';
@@ -5,14 +7,20 @@ import { parse, stringify } from 'yaml';
5
7
  export class AccountsWrapper {
6
8
  netrc;
7
9
  add(name, username, password) {
8
- const basedir = path.join(this.configDir(), 'accounts');
9
- fs.mkdirSync(basedir, { recursive: true });
10
- fs.writeFileSync(path.join(basedir, name),
10
+ fs.mkdirSync(this.accountsDir(), { recursive: true });
11
11
  // eslint-disable-next-line perfectionist/sort-objects
12
- stringify({ username, password }), 'utf8');
13
- fs.chmodSync(path.join(basedir, name), 0o600);
12
+ this.writeAccountFile(name, { username, password });
14
13
  }
15
- async current() {
14
+ async current(heroku) {
15
+ const config = this.getStorageConfig();
16
+ if (config.credentialStore) {
17
+ const authEntry = await heroku.getAuthEntry();
18
+ const current = this.list().find(a => a.username === authEntry?.account);
19
+ return current && current.name ? current.name : null;
20
+ }
21
+ return this.currentNetrc();
22
+ }
23
+ async currentNetrc() {
16
24
  const netrcInstance = await this.initNetrc();
17
25
  if (netrcInstance.machines['api.heroku.com']) {
18
26
  const current = this.list().find(a => a.username === netrcInstance.machines['api.heroku.com'].login);
@@ -20,40 +28,66 @@ export class AccountsWrapper {
20
28
  }
21
29
  return null;
22
30
  }
31
+ getStorageConfig() {
32
+ return getStorageConfig();
33
+ }
23
34
  list() {
24
- const basedir = path.join(this.configDir(), 'accounts');
35
+ const basedir = this.accountsDir();
25
36
  try {
26
37
  return fs.readdirSync(basedir)
27
- .map(name => Object.assign(this.account(name), { name }));
38
+ .filter(name => this.account(name).username)
39
+ .map(name => ({ name, username: this.account(name).username }));
28
40
  }
29
41
  catch {
30
42
  return [];
31
43
  }
32
44
  }
33
- remove(name) {
34
- const basedir = path.join(this.configDir(), 'accounts');
35
- fs.unlinkSync(path.join(basedir, name));
45
+ async remove(name) {
46
+ const config = this.getStorageConfig();
47
+ if (config.credentialStore) {
48
+ // Keychain mode
49
+ const email = this.getAliasEmail(name);
50
+ await removeAuth(email, ['api.heroku.com', 'git.heroku.com']);
51
+ fs.unlinkSync(path.join(this.accountsDir(), name));
52
+ return;
53
+ }
54
+ // Netrc mode
55
+ fs.unlinkSync(path.join(this.accountsDir(), name));
36
56
  }
37
- async set(name) {
38
- const netrcInstance = await this.initNetrc();
39
- const current = this.account(name);
40
- netrcInstance.machines['git.heroku.com'] = { login: current.username, password: current.password };
41
- netrcInstance.machines['api.heroku.com'] = { login: current.username, password: current.password };
42
- await netrcInstance.save();
57
+ async set(account, dataDir) {
58
+ const config = this.getStorageConfig();
59
+ if (account.name) {
60
+ if (config.credentialStore) {
61
+ const email = this.getAliasEmail(account.name);
62
+ if (email) {
63
+ await this.writeLoginState(dataDir, email);
64
+ }
65
+ }
66
+ const netrcInstance = await this.initNetrc();
67
+ let current;
68
+ try {
69
+ current = this.account(account.name);
70
+ }
71
+ catch {
72
+ throw new Error(`We can't find the alias file for ${account.name}.`);
73
+ }
74
+ netrcInstance.machines['git.heroku.com'] = { login: current.username, password: current.password || '' };
75
+ netrcInstance.machines['api.heroku.com'] = { login: current.username, password: current.password || '' };
76
+ await netrcInstance.save();
77
+ }
78
+ }
79
+ async writeLoginState(dataDir, name) {
80
+ return writeLoginState(dataDir, name);
43
81
  }
44
82
  account(name) {
45
- const basedir = path.join(this.configDir(), 'accounts');
46
- const file = fs.readFileSync(path.join(basedir, name), 'utf8');
83
+ const file = fs.readFileSync(path.join(this.accountsDir(), name), 'utf8');
47
84
  const account = parse(file);
48
- if (account[':username']) {
49
- // convert from ruby symbols
50
- account.username = account[':username'];
51
- account.password = account[':password'];
52
- delete account[':username'];
53
- delete account[':password'];
54
- }
85
+ this.convertRubySymbols(account);
55
86
  return account;
56
87
  }
88
+ accountsDir() {
89
+ return path.join(this.configDir(), 'accounts');
90
+ }
57
91
  configDir() {
58
92
  const legacyDir = path.join(os.homedir(), '.heroku');
59
93
  if (fs.existsSync(legacyDir)) {
@@ -61,6 +95,29 @@ export class AccountsWrapper {
61
95
  }
62
96
  return path.join(os.homedir(), '.config', 'heroku');
63
97
  }
98
+ convertRubySymbols(account) {
99
+ if (account[':username']) {
100
+ account.username = account[':username'];
101
+ account.password = account[':password'];
102
+ delete account[':username'];
103
+ delete account[':password'];
104
+ }
105
+ }
106
+ getAliasEmail(alias) {
107
+ try {
108
+ const filePath = path.join(this.accountsDir(), alias);
109
+ if (!fs.existsSync(filePath)) {
110
+ return;
111
+ }
112
+ const file = fs.readFileSync(filePath, 'utf8');
113
+ const account = parse(file);
114
+ this.convertRubySymbols(account);
115
+ return account.username ?? undefined;
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
64
121
  async initNetrc() {
65
122
  if (!this.netrc) {
66
123
  const NetrcModule = await import('netrc-parser');
@@ -70,6 +127,11 @@ export class AccountsWrapper {
70
127
  }
71
128
  return this.netrc;
72
129
  }
130
+ writeAccountFile(name, content) {
131
+ const filePath = path.join(this.accountsDir(), name);
132
+ fs.writeFileSync(filePath, stringify(content), 'utf8');
133
+ fs.chmodSync(filePath, 0o600);
134
+ }
73
135
  }
74
136
  // Default export for convenience
75
137
  export default new AccountsWrapper();
@@ -26,15 +26,11 @@ export default class BackboardHerokulyticsClient {
26
26
  config: Interfaces.Config;
27
27
  http: ReturnType<typeof HTTP.create>;
28
28
  userConfig: HerokulyticsConfig;
29
+ private heroku;
29
30
  private isInitialized;
30
- private netrc;
31
31
  constructor(config: Interfaces.Config);
32
32
  get authorizationToken(): string | undefined;
33
- get netrcLogin(): string | undefined;
34
- get netrcToken(): string | undefined;
35
33
  get url(): string;
36
- get user(): string | undefined;
37
- get usingHerokuAPIKey(): boolean;
38
34
  _acAnalytics(id: string): Promise<number>;
39
35
  send(opts: RecordOpts): Promise<void>;
40
36
  private ensureInitialized;