Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@heroku-cli/command

Package Overview
Dependencies
Maintainers
49
Versions
128
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@heroku-cli/command - npm Package Compare versions

Comparing version
12.3.3
to
12.4.0-beta.0
+50
lib/credential-man.../credential-handlers/linux-handler.d.ts
import { KeychainAuthEntry } from '../lib/types.js';
/**
* Handles credential storage, removal, and retrieval using the Linux Secret Service API.
* Uses the secret-tool command-line utility (part of libsecret) to interact with desktop keyrings.
*/
export declare class LinuxHandler {
private readonly scrubber;
/**
* Retrieves the authentication token from the Linux keyring.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account: string, service: string): string;
/**
* Lists all accounts stored in the Linux keyring for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service: string): string[];
/**
* Removes the authentication token from the Linux keyring.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account: string, service: string): void;
/**
* Saves an authentication entry to the Linux keyring.
* If a credential with the same attributes already exists, it is updated with the new token.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth: KeychainAuthEntry): void;
/**
* secret-tool clear fails when no matching credential exists; treat as successful no-op for logout.
*/
private isMissingSecretClearFailure;
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
private scrubError;
}
import { Scrubber } from '@heroku/js-blanket';
import childProcess from 'node:child_process';
/**
* Handles credential storage, removal, and retrieval using the Linux Secret Service API.
* Uses the secret-tool command-line utility (part of libsecret) to interact with desktop keyrings.
*/
export class LinuxHandler {
scrubber = new Scrubber({
patterns: [
/account\s+"[^"]*"/g, // Scrub account value
],
});
/**
* Retrieves the authentication token from the Linux keyring.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account, service) {
try {
const output = childProcess.execSync(`secret-tool lookup service "${service}" account "${account}"`, { encoding: 'utf8' });
const token = output.trim();
if (!token) {
throw new Error('Token not found');
}
return token;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to retrieve token from Linux keyring: ${this.scrubError(message)}`);
}
}
/**
* Lists all accounts stored in the Linux keyring for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service) {
try {
const spawnResult = childProcess.spawnSync('secret-tool', ['search', '--all', 'service', service], { encoding: 'utf8' });
if (spawnResult.error) {
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = spawnResult.stderr || 'Unknown error';
throw new Error(stderr);
}
/*
* Expected output format:
* stdout: label, secret, created, modified, schema lines
* stderr: attribute.service / attribute.account lines
*/
const accounts = [];
const lines = (spawnResult.stderr ?? '').split('\n');
for (const line of lines) {
const match = line.trim().match(/^attribute\.account\s*=\s*(.+)$/);
if (match) {
const account = match[1].trim();
if (account) {
accounts.push(account);
}
}
}
return accounts;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to list accounts in Linux keyring: ${this.scrubError(message)}`);
}
}
/**
* Removes the authentication token from the Linux keyring.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account, service) {
const spawnResult = childProcess.spawnSync('secret-tool', ['clear', 'service', service, 'account', account], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } });
if (spawnResult.error) {
const { message } = spawnResult.error;
throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(message)}`);
}
if (spawnResult.status === 0) {
return;
}
const status = spawnResult.status ?? -1;
const stderr = (spawnResult.stderr ?? '').toString();
if (this.isMissingSecretClearFailure(status, stderr)) {
return;
}
throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(stderr || `exit ${status}`)}`);
}
/**
* Saves an authentication entry to the Linux keyring.
* If a credential with the same attributes already exists, it is updated with the new token.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth) {
try {
const spawnResult = childProcess.spawnSync('secret-tool', [
'store',
'--label=Heroku CLI',
'service',
auth.service,
'account',
auth.account,
], {
encoding: 'utf8',
input: auth.token,
});
if (spawnResult.error) {
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = spawnResult.stderr || 'Unknown error';
throw new Error(stderr);
}
}
catch (error) {
const { message } = error;
throw new Error(`Failed to store token in Linux keyring: ${this.scrubError(message)}`);
}
}
/**
* secret-tool clear fails when no matching credential exists; treat as successful no-op for logout.
*/
isMissingSecretClearFailure(status, stderr) {
if (status === 0) {
return false;
}
// secret-tool clear exits 1 with no output when nothing matched (locale-independent).
if (status === 1 && stderr.trim() === '') {
return true;
}
const text = stderr.toLowerCase();
return /no matching|could not find|not found|unknown attribute|does not exist|cannot remove/i.test(text);
}
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
scrubError(message) {
const result = this.scrubber.scrub({ message });
return result.data.message;
}
}
import { KeychainAuthEntry } from '../lib/types.js';
/**
* Handles credential storage, removal, and retrieval using the macOS Keychain.
* Uses the macOS security command-line tool to interact with the Keychain.
*/
export declare class MacOSHandler {
private readonly scrubber;
/**
* Retrieves the authentication token from macOS Keychain.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account: string, service: string): string;
/**
* Lists all accounts stored in macOS Keychain for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service: string): string[];
/**
* Removes the authentication token from macOS Keychain.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account: string, service: string): void;
/**
* Saves an authentication entry to macOS Keychain.
* If a credential with the same name already exists, it is updated with the new token.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth: KeychainAuthEntry): void;
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
private scrubError;
}
import { Scrubber } from '@heroku/js-blanket';
import childProcess from 'node:child_process';
/**
* Handles credential storage, removal, and retrieval using the macOS Keychain.
* Uses the macOS security command-line tool to interact with the Keychain.
*/
export class MacOSHandler {
scrubber = new Scrubber({
patterns: [
/-a\s+"[^"]*"/g, // Scrub account (-a flag)
/-w\s+"[^"]*"/g, // Scrub password/token (-w flag)
],
});
/**
* Retrieves the authentication token from macOS Keychain.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account, service) {
try {
const output = childProcess.execSync(`security find-generic-password -a "${account}" -s "${service}" -w`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
const token = output.trim();
if (!token) {
throw new Error('Token not found');
}
return token;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to retrieve token from macOS Keychain: ${this.scrubError(message)}`);
}
}
/**
* Lists all accounts stored in macOS Keychain for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service) {
try {
const output = childProcess.execSync('security dump-keychain', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
// Expected output format:
// keychain: "/path/to/keychain"
// version: 512
// class: "genp"
// attributes:
// 0x00000007 <blob>="service-name"
// "acct"<blob>="account-name"
// "svce"<blob>="service-name"
// ...
const accounts = [];
// Split by keychain entry boundaries
const entries = output.split(/^keychain:/m);
for (const entry of entries) {
// Only process generic password entries
if (!entry.includes('class: "genp"'))
continue;
// Extract service name
const serviceMatch = entry.match(/"svce"<blob>="([^"]+)"/);
if (!serviceMatch || serviceMatch[1] !== service)
continue;
// Extract account name
const accountMatch = entry.match(/"acct"<blob>="([^"]+)"/);
if (accountMatch) {
accounts.push(accountMatch[1]);
}
}
return accounts;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to list accounts in macOS Keychain: ${this.scrubError(message)}`);
}
}
/**
* Removes the authentication token from macOS Keychain.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account, service) {
try {
childProcess.execSync(`security delete-generic-password -a "${account}" -s "${service}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
}
catch (error) {
const execError = error;
// security exits 44 when the generic password does not exist (e.g. netrc-only login)
if (execError.status === 44) {
return;
}
const { message } = error;
throw new Error(`Failed to remove token from macOS Keychain: ${this.scrubError(message)}`);
}
}
/**
* Saves an authentication entry to macOS Keychain.
* If a credential with the same name already exists, it is updated with the new token.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth) {
try {
childProcess.execSync(`security add-generic-password -U -a "${auth.account}" -s "${auth.service}" -w "${auth.token}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
}
catch (error) {
const { message } = error;
throw new Error(`Failed to store token in macOS Keychain: ${this.scrubError(message)}`);
}
}
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
scrubError(message) {
const result = this.scrubber.scrub({ message });
return result.data.message;
}
}
import { Netrc } from '../lib/netrc-parser.js';
import { NetrcAuthEntry } from '../lib/types.js';
export declare class NetrcHandler {
readonly netrc: Netrc;
/** @param file - Optional netrc path; otherwise uses the default location. */
constructor(file?: string);
/**
* Retrieves authentication credentials for a given host.
* @param host - The hostname to retrieve credentials for.
* @returns The authentication entry for the host, or undefined if not found.
*/
getAuth(host: string): Promise<{
[key: string]: string | undefined;
account?: string;
login?: string;
password?: string;
}>;
/**
* Removes authentication credentials for a given host.
* @param host - The hostname to remove credentials for.
* @returns A promise that resolves when the credentials are removed.
*/
removeAuth(host: string): Promise<void>;
/**
* Removes credentials for multiple hosts with a single netrc load/save.
*/
removeAuthForHosts(hosts: string[]): Promise<void>;
/**
* Saves authentication credentials for a given host.
* @param auth - The authentication entry containing login and password.
* @param host - The hostname to save credentials for.
* @returns A promise that resolves when the credentials are saved.
*/
saveAuth(auth: NetrcAuthEntry, host: string): Promise<void>;
/**
* Saves the same credentials for multiple hosts with a single netrc load/save.
*/
saveAuthForHosts(auth: NetrcAuthEntry, hosts: string[]): Promise<void>;
private applyAuthToHost;
}
import debug from 'debug';
import { Netrc } from '../lib/netrc-parser.js';
const credDebug = debug('heroku-credential-manager');
export class NetrcHandler {
netrc;
/** @param file - Optional netrc path; otherwise uses the default location. */
constructor(file) {
this.netrc = new Netrc(file);
}
/**
* Retrieves authentication credentials for a given host.
* @param host - The hostname to retrieve credentials for.
* @returns The authentication entry for the host, or undefined if not found.
*/
async getAuth(host) {
await this.netrc.load();
const auth = this.netrc.machines[host];
if (!auth) {
throw new Error(`No auth found for ${host}`);
}
return auth;
}
/**
* Removes authentication credentials for a given host.
* @param host - The hostname to remove credentials for.
* @returns A promise that resolves when the credentials are removed.
*/
async removeAuth(host) {
await this.removeAuthForHosts([host]);
}
/**
* Removes credentials for multiple hosts with a single netrc load/save.
*/
async removeAuthForHosts(hosts) {
if (hosts.length === 0)
return;
await this.netrc.load();
let changed = false;
for (const host of hosts) {
if (!this.netrc.machines[host]) {
credDebug(`No credentials to logout for ${host}`);
continue;
}
delete this.netrc.machines[host];
changed = true;
}
if (changed)
await this.netrc.save();
}
/**
* Saves authentication credentials for a given host.
* @param auth - The authentication entry containing login and password.
* @param host - The hostname to save credentials for.
* @returns A promise that resolves when the credentials are saved.
*/
async saveAuth(auth, host) {
await this.saveAuthForHosts(auth, [host]);
}
/**
* Saves the same credentials for multiple hosts with a single netrc load/save.
*/
async saveAuthForHosts(auth, hosts) {
if (hosts.length === 0)
return;
await this.netrc.load();
for (const host of hosts) {
this.applyAuthToHost(auth, host);
}
await this.netrc.save();
}
applyAuthToHost(auth, host) {
if (!this.netrc.machines[host])
this.netrc.machines[host] = {};
this.netrc.machines[host] = {
login: auth.login,
password: auth.password,
};
delete this.netrc.machines[host].method;
delete this.netrc.machines[host].org;
if (this.netrc.machines._tokens) {
for (const token of this.netrc.machines._tokens) {
if (token.type === 'machine' && host === token.host) {
token.internalWhitespace = '\n ';
}
}
}
}
}
import { KeychainAuthEntry } from '../lib/types.js';
/**
* Handles credential storage and retrieval using the Windows Credential Manager.
* Uses PowerShell commands to interact with the Windows.Security.Credentials.PasswordVault API.
*/
export declare class WindowsHandler {
private readonly scrubber;
/**
* Retrieves the authentication token from Windows Credential Manager.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account: string, service: string): string;
/**
* Lists all accounts stored in Windows Credential Manager for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service: string): string[];
/**
* Removes the authentication token from Windows Credential Manager.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account: string, service: string): void;
/**
* Saves an authentication entry to Windows Credential Manager.
* If a credential with the same name already exists, it is removed before saving the new one.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth: KeychainAuthEntry): void;
/**
* PasswordVault.Retrieve throws when the credential is absent (e.g. netrc-only login).
*/
private isMissingVaultCredential;
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
private scrubError;
}
import { Scrubber } from '@heroku/js-blanket';
import childProcess from 'node:child_process';
/**
* Handles credential storage and retrieval using the Windows Credential Manager.
* Uses PowerShell commands to interact with the Windows.Security.Credentials.PasswordVault API.
*/
export class WindowsHandler {
scrubber = new Scrubber({
patterns: [
/Retrieve\("([^"]+)",\s*"([^"]+)"\)/g, // Scrub account in Retrieve("service", "account")
/PasswordCredential\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\)/g, // Scrub account and token in PasswordCredential
],
});
/**
* Retrieves the authentication token from Windows Credential Manager.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns The stored authentication token.
* @throws Error if the token is not found or retrieval fails.
*/
getAuth(account, service) {
try {
const psCommand = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
$credential = $vault.Retrieve("${service}", "${account}")
$credential.Password
`;
const output = childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] });
const token = output.trim();
if (!token) {
throw new Error('Token not found');
}
return token;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to retrieve token from Windows Credential Manager: ${this.scrubError(message)}`);
}
}
/**
* Lists all accounts stored in Windows Credential Manager for a given service.
* @param service - The service name to search for
* @returns Array of account names found for the service
* @throws Error if the search operation fails
*/
listAccounts(service) {
try {
const psCommand = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
try {
$creds = $vault.FindAllByResource("${service}")
$creds | ForEach-Object { $_.UserName }
} catch {
# No credentials found for this resource
exit 0
}
`;
const output = childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] });
// Expected output format:
// user1@example.com
// user2@example.com
// ...
const accounts = output
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
return accounts;
}
catch (error) {
const { message } = error;
throw new Error(`Failed to list accounts in Windows Credential Manager: ${this.scrubError(message)}`);
}
}
/**
* Removes the authentication token from Windows Credential Manager.
* @param account - The account login to use (e.g. 'test@example.com')
* @param service - The service name to use
* @returns void
* @throws Error if the removal operation fails.
*/
removeAuth(account, service) {
try {
const psCommand = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
$credential = $vault.Retrieve("${service}", "${account}")
$vault.Remove($credential)
`;
childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] });
}
catch (error) {
const { message } = error;
if (this.isMissingVaultCredential(message)) {
return;
}
throw new Error(`Failed to remove token from Windows Credential Manager: ${this.scrubError(message)}`);
}
}
/**
* Saves an authentication entry to Windows Credential Manager.
* If a credential with the same name already exists, it is removed before saving the new one.
* @param auth - The authentication entry containing account and token information to store.
* @returns void
* @throws Error if the save operation fails.
*/
saveAuth(auth) {
try {
try {
const removeCommand = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
$credential = $vault.Retrieve("${auth.service}", "${auth.account}")
$vault.Remove($credential)
`;
childProcess.execSync(removeCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] });
}
catch {
// noop - item does not exist
}
const addCommand = `
$ErrorActionPreference = 'Stop'
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
$credential = New-Object Windows.Security.Credentials.PasswordCredential("${auth.service}", "${auth.account}", "${auth.token}")
$vault.Add($credential)
`;
childProcess.execSync(addCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] });
}
catch (error) {
const { message } = error;
throw new Error(`Failed to store token in Windows Credential Manager: ${this.scrubError(message)}`);
}
}
/**
* PasswordVault.Retrieve throws when the credential is absent (e.g. netrc-only login).
*/
isMissingVaultCredential(message) {
const lower = message.toLowerCase();
return lower.includes('element not found')
|| lower.includes('specified credential could not be found')
|| lower.includes('0x80070490');
}
/**
* Scrubs account names and passwords/tokens from error messages.
*
* @param message - The error message to scrub
* @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]"
*/
scrubError(message) {
const result = this.scrubber.scrub({ message });
return result.data.message;
}
}
import { LinuxHandler } from './credential-handlers/linux-handler.js';
import { MacOSHandler } from './credential-handlers/macos-handler.js';
import { WindowsHandler } from './credential-handlers/windows-handler.js';
import { CredentialStore } from './lib/credential-storage-selector.js';
import { AuthEntry } from './lib/types.js';
/**
* Saves authentication credentials to the native credential store (if available) and .netrc file.
*
* @param account - User's account (email)
* @param token - Authentication token
* @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com'])
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves when credentials are saved
*/
export declare function saveAuth(account: string, token: string, hosts: string[], service?: string): Promise<void>;
/**
* Retrieves authentication credentials from the native credential store (if available) or .netrc file.
*
* @param account - User's account (email), or undefined to search for account
* @param host - Hostname for netrc lookup (e.g., 'api.heroku.com')
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves with the authentication account and token.
* @throws Error if no credentials are found in either location.
*/
export declare function getAuth(account: string | undefined, host: string, service?: string): Promise<AuthEntry>;
/**
* Lists all accounts stored in the native credential store for a given service.
*
* @param service - Service name (defaults to 'heroku-cli')
* @returns Array of account names, or empty array if no native credential store is available
*/
export declare function listKeychainAccounts(service?: string): Promise<string[]>;
/**
* Removes authentication credentials from the platform native store (when present) and .netrc.
* Uses {@link getNativeCredentialStore} so legacy HEROKU_NETRC_WRITE-only mode does not skip Keychain/vault cleanup after a mixed login.
*
* @param account - User's account (email), or undefined when using HEROKU_API_KEY only (native removal is skipped)
* @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com'])
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves when credentials are removed
*/
export declare function removeAuth(account: string | undefined, hosts: string[], service?: string): Promise<void>;
/**
* Factory function to create the appropriate credential handler based on platform.
* @private
* @param store - The type of credential store to use
* @returns A handler instance for the specified store
*/
export declare function getCredentialHandler(store: CredentialStore): LinuxHandler | MacOSHandler | WindowsHandler;
export { LinuxHandler } from './credential-handlers/linux-handler.js';
export { MacOSHandler } from './credential-handlers/macos-handler.js';
export { NetrcHandler } from './credential-handlers/netrc-handler.js';
export { WindowsHandler } from './credential-handlers/windows-handler.js';
export { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js';
export type { StorageConfig } from './lib/credential-storage-selector.js';
export { deleteLoginState, readLoginState, writeLoginState } from './lib/login-state.js';
export { Netrc, parse } from './lib/netrc-parser.js';
export type { Machines, MachinesWithTokens, MachineToken, Token, } from './lib/netrc-parser.js';
export type { AuthEntry, KeychainAuthEntry, NetrcAuthEntry } from './lib/types.js';
import { ux } from '@oclif/core';
import debug from 'debug';
import tsheredoc from 'tsheredoc';
import { LinuxHandler } from './credential-handlers/linux-handler.js';
import { MacOSHandler } from './credential-handlers/macos-handler.js';
import { NetrcHandler } from './credential-handlers/netrc-handler.js';
import { WindowsHandler } from './credential-handlers/windows-handler.js';
import { reportCredentialStoreError } from './lib/cli-command-telemetry.js';
import { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js';
const credDebug = debug('heroku-credential-manager');
const heredoc = tsheredoc.default;
const SERVICE_NAME = 'heroku-cli';
/**
* Saves authentication credentials to the native credential store (if available) and .netrc file.
*
* @param account - User's account (email)
* @param token - Authentication token
* @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com'])
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves when credentials are saved
*/
export async function saveAuth(account, token, hosts, service = SERVICE_NAME) {
const config = getStorageConfig();
const netrcHandler = new NetrcHandler();
if (config.credentialStore) {
try {
const handler = getCredentialHandler(config.credentialStore);
handler.saveAuth({ account, service, token });
}
catch (error) {
const { message } = error;
credDebug(message);
if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') {
ux.warn(heredoc(`
We can't save the Heroku token to your computer's keychain.
We'll save the token to the .netrc file instead.
To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`));
}
await reportCredentialStoreError(error, {
credentialStore: config.credentialStore,
operation: 'saveAuth',
});
}
}
if (config.useNetrc && hosts.length > 0) {
const netrcAuth = {
login: account,
password: token,
};
await netrcHandler.saveAuthForHosts(netrcAuth, hosts);
}
}
/**
* Retrieves authentication credentials from the native credential store (if available) or .netrc file.
*
* @param account - User's account (email), or undefined to search for account
* @param host - Hostname for netrc lookup (e.g., 'api.heroku.com')
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves with the authentication account and token.
* @throws Error if no credentials are found in either location.
*/
export async function getAuth(account, host, service = SERVICE_NAME) {
const config = getStorageConfig();
const netrcHandler = new NetrcHandler();
if (config.credentialStore && account) {
try {
const handler = getCredentialHandler(config.credentialStore);
const token = handler.getAuth(account, service);
return { account, token };
}
catch (error) {
const { message } = error;
credDebug(message);
if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') {
ux.warn(heredoc(`
We can't retrieve the Heroku token from your computer's keychain.
We'll try to retrieve the token from the .netrc file instead.
To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`));
}
await reportCredentialStoreError(error, {
credentialStore: config.credentialStore,
operation: 'getAuth',
});
}
}
if (config.useNetrc) {
const auth = await netrcHandler.getAuth(host);
if (!auth.password) {
throw new Error('No auth found');
}
return { account: auth.login, token: auth.password };
}
throw new Error('No auth found');
}
/**
* Lists all accounts stored in the native credential store for a given service.
*
* @param service - Service name (defaults to 'heroku-cli')
* @returns Array of account names, or empty array if no native credential store is available
*/
export async function listKeychainAccounts(service = SERVICE_NAME) {
const config = getStorageConfig();
if (config.credentialStore) {
try {
const handler = getCredentialHandler(config.credentialStore);
return handler.listAccounts(service);
}
catch (error) {
const { message } = error;
credDebug(message);
await reportCredentialStoreError(error, {
credentialStore: config.credentialStore,
operation: 'listKeychainAccounts',
});
return [];
}
}
return [];
}
/**
* Removes authentication credentials from the platform native store (when present) and .netrc.
* Uses {@link getNativeCredentialStore} so legacy HEROKU_NETRC_WRITE-only mode does not skip Keychain/vault cleanup after a mixed login.
*
* @param account - User's account (email), or undefined when using HEROKU_API_KEY only (native removal is skipped)
* @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com'])
* @param service - Service name (defaults to 'heroku-cli')
* @returns Promise that resolves when credentials are removed
*/
export async function removeAuth(account, hosts, service = SERVICE_NAME) {
const config = getStorageConfig();
const netrcHandler = new NetrcHandler();
const nativeStore = getNativeCredentialStore();
if (nativeStore && account) {
try {
const handler = getCredentialHandler(nativeStore);
handler.removeAuth(account, service);
}
catch (error) {
const { message } = error;
credDebug(message);
if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') {
ux.warn(heredoc(`
We can't remove the Heroku token from your computer's keychain.
We'll remove the token from the .netrc file instead.
To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`));
}
await reportCredentialStoreError(error, {
credentialStore: nativeStore,
operation: 'removeAuth',
});
}
}
if (config.useNetrc && hosts.length > 0) {
await netrcHandler.removeAuthForHosts(hosts);
}
}
/**
* Factory function to create the appropriate credential handler based on platform.
* @private
* @param store - The type of credential store to use
* @returns A handler instance for the specified store
*/
export function getCredentialHandler(store) {
switch (store) {
case CredentialStore.LinuxSecretService: {
return new LinuxHandler();
}
case CredentialStore.MacOSKeychain: {
return new MacOSHandler();
}
case CredentialStore.WindowsCredentialManager: {
return new WindowsHandler();
}
}
}
export { LinuxHandler } from './credential-handlers/linux-handler.js';
export { MacOSHandler } from './credential-handlers/macos-handler.js';
export { NetrcHandler } from './credential-handlers/netrc-handler.js';
export { WindowsHandler } from './credential-handlers/windows-handler.js';
export { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js';
export { deleteLoginState, readLoginState, writeLoginState } from './lib/login-state.js';
export { Netrc, parse } from './lib/netrc-parser.js';
import * as Sentry from '@sentry/node';
import type { CredentialStore } from './credential-storage-selector.js';
/** Indirection so tests can `sinon.stub` without stubbing the ESM `@sentry/node` namespace. */
export declare const credentialSentrySdk: {
captureException: typeof Sentry.captureException;
flush: typeof Sentry.flush;
getClient: typeof Sentry.getClient;
init: typeof Sentry.init;
};
export declare function shouldReportCredentialErrorsToSentry(): boolean;
export type CredentialSentryOperation = 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth';
export declare function reportCredentialStoreError(error: unknown, context: {
credentialStore: CredentialStore;
operation: CredentialSentryOperation;
}): Promise<void>;
import { GDPR_FIELDS, HEROKU_FIELDS, PCI_FIELDS, PII_PATTERNS, Scrubber, } from '@heroku/js-blanket';
import * as Sentry from '@sentry/node';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DSN = 'https://4eb3812769d649a09ae76ef3fcd03dbb@o4508609692368896.ingest.us.sentry.io/4511095245832192';
const scrubber = new Scrubber({
fields: [...HEROKU_FIELDS, ...GDPR_FIELDS, ...PCI_FIELDS],
patterns: [...PII_PATTERNS],
});
/** Indirection so tests can `sinon.stub` without stubbing the ESM `@sentry/node` namespace. */
export const credentialSentrySdk = {
captureException: Sentry.captureException.bind(Sentry),
flush: Sentry.flush.bind(Sentry),
getClient: Sentry.getClient.bind(Sentry),
init: Sentry.init.bind(Sentry),
};
let releaseCache;
let sentryClient;
function readPackageVersion() {
if (releaseCache !== undefined) {
return releaseCache;
}
const dir = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(dir, '../../../package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
releaseCache = pkg.version ?? 'unknown';
return releaseCache;
}
export function shouldReportCredentialErrorsToSentry() {
if (process.env.CI === 'true') {
return false;
}
if (process.env.NODE_ENV === 'test') {
return false;
}
if (process.env.IS_HEROKU_TEST_ENV === 'true') {
return false;
}
if (process.env.DISABLE_TELEMETRY === 'true') {
return false;
}
return true;
}
function ensureCredentialSentryInitialized() {
if (!shouldReportCredentialErrorsToSentry()) {
return;
}
if (sentryClient || credentialSentrySdk.getClient()) {
return;
}
const isDev = process.env.IS_DEV_ENVIRONMENT === 'true';
sentryClient = credentialSentrySdk.init({
beforeSend(event) {
const scrubbed = scrubber.scrub(event).data;
return scrubbed;
},
dsn: DSN,
environment: isDev ? 'development' : 'production',
release: `@heroku-cli/command@${readPackageVersion()}`,
skipOpenTelemetrySetup: true,
});
}
export async function reportCredentialStoreError(error, context) {
if (!shouldReportCredentialErrorsToSentry()) {
return;
}
try {
ensureCredentialSentryInitialized();
credentialSentrySdk.captureException(error, {
tags: {
component: 'heroku-cli-command',
credential_operation: context.operation,
credential_store: context.credentialStore,
},
});
await credentialSentrySdk.flush(2000);
}
catch {
// avoid impacting credential flows if Sentry fails
}
}
export declare const CredentialStore: {
readonly LinuxSecretService: "linux-secret-service";
readonly MacOSKeychain: "macos-keychain";
readonly WindowsCredentialManager: "windows-credential-manager";
};
export type CredentialStore = typeof CredentialStore[keyof typeof CredentialStore];
export type StorageConfig = {
credentialStore: CredentialStore | null;
useNetrc: boolean;
};
/**
* Native credential backend for this platform (Keychain, Secret Service, Windows vault).
* Ignores HEROKU_NETRC_WRITE so logout can clear credentials written before that mode was used.
*/
export declare function getNativeCredentialStore(): CredentialStore | null;
/**
* Determines whether to use OS-native credential storage, .netrc file, or both.
*
* `HEROKU_NETRC_WRITE=true` alone selects legacy netrc-only reads/writes (no native store on the primary path).
* `HEROKU_NATIVE_STORE_WRITE=true` skips .netrc on the primary path so the OS native store (Keychain, Secret Service, Windows Credential Manager) can be tested in isolation.
* When both are `true`, credentials use the native store and .netrc (dual path).
*
* @returns Object containing storage configuration
*
* @example
* ```typescript
* const config = getStorageConfig()
* if (config.credentialStore === CredentialStore.MacOSKeychain) {
* // Use macOS handler
* }
* if (config.useNetrc) {
* // Also use netrc handler
* }
* ```
*/
export declare function getStorageConfig(): StorageConfig;
import childProcess from 'node:child_process';
export const CredentialStore = {
LinuxSecretService: 'linux-secret-service',
MacOSKeychain: 'macos-keychain',
WindowsCredentialManager: 'windows-credential-manager',
};
/**
* Determines whether the secret-tool command is accessible.
*
* @returns True if secret-tool is installed and accessible, false otherwise
*/
function hasSecretTool() {
try {
childProcess.execSync('which secret-tool', {
stdio: 'ignore',
});
return true;
}
catch {
return false;
}
}
/**
* Native credential backend for this platform (Keychain, Secret Service, Windows vault).
* Ignores HEROKU_NETRC_WRITE so logout can clear credentials written before that mode was used.
*/
export function getNativeCredentialStore() {
const { platform } = process;
switch (platform) {
case 'darwin': {
return CredentialStore.MacOSKeychain;
}
case 'linux': {
return hasSecretTool() ? CredentialStore.LinuxSecretService : null;
}
case 'win32': {
return CredentialStore.WindowsCredentialManager;
}
default: {
return null;
}
}
}
/**
* Determines whether to use OS-native credential storage, .netrc file, or both.
*
* `HEROKU_NETRC_WRITE=true` alone selects legacy netrc-only reads/writes (no native store on the primary path).
* `HEROKU_NATIVE_STORE_WRITE=true` skips .netrc on the primary path so the OS native store (Keychain, Secret Service, Windows Credential Manager) can be tested in isolation.
* When both are `true`, credentials use the native store and .netrc (dual path).
*
* @returns Object containing storage configuration
*
* @example
* ```typescript
* const config = getStorageConfig()
* if (config.credentialStore === CredentialStore.MacOSKeychain) {
* // Use macOS handler
* }
* if (config.useNetrc) {
* // Also use netrc handler
* }
* ```
*/
export function getStorageConfig() {
const netrcWriteLegacy = process.env.HEROKU_NETRC_WRITE?.toLowerCase() === 'true';
const nativeStoreWriteEnabled = process.env.HEROKU_NATIVE_STORE_WRITE?.toLowerCase() === 'true';
if (netrcWriteLegacy && !nativeStoreWriteEnabled) {
return {
credentialStore: null,
useNetrc: true,
};
}
return {
credentialStore: getNativeCredentialStore(),
useNetrc: !nativeStoreWriteEnabled || netrcWriteLegacy,
};
}
type LoginState = {
account: string;
};
export declare function readLoginState(dataDir: string): Promise<LoginState | undefined>;
export declare function writeLoginState(dataDir: string, account: string): Promise<void>;
export declare function deleteLoginState(dataDir: string): Promise<void>;
export {};
import debug from 'debug';
import * as fs from 'node:fs';
import { join } from 'node:path';
const credDebug = debug('heroku-credential-manager');
const LOGIN_STATE_FILE = 'login.json';
export async function readLoginState(dataDir) {
const filePath = join(dataDir, LOGIN_STATE_FILE);
try {
const content = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(content);
if (typeof parsed?.account === 'string' && parsed.account.length > 0) {
return { account: parsed.account };
}
credDebug('login state file missing valid account field: %s', filePath);
}
catch (error) {
if (error.code !== 'ENOENT') {
credDebug('failed to read login state file: %s', error.message);
}
}
}
export async function writeLoginState(dataDir, account) {
const filePath = join(dataDir, LOGIN_STATE_FILE);
await fs.promises.mkdir(dataDir, { mode: 0o700, recursive: true });
await fs.promises.writeFile(filePath, JSON.stringify({ account }) + '\n', { encoding: 'utf8', mode: 0o600 });
}
export async function deleteLoginState(dataDir) {
const filePath = join(dataDir, LOGIN_STATE_FILE);
try {
await fs.promises.unlink(filePath);
}
catch (error) {
if (error.code !== 'ENOENT') {
credDebug('failed to delete login state file: %s', error.message);
}
}
}
export type Token = MachineToken | {
content: string;
type: 'other';
};
export type MachineToken = {
comment?: string;
host: string;
internalWhitespace: string;
pre?: string;
props: {
[key: string]: {
comment?: string;
value: string;
};
};
type: 'machine';
};
export type Machines = {
[key: string]: {
[key: string]: string | undefined;
account?: string;
login?: string;
password?: string;
};
};
export type MachinesWithTokens = Machines & {
_tokens?: Token[];
};
/**
* Parses a netrc file body into a structured MachinesWithTokens object.
* Handles both inline and multiline machine definitions, including comments.
* @param body - The raw string content of a netrc file
* @returns A proxied MachinesWithTokens object containing all parsed machine entries
*/
export declare function parse(body: string): MachinesWithTokens;
export declare class Netrc {
file: string;
machines: MachinesWithTokens;
/**
* Creates a new Netrc instance.
* @param file - Optional path to the netrc file. If not provided, uses the default location.
*/
constructor(file?: string);
/**
* Gets the default netrc file path based on the operating system.
* Checks for GPG-encrypted version first.
* @returns The path to the default netrc file
*/
private get defaultFile();
/**
* Gets the GPG command arguments for decrypting the netrc file.
* @returns Array of GPG command-line arguments for decryption
*/
private get gpgDecryptArgs();
/**
* Gets the GPG command arguments for encrypting the netrc file.
* @returns Array of GPG command-line arguments for encryption
*/
private get gpgEncryptArgs();
/**
* Generates the string representation of all machines for writing to file.
* @returns The formatted netrc file content as a string
*/
private get output();
/**
* Asynchronously loads and parses the netrc file.
* Handles both plain text and GPG-encrypted files.
* @returns A promise that resolves when loading is complete, or throws on error
*/
load(): Promise<never | void>;
/**
* Synchronously loads and parses the netrc file.
* Handles both plain text and GPG-encrypted files.
* @returns void, or throws on error
*/
loadSync(): never | void;
/**
* Asynchronously saves the current machines to the netrc file.
* Handles GPG encryption if the file has a .gpg extension.
* @returns A promise that resolves when saving is complete
*/
save(): Promise<void>;
/**
* Synchronously saves the current machines to the netrc file.
* Handles GPG encryption if the file has a .gpg extension.
* @returns void, or throws on error
*/
saveSync(): void;
/**
* Appends a machine token's comment to the output array.
* @param t - The machine token containing the comment
* @param output - The output string array to append to
* @returns void
*/
private addCommentToOutput;
/**
* Appends a machine token's properties to the output array.
* Login and password are added first, followed by other properties.
* @param t - The machine token containing the properties
* @param output - The output string array to append to
* @returns void
*/
private addPropsToOutput;
/**
* Wraps and throws an error with additional context about the netrc file.
* @param err - The original error
* @returns Never returns; always throws
*/
private throw;
}
declare const _default: Netrc;
export default _default;
import debug from 'debug';
import { execa, execaSync } from 'execa';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const credDebug = debug('heroku-credential-manager');
/**
* Creates ES6 proxy objects from parsed tokens to allow easy modification by consumers.
* This is somewhat complicated, but it takes the array of parsed tokens from parse()
* and wraps them in proxies that intercept get/set/delete operations.
* @param tokens - Array of parsed tokens from the netrc file
* @returns A proxied MachinesWithTokens object that allows direct property access and modification
*/
function proxify(tokens) {
const proxifyProps = (t) => new Proxy(t.props, {
get(_, key) {
if (key === 'host')
return t.host;
if (typeof key !== 'string')
return t.props[key];
const prop = t.props[key];
if (!prop)
return;
return prop.value;
},
set(_, key, value) {
if (key === 'host') {
t.host = value;
}
else if (value) {
t.props[key] = t.props[key] || (t.props[key] = { value: '' });
t.props[key].value = value;
}
else {
delete t.props[key];
}
return true;
},
});
const machineTokens = tokens.filter((m) => m.type === 'machine');
const machines = machineTokens.map(t => proxifyProps(t));
const getWhitespace = () => {
if (machineTokens.length === 0)
return ' ';
return machineTokens.at(-1).internalWhitespace;
};
const obj = {};
obj._tokens = tokens;
for (const m of machines)
obj[m.host] = m;
return new Proxy(obj, {
deleteProperty(obj, host) {
delete obj[host];
const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host);
if (idx === -1)
return true;
tokens.splice(idx, 1);
return true;
},
ownKeys() {
return machines.map(m => m.host);
},
set(obj, host, props) {
if (!props) {
delete obj[host];
const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host);
if (idx === -1)
return true;
tokens.splice(idx, 1);
return true;
}
let machine = machines.find(m => m.host === host);
if (!machine) {
const token = {
host, internalWhitespace: getWhitespace(), props: {}, type: 'machine',
};
tokens.push(token);
machine = proxifyProps(token);
machines.push(machine);
obj[host] = machine;
}
for (const [k, v] of Object.entries(props)) {
machine[k] = v;
}
return true;
},
});
}
/**
* Parses a netrc file body into a structured MachinesWithTokens object.
* Handles both inline and multiline machine definitions, including comments.
* @param body - The raw string content of a netrc file
* @returns A proxied MachinesWithTokens object containing all parsed machine entries
*/
export function parse(body) {
const lines = body.split('\n');
let pre = [];
const machines = [];
while (lines.length > 0) {
const line = lines.shift();
const match = line.match(/machine\s+((?:[^\s#]+\s*)+)(#.*)?$/);
if (!match) {
pre.push(line);
continue;
}
const [, body, comment] = match;
const machine = {
comment,
host: body.split(' ')[0],
internalWhitespace: '\n ',
pre: pre.join('\n'),
props: {},
type: 'machine',
};
pre = [];
// do not read other machines with same host
if (!machines.some(m => m.type === 'machine' && m.host === machine.host))
machines.push(machine);
if (body.trim().includes(' ')) { // inline machine
const [host, ...propStrings] = body.split(' ');
for (let a = 0; a < propStrings.length; a += 2) {
machine.props[propStrings[a]] = { value: propStrings[a + 1] };
}
machine.host = host;
machine.internalWhitespace = ' ';
}
else { // multiline machine
while (lines.length > 0) {
const line = lines.shift();
const match = line.match(/^(\s+)(\S+)\s+(\S+)(\s+#.*)?$/);
if (!match) {
lines.unshift(line);
break;
}
const [, ws, key, value, comment] = match;
machine.props[key] = { comment, value };
machine.internalWhitespace = `\n${ws}`;
}
}
}
return proxify([...machines, { content: pre.join('\n'), type: 'other' }]);
}
export class Netrc {
file;
machines;
/**
* Creates a new Netrc instance.
* @param file - Optional path to the netrc file. If not provided, uses the default location.
*/
constructor(file) {
this.file = file || this.defaultFile;
}
/**
* Gets the default netrc file path based on the operating system.
* Checks for GPG-encrypted version first.
* @returns The path to the default netrc file
*/
get defaultFile() {
let home;
if (os.platform() === 'win32') {
const fromDrive = process.env.HOMEDRIVE && process.env.HOMEPATH
? path.join(process.env.HOMEDRIVE, process.env.HOMEPATH)
: undefined;
home = process.env.HOME || fromDrive || process.env.USERPROFILE;
}
const resolved = home || os.homedir() || os.tmpdir();
const file = path.join(resolved, os.platform() === 'win32' ? '_netrc' : '.netrc');
const gpgFile = `${file}.gpg`;
return fs.existsSync(gpgFile) ? gpgFile : file;
}
/**
* Gets the GPG command arguments for decrypting the netrc file.
* @returns Array of GPG command-line arguments for decryption
*/
get gpgDecryptArgs() {
const args = ['--batch', '--quiet', '--decrypt', this.file];
credDebug('running gpg with args %o', args);
return args;
}
/**
* Gets the GPG command arguments for encrypting the netrc file.
* @returns Array of GPG command-line arguments for encryption
*/
get gpgEncryptArgs() {
const args = ['-a', '--batch', '--default-recipient-self', '-e'];
credDebug('running gpg with args %o', args);
return args;
}
/**
* Generates the string representation of all machines for writing to file.
* @returns The formatted netrc file content as a string
*/
get output() {
const output = [];
if (this.machines._tokens) {
for (const t of this.machines._tokens) {
if (t.type === 'other') {
output.push(t.content);
continue;
}
if (t.pre) {
output.push(t.pre + '\n');
}
output.push(`machine ${t.host}`);
if (t.internalWhitespace.includes('\n')) {
this.addCommentToOutput(t, output);
this.addPropsToOutput(t, output);
output.push('\n');
}
else {
this.addPropsToOutput(t, output);
this.addCommentToOutput(t, output);
output.push('\n');
}
}
}
return output.join('');
}
/**
* Asynchronously loads and parses the netrc file.
* Handles both plain text and GPG-encrypted files.
* @returns A promise that resolves when loading is complete, or throws on error
*/
async load() {
try {
credDebug('load', this.file);
const decryptFile = async () => {
const { exitCode, stdout } = await execa('gpg', this.gpgDecryptArgs, { reject: false, stdio: ['inherit', 'pipe', 'inherit'] });
if (exitCode !== 0)
throw new Error(`gpg exited with code ${exitCode}`);
return stdout;
};
const body = await (path.extname(this.file) === '.gpg'
? decryptFile()
: new Promise((resolve, reject) => {
fs.readFile(this.file, { encoding: 'utf8' }, (err, data) => {
if (err && err.code !== 'ENOENT')
reject(err);
debug('ENOENT');
resolve(data || '');
});
}));
this.machines = parse(body);
credDebug('machines: %o', Object.keys(this.machines));
}
catch (error) {
return this.throw(error);
}
}
/**
* Synchronously loads and parses the netrc file.
* Handles both plain text and GPG-encrypted files.
* @returns void, or throws on error
*/
loadSync() {
try {
credDebug('loadSync', this.file);
const decryptFile = () => {
const { exitCode, stdout } = execaSync('gpg', this.gpgDecryptArgs, { reject: false, stdio: ['inherit', 'pipe', 'inherit'] });
if (exitCode !== 0)
throw new Error(`gpg exited with code ${exitCode}`);
return stdout;
};
let body = '';
if (path.extname(this.file) === '.gpg') {
body = decryptFile();
}
else {
try {
body = fs.readFileSync(this.file, 'utf8');
}
catch (error) {
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT')
throw error;
}
}
this.machines = parse(body);
credDebug('machines: %o', Object.keys(this.machines));
}
catch (error) {
return this.throw(error);
}
}
/**
* Asynchronously saves the current machines to the netrc file.
* Handles GPG encryption if the file has a .gpg extension.
* @returns A promise that resolves when saving is complete
*/
async save() {
credDebug('save', this.file);
let body = this.output;
if (this.file.endsWith('.gpg')) {
const { exitCode, stdout } = await execa('gpg', this.gpgEncryptArgs, { input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit'] });
if (exitCode !== 0)
throw new Error(`gpg exited with code ${exitCode}`);
body = stdout;
}
return new Promise((resolve, reject) => {
fs.writeFile(this.file, body, { mode: 0o600 }, err => (err ? reject(err) : resolve()));
});
}
/**
* Synchronously saves the current machines to the netrc file.
* Handles GPG encryption if the file has a .gpg extension.
* @returns void, or throws on error
*/
saveSync() {
credDebug('saveSync', this.file);
let body = this.output;
if (this.file.endsWith('.gpg')) {
const { exitCode, stdout } = execaSync('gpg', this.gpgEncryptArgs, { input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit'] });
if (exitCode !== 0)
throw new Error(`gpg exited with code ${exitCode}`);
body = stdout;
}
fs.writeFileSync(this.file, body, { mode: 0o600 });
}
/**
* Appends a machine token's comment to the output array.
* @param t - The machine token containing the comment
* @param output - The output string array to append to
* @returns void
*/
addCommentToOutput(t, output) {
if (t.comment)
output.push(' ' + t.comment);
}
/**
* Appends a machine token's properties to the output array.
* Login and password are added first, followed by other properties.
* @param t - The machine token containing the properties
* @param output - The output string array to append to
* @returns void
*/
addPropsToOutput(t, output) {
const addProp = (k) => output.push(`${t.internalWhitespace}${k} ${t.props[k].value}${t.props[k].comment || ''}`);
// do login/password first
if (t.props.login)
addProp('login');
if (t.props.password)
addProp('password');
for (const k of Object.keys(t.props).filter(k => !['login', 'password'].includes(k))) {
addProp(k);
}
}
/**
* Wraps and throws an error with additional context about the netrc file.
* @param err - The original error
* @returns Never returns; always throws
*/
throw(err) {
const error = (err instanceof Error ? err : new Error(String(err)));
if (error.detail)
error.detail += '\n';
else
error.detail = '';
error.detail += `Error occurred during reading netrc file: ${this.file}`;
throw error;
}
}
export default new Netrc();
export type NetrcAuthEntry = {
login: string;
password: string;
};
export type KeychainAuthEntry = {
account: string;
service: string;
token: string;
};
export type AuthEntry = {
account: string | undefined;
token: string | undefined;
};
/**
* Thin wrapper around credential-manager-core so tests can inject a mock
* (ESM module exports cannot be stubbed with sinon).
*/
import { type AuthEntry } from './credential-manager-core/index.js';
export interface CredentialManagerProvider {
getAuth: (account: string | undefined, host: string, service?: string) => Promise<AuthEntry>;
removeAuth: (account: string | undefined, hosts: string[], service?: string) => Promise<void>;
saveAuth: (account: string, token: string, hosts: string[], service?: string) => Promise<void>;
}
export declare function setCredentialManagerProvider(p: CredentialManagerProvider): void;
export declare function getAuth(account: string | undefined, host: string, service?: string): Promise<AuthEntry>;
export declare function removeAuth(account: string | undefined, hosts: string[], service?: string): Promise<void>;
export declare function saveAuth(account: string, token: string, hosts: string[], service?: string): Promise<void>;
export type { AuthEntry } from './credential-manager-core/index.js';
/**
* Thin wrapper around credential-manager-core so tests can inject a mock
* (ESM module exports cannot be stubbed with sinon).
*/
import { getAuth as realGetAuth, removeAuth as realRemoveAuth, saveAuth as realSaveAuth, } from './credential-manager-core/index.js';
let provider = {
getAuth: realGetAuth,
removeAuth: realRemoveAuth,
saveAuth: realSaveAuth,
};
export function setCredentialManagerProvider(p) {
provider = p;
}
export async function getAuth(account, host, service) {
return provider.getAuth(account, host, service);
}
export async function removeAuth(account, hosts, service) {
return provider.removeAuth(account, hosts, service);
}
export async function saveAuth(account, token, hosts, service) {
return provider.saveAuth(account, token, hosts, service);
}
+11
-0
import type { Config } from '@oclif/core/interfaces';
import { HTTP, HTTPError, HTTPRequestOptions } from '@heroku/http-call';
import { CLIError } from '@oclif/core/errors';
import { type AuthEntry } from './credential-manager.js';
import { Login } from './login.js';

@@ -43,5 +44,10 @@ import { Mutex } from './mutex.js';

};
private _account?;
private _auth?;
private readonly _login;
private _particleboard;
/** In-flight dedupe for concurrent getAuthEntry() calls before resolution completes. */
private _storedAuthPromise?;
/** After a failed read from storage, skip re-querying until state is reset (login/logout/auth setter). */
private _storedAuthResolvedAbsent;
private _twoFactorMutex;

@@ -56,2 +62,4 @@ constructor(config: Config, options?: IOptions);

get<T>(url: string, options?: APIClient.Options): Promise<HTTP<T>>;
getAuth(): Promise<string | undefined>;
getAuthEntry(): Promise<AuthEntry | undefined>;
login(opts?: Login.Options): Promise<void>;

@@ -64,4 +72,7 @@ logout(): Promise<void>;

request<T>(url: string, options?: APIClient.Options): Promise<HTTP<T>>;
setAuthEntry(entry: AuthEntry | undefined): void;
stream(url: string, options?: APIClient.Options): Promise<HTTP<unknown>>;
twoFactorPrompt(): Promise<string>;
private clearLoginState;
private resetStoredAuthResolution;
}
+81
-29
import { HTTP, HTTPError } from '@heroku/http-call';
import { CLIError, warn } from '@oclif/core/errors';
import debug from 'debug';
import { Netrc } from 'netrc-parser';
import * as url from 'node:url';
import { getStorageConfig } from './credential-manager-core/lib/credential-storage-selector.js';
import { deleteLoginState, readLoginState } from './credential-manager-core/lib/login-state.js';
import { getAuth as getStoredAuth, removeAuth } from './credential-manager.js';
import { Login } from './login.js';

@@ -13,10 +15,2 @@ import { Mutex } from './mutex.js';

import { yubikey } from './yubikey.js';
// Defer netrc instantiation to avoid eager .netrc file operations at module load time
let _netrc;
function getNetrc() {
if (!_netrc) {
_netrc = new Netrc();
}
return _netrc;
}
export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com']);

@@ -54,5 +48,10 @@ export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1']);

preauthPromises;
_account;
_auth;
_login;
_particleboard;
/** In-flight dedupe for concurrent getAuthEntry() calls before resolution completes. */
_storedAuthPromise;
/** After a failed read from storage, skip re-querying until state is reset (login/logout/auth setter). */
_storedAuthResolvedAbsent = false;
_twoFactorMutex;

@@ -146,2 +145,3 @@ constructor(config, options = {}) {

}
let auth;
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {

@@ -161,3 +161,4 @@ // Handle both relative and absolute URLs for validation

if (isHerokuApi || isLocalhost) {
opts.headers.authorization = `Bearer ${self.auth}`;
auth = await self.getAuth();
opts.headers.authorization = `Bearer ${auth}`;
}

@@ -170,5 +171,5 @@ }

let particleboardResponse;
const particleboardClient = self.particleboard;
if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
self._particleboard.auth = self.auth;
const particleboardClient = self.particleboard;
particleboardClient.auth = auth ?? await self.getAuth();
const settledResponses = await Promise.allSettled([

@@ -201,3 +202,3 @@ super.request(url, opts),

if (retries > 0) {
if (opts.retryAuth !== false && error.http.statusCode === 401 && error.body.id === 'unauthorized') {
if (opts.retryAuth !== false && error.http.statusCode === 401) {
if (process.env.HEROKU_API_KEY) {

@@ -209,3 +210,4 @@ throw new Error('The token provided to HEROKU_API_KEY is invalid. Please double-check that you have the correct token, or run `heroku login` without HEROKU_API_KEY set.');

await self.authPromise;
opts.headers.authorization = `Bearer ${self.auth}`;
const retryAuth = await self.getAuth();
opts.headers.authorization = `Bearer ${retryAuth}`;
return this.request(url, opts, retries);

@@ -260,12 +262,2 @@ }

get auth() {
if (!this._auth) {
if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY)
warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY');
this._auth = process.env.HEROKU_API_KEY;
if (!this._auth) {
const netrc = getNetrc();
netrc.loadSync();
this._auth = netrc.machines[vars.apiHost] && netrc.machines[vars.apiHost].password;
}
}
return this._auth;

@@ -298,2 +290,46 @@ }

}
async getAuth() {
const authEntry = await this.getAuthEntry();
return authEntry?.token;
}
async getAuthEntry() {
if (this._auth)
return { account: this._account, token: this._auth };
if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY)
warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY');
if (process.env.HEROKU_API_KEY) {
this._account = undefined;
this._auth = process.env.HEROKU_API_KEY;
return { account: this._account, token: this._auth };
}
if (this._storedAuthResolvedAbsent)
return undefined;
if (!this._storedAuthPromise) {
this._storedAuthPromise = (async () => {
const { credentialStore } = getStorageConfig();
const useLoginState = Boolean(credentialStore && this.config.dataDir);
try {
const cachedAccount = useLoginState
? (await readLoginState(this.config.dataDir))?.account
: undefined;
const { account, token } = await getStoredAuth(cachedAccount, vars.apiHost);
this._auth = token;
this._account = account;
this._storedAuthResolvedAbsent = false;
return { account: this._account, token: this._auth };
}
catch {
if (useLoginState) {
await deleteLoginState(this.config.dataDir);
}
this._storedAuthResolvedAbsent = true;
return undefined;
}
finally {
this._storedAuthPromise = undefined;
}
})();
}
return this._storedAuthPromise;
}
login(opts = {}) {

@@ -303,4 +339,5 @@ return this._login.login(opts);

async logout() {
const entry = await this.getAuthEntry();
try {
await this._login.logout();
await this._login.logout(entry?.token);
}

@@ -311,6 +348,5 @@ catch (error) {

}
const netrc = getNetrc();
delete netrc.machines['api.heroku.com'];
delete netrc.machines['git.heroku.com'];
await netrc.save();
this.setAuthEntry(undefined);
await removeAuth(entry?.account, [vars.apiHost, vars.httpGitHost]);
await this.clearLoginState();
}

@@ -334,2 +370,8 @@ patch(url, options = {}) {

}
setAuthEntry(entry) {
delete this.authPromise;
this._auth = entry?.token;
this._account = entry?.account;
this.resetStoredAuthResolution();
}
stream(url, options = {}) {

@@ -357,2 +399,12 @@ return this.http.stream(url, options);

}
async clearLoginState() {
const config = getStorageConfig();
if (config.credentialStore && this.config.dataDir) {
await deleteLoginState(this.config.dataDir);
}
}
resetStoredAuthResolution() {
this._storedAuthPromise = undefined;
this._storedAuthResolvedAbsent = false;
}
}

@@ -49,2 +49,4 @@ import { Command as Base } from '@oclif/core/command';

await super.init();
// Preload credentials so this.heroku.auth is set before run() (async credential-manager + sync checks).
await this.heroku.getAuth();
if (!this.isPromptModeActive()) {

@@ -51,0 +53,0 @@ return;

export * from './api-client.js';
export { Command, Command as default } from './command.js';
export * from './completions.js';
export * from './credential-manager-core/index.js';
export * as flags from './flags/index.js';

@@ -5,0 +6,0 @@ export * from './git.js';

export * from './api-client.js';
export { Command, Command as default } from './command.js';
export * from './completions.js';
export * from './credential-manager-core/index.js';
export * as flags from './flags/index.js';

@@ -5,0 +6,0 @@ export * from './git.js';

@@ -16,3 +16,3 @@ import type { Config } from '@oclif/core/interfaces';

login(opts?: Login.Options): Promise<void>;
logout(token?: string | undefined): Promise<void>;
logout(token?: string): Promise<void>;
private browser;

@@ -23,2 +23,3 @@ private createOAuthToken;

private interactive;
private isCurrentOAuthToken;
private saveToken;

@@ -25,0 +26,0 @@ private showManualBrowserLoginUrl;

@@ -5,6 +5,8 @@ import { HTTP } from '@heroku/http-call';

import debug from 'debug';
import { Netrc } from 'netrc-parser';
import os from 'node:os';
import * as readline from 'node:readline';
import { HerokuAPIError } from './api-client.js';
import { getStorageConfig } from './credential-manager-core/lib/credential-storage-selector.js';
import { writeLoginState } from './credential-manager-core/lib/login-state.js';
import { saveAuth } from './credential-manager.js';
import { prompter } from './prompter.js';

@@ -15,10 +17,3 @@ import { vars } from './vars.js';

const thirtyDays = 60 * 60 * 24 * 30;
// Defer netrc instantiation to avoid eager file operations
let _netrc;
function getNetrc() {
if (!_netrc) {
_netrc = new Netrc();
}
return _netrc;
}
const REDACTED_TOKEN_ASTERISKS = '*'.repeat(10);
const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } });

@@ -45,5 +40,6 @@ export class Login {

ux.error('Cannot set an expiration longer than thirty days');
const netrc = getNetrc();
await netrc.load();
const previousEntry = netrc.machines['api.heroku.com'];
const previousToken = await this.heroku.getAuth();
const previousAccount = previousToken
? (await this.heroku.getAuthEntry())?.account?.trim() || undefined
: undefined;
let input = opts.method;

@@ -80,10 +76,2 @@ if (!input) {

}
try {
if (previousEntry && previousEntry.password)
await this.logout(previousEntry.password);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
ux.warn(message);
}
let auth;

@@ -98,3 +86,3 @@ switch (input) {

case 'interactive': {
auth = await this.interactive(previousEntry && previousEntry.login, opts.expiresIn);
auth = await this.interactive(previousAccount, opts.expiresIn);
break;

@@ -120,4 +108,5 @@ }

}
async logout(token = this.heroku.auth) {
if (!token)
async logout(token) {
const resolvedToken = token ?? await this.heroku.getAuth();
if (!resolvedToken)
return cliDebug('no credentials to logout');

@@ -127,3 +116,3 @@ const requests = [];

// authorizations because they are created a trusted client
requests.push(HTTP.delete(`${vars.apiUrl}/oauth/sessions/~`, headers(token))
requests.push(HTTP.delete(`${vars.apiUrl}/oauth/sessions/~`, headers(resolvedToken))
.catch(error => {

@@ -135,3 +124,3 @@ if (!error.http)

}
if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') {
if (error.http.statusCode === 401) {
return;

@@ -144,3 +133,3 @@ }

// the ~ is the API Key, not the authorization that is currently requesting
requests.push(HTTP.get(`${vars.apiUrl}/oauth/authorizations`, headers(token))
requests.push(HTTP.get(`${vars.apiUrl}/oauth/authorizations`, headers(resolvedToken))
.then(async ({ body: authorizations }) => {

@@ -150,8 +139,8 @@ // grab the default authorization because that is the token shown in the

// would unwittingly break an integration that they are depending on
const d = await this.defaultToken();
if (d === token)
const defaultApiToken = await this.defaultToken();
if (defaultApiToken && this.isCurrentOAuthToken(resolvedToken, defaultApiToken))
return;
return Promise.all(authorizations
.filter(a => a.access_token && a.access_token.token === this.heroku.auth)
.map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(token))));
.filter(a => a.access_token?.token && this.isCurrentOAuthToken(resolvedToken, a.access_token.token))
.map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(resolvedToken))));
})

@@ -161,3 +150,3 @@ .catch(error => {

throw error;
if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') {
if (error.http.statusCode === 401) {
return [];

@@ -211,6 +200,6 @@ }

ux.error(auth.error);
this.heroku.auth = auth.access_token;
ux.action.start('Logging in');
const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(auth.access_token));
ux.action.stop();
this.heroku.setAuthEntry({ account: account.email, token: auth.access_token });
return {

@@ -244,4 +233,7 @@ login: account.email,

async defaultToken() {
const token = await this.heroku.getAuth();
if (!token)
return;
try {
const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(this.heroku.auth));
const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(token));
return authorization.access_token && authorization.access_token.token;

@@ -252,5 +244,5 @@ }

throw error;
if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.body.resource === 'authorization')
if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'authorization')
return;
if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized')
if (error.http.statusCode === 401)
return;

@@ -302,24 +294,21 @@ throw error;

}
this.heroku.auth = auth.password;
this.heroku.setAuthEntry({ account: auth.login, token: auth.password });
return auth;
}
isCurrentOAuthToken(localToken, apiToken) {
const asteriskIndex = apiToken.indexOf(REDACTED_TOKEN_ASTERISKS);
if (asteriskIndex === -1) {
// raw value stored, direct match works
return localToken === apiToken;
}
const prefix = apiToken.slice(0, asteriskIndex);
const suffix = apiToken.slice(asteriskIndex + REDACTED_TOKEN_ASTERISKS.length);
return localToken.startsWith(prefix) && (suffix === '' || localToken.endsWith(suffix));
}
async saveToken(entry) {
const netrc = getNetrc();
const hosts = [vars.apiHost, vars.httpGitHost];
for (const host of hosts) {
if (!netrc.machines[host])
netrc.machines[host] = {};
netrc.machines[host].login = entry.login;
netrc.machines[host].password = entry.password;
delete netrc.machines[host].method;
delete netrc.machines[host].org;
await saveAuth(entry.login, entry.password, [vars.apiHost, vars.httpGitHost]);
const config = getStorageConfig();
if (config.credentialStore && this.config.dataDir) {
await writeLoginState(this.config.dataDir, entry.login);
}
if (netrc.machines._tokens) {
netrc.machines._tokens.forEach((token) => {
if (hosts.includes(token.host)) {
token.internalWhitespace = '\n ';
}
});
}
await netrc.save();
}

@@ -356,5 +345,5 @@ showManualBrowserLoginUrl(url) {

ux.action.start('Validating token');
this.heroku.auth = password;
const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(password));
ux.action.stop();
this.heroku.setAuthEntry({ account: account.email, token: password });
return {

@@ -361,0 +350,0 @@ login: account.email,

{
"name": "@heroku-cli/command",
"description": "base class for Heroku CLI commands",
"version": "12.3.3",
"version": "12.4.0-beta.0",
"author": "Heroku",
"bugs": "https://github.com/heroku/heroku-cli-command/issues",
"dependencies": {
"@heroku/js-blanket": "^1.0.0",
"@heroku/http-call": "^5.4.0",
"@oclif/core": "^4.3.0",
"@sentry/node": "^10.45.0",
"ansis": "^4",
"debug": "^4.4.0",
"execa": "^9.6.1",
"inquirer": "^12.11.1",
"netrc-parser": "^3.1.6",
"open": "^11.0.0",
"tsheredoc": "^1.0.1",
"yargs-parser": "^20.2.9",

@@ -22,3 +25,5 @@ "yargs-unparser": "^2.0.0"

"@types/chai": "^5.2.2",
"@types/chai-as-promised": "^8.0.2",
"@types/debug": "^4.1.12",
"@types/fs-extra": "^11.0.4",
"@types/mocha": "^10.0.6",

@@ -34,5 +39,7 @@ "@types/node": "22.15.21",

"chai": "^6.2.2",
"chai-as-promised": "^8.0.1",
"eslint": "^9",
"eslint-config-oclif": "^6.0.152",
"fancy-test": "^3.0.16",
"fs-extra": "^11.3.0",
"mocha": "^11",

@@ -65,4 +72,5 @@ "nock": "^14.0.1",

"prepare": "npm run build",
"test": "c8 --all --check-coverage --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.test.ts\"",
"test": "c8 --all --check-coverage --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/**/acceptance/**/*.test.ts\"",
"test:file": "c8 mocha",
"test:ci:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/index.acceptance.test.ts\"",
"example": "sh examples/run.sh"

@@ -69,0 +77,0 @@ },