heroku 11.7.1 → 11.8.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,6 +4,26 @@ 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.8.0-beta.0](https://github.com/heroku/cli/compare/v11.7.1...v11.8.0-beta.0) (2026-07-01)
8
+
9
+
10
+ ### Features
11
+
12
+ * 'data:pg:migrate' interactive method selection (W-22585064) ([#3791](https://github.com/heroku/cli/issues/3791)) ([914fc06](https://github.com/heroku/cli/commit/914fc06e1dd76cd687ffa3b0973dc0356814a4ae))
13
+ * skip caching token in keychain mode for accounts cache ([#3792](https://github.com/heroku/cli/issues/3792)) ([5d7d95b](https://github.com/heroku/cli/commit/5d7d95bafbcf088c9818690df769d3b8451be369))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * remove keychain accounts cache on logout ([#3797](https://github.com/heroku/cli/issues/3797)) ([70cbd44](https://github.com/heroku/cli/commit/70cbd44fcac9b7d3cd21d8d62b713e63e2030924))
19
+ * update CLI to work with changes to heroku-cli-util table function ([#3796](https://github.com/heroku/cli/issues/3796)) ([a523792](https://github.com/heroku/cli/commit/a523792944869e4ea9fe217c080619e1819b43ae))
20
+
21
+
22
+ ### Miscellaneous Chores
23
+
24
+ * **deps:** bump @heroku/mcp-server to 1.2.5 and update undici ([#3794](https://github.com/heroku/cli/issues/3794)) ([f5dab06](https://github.com/heroku/cli/commit/f5dab067f0b760a4efd9deee9760b6075a7d913e))
25
+ * **deps:** bump @heroku/mcp-server to 1.2.5, update undici ([09ca009](https://github.com/heroku/cli/commit/09ca0096ae32a27809d688e5e783466fe27d2401))
26
+
7
27
  ## [11.7.1](https://github.com/heroku/cli/compare/v11.7.0...v11.7.1) (2026-06-24)
8
28
 
9
29
 
@@ -22,6 +22,14 @@ export default class Add extends Command {
22
22
  ux.error(`Account ${email} already has an alias of ${existingAlias.name}.`);
23
23
  }
24
24
  const token = this.heroku.auth;
25
- AccountsModule.add(name, email, token);
25
+ const config = AccountsModule.getStorageConfig();
26
+ if (config.credentialStore) {
27
+ // Keychain-mode: don't save token to cache file
28
+ AccountsModule.add(name, email);
29
+ }
30
+ else {
31
+ // Netrc-mode: save token to cache file
32
+ AccountsModule.add(name, email, token);
33
+ }
26
34
  }
27
35
  }
@@ -10,7 +10,7 @@ export default class Logout extends Command {
10
10
  async run() {
11
11
  await this.parse(Logout);
12
12
  ux.action.start('Logging out');
13
- const cachedNetrcAccount = await AccountsModule.currentNetrc();
13
+ const cachedAccount = await AccountsModule.current(this.heroku);
14
14
  await this.heroku.logout();
15
15
  const git = new Git();
16
16
  try {
@@ -20,8 +20,8 @@ export default class Logout extends Command {
20
20
  catch {
21
21
  // ignore
22
22
  }
23
- if (cachedNetrcAccount) {
24
- await AccountsModule.remove(cachedNetrcAccount);
23
+ if (cachedAccount) {
24
+ await AccountsModule.remove(cachedAccount);
25
25
  }
26
26
  await this.config.runHook('recache', { type: 'logout' });
27
27
  ux.action.stop();
@@ -23,7 +23,7 @@ export default class AuthorizationsIndex extends Command {
23
23
  Description: { get: (v) => color.name(v.description) },
24
24
  ID: { get: (v) => v.id },
25
25
  Scope: { get: (v) => v.scope.join(',') },
26
- }, { sort: { Description: 'asc' } });
26
+ }, { sort: 'Description' });
27
27
  }
28
28
  }
29
29
  }
@@ -23,7 +23,7 @@ export default class ClientsIndex extends Command {
23
23
  id: {},
24
24
  redirect_uri: {},
25
25
  }, {
26
- sort: { name: 'asc' },
26
+ sort: 'name',
27
27
  });
28
28
  /* eslint-enable perfectionist/sort-objects */
29
29
  }
@@ -6,15 +6,15 @@ export default class DataPgMigrate extends BaseCommand {
6
6
  static description: string;
7
7
  static flags: {
8
8
  app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
- method: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ method: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
10
  remote: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
11
  };
12
12
  private advancedDatabases;
13
13
  private appName;
14
14
  private classicDatabases;
15
15
  private extendedLevelsInfo;
16
- private migrationMethod;
17
16
  private migrationTargets;
17
+ private selectedMigrationMethod?;
18
18
  createAddon(...args: Parameters<typeof createAddon>): Promise<Heroku.AddOn>;
19
19
  prompt<T extends inquirer.Answers>(...args: Parameters<typeof inquirer.prompt<T>>): Promise<T>;
20
20
  run(): Promise<void>;
@@ -7,7 +7,7 @@ import tsheredoc from 'tsheredoc';
7
7
  import createAddon from '../../../lib/addons/create-addon.js';
8
8
  import BaseCommand from '../../../lib/data/base-command.js';
9
9
  import PoolConfig from '../../../lib/data/pool-config.js';
10
- import { DatabaseStatus, MigrationStatus, } from '../../../lib/data/types.js';
10
+ import { DatabaseStatus, MigrationMethod, MigrationStatus, } from '../../../lib/data/types.js';
11
11
  import { fetchLevelsAndPricing } from '../../../lib/data/utils.js';
12
12
  import { getAttachmentNamesByAddon } from '../../../lib/pg/util.js';
13
13
  const heredoc = tsheredoc.default;
@@ -17,7 +17,6 @@ export default class DataPgMigrate extends BaseCommand {
17
17
  static flags = {
18
18
  app: Flags.app({ required: true }),
19
19
  method: Flags.string({
20
- default: 'snapshot',
21
20
  hidden: true,
22
21
  options: ['snapshot', 'streaming'],
23
22
  }),
@@ -27,8 +26,8 @@ export default class DataPgMigrate extends BaseCommand {
27
26
  appName;
28
27
  classicDatabases = [];
29
28
  extendedLevelsInfo;
30
- migrationMethod = 'full-load';
31
29
  migrationTargets = [];
30
+ selectedMigrationMethod;
32
31
  async createAddon(...args) {
33
32
  return createAddon(...args);
34
33
  }
@@ -39,7 +38,10 @@ export default class DataPgMigrate extends BaseCommand {
39
38
  const { flags } = await this.parse(DataPgMigrate);
40
39
  const { app, method } = flags;
41
40
  this.appName = app;
42
- this.migrationMethod = method === 'streaming' ? 'cdc' : 'full-load';
41
+ // If --method flag is provided, convert and store
42
+ if (method !== undefined) {
43
+ this.selectedMigrationMethod = method === 'streaming' ? MigrationMethod.CDC : MigrationMethod.FULL_LOAD;
44
+ }
43
45
  ux.stdout(heredoc `
44
46
 
45
47
  Migrate existing classic Heroku Postgres databases to Advanced databases
@@ -148,28 +150,121 @@ export default class DataPgMigrate extends BaseCommand {
148
150
  let sourceDatabaseId;
149
151
  let targetDatabaseId;
150
152
  let targetDatabaseName;
151
- while (currentStep !== '__exit') {
152
- switch (currentStep) {
153
- case '__confirm_migration': {
154
- ux.stdout(color.info(heredoc `
153
+ const confirmMigration = async () => {
154
+ ux.stdout(color.info(heredoc `
155
155
 
156
- By continuing, we prepare the necessary steps for the migration.
157
- Your source database is available while we prepare the migration.
158
- You'll receive an email when the preparation is complete or if there's an error.
159
- You have 24 hours to begin migration after the preparation is complete.
160
- Your source database will be unavailable during the migration.
161
- Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName)}.
156
+ By continuing, we prepare the necessary steps for the migration.
157
+ Your source database is available while we prepare the migration.
158
+ You'll receive an email when the preparation is complete or if there's an error.
159
+ You have 24 hours to begin the migration after the preparation is complete.
160
+ Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName)}.
162
161
 
163
- `));
164
- const { action } = await this.prompt({
165
- choices: [
166
- { name: 'Confirm', value: '__confirm' },
167
- { name: 'Go back', value: '__go_back' },
168
- ],
169
- message: 'Confirm migration configuration:',
170
- name: 'action',
171
- type: 'list',
162
+ `));
163
+ const { action } = await this.prompt({
164
+ choices: [
165
+ { name: 'Confirm', value: '__confirm' },
166
+ { name: 'Go back', value: '__go_back' },
167
+ ],
168
+ message: 'Confirm migration configuration:',
169
+ name: 'action',
170
+ type: 'list',
171
+ });
172
+ return action;
173
+ };
174
+ const selectMethod = async () => {
175
+ ux.stdout(color.info(heredoc `
176
+
177
+ Migration methods:
178
+ · Snapshot: Copies the data from the source database to the destination database. Requires downtime on the source database depending on the size. Best for smaller databases or when a maintenance window is acceptable.
179
+ · Streaming: Replicates changes from the source database to the destination database continuously until you start the migration. Requires minimal downtime. Best for larger databases or when you need near-zero downtime.
180
+
181
+ `));
182
+ const { method } = await this.prompt({
183
+ choices: [
184
+ { name: 'Snapshot', value: '__snapshot' },
185
+ { name: 'Streaming', value: '__streaming' },
186
+ new Separator(),
187
+ { name: 'Go back', value: '__go_back' },
188
+ ],
189
+ message: 'Select the migration method:',
190
+ name: 'method',
191
+ type: 'list',
192
+ });
193
+ return method;
194
+ };
195
+ const selectSource = async () => {
196
+ const choices = [];
197
+ for (const database of this.classicDatabases) {
198
+ const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
199
+ if (this.migrationTargets.some(migration => migration.source_id === database.id && this.isActiveMigration(migration))) {
200
+ choices.push({
201
+ disabled: 'already a source database for an active migration',
202
+ name: color.gray(name),
203
+ value: database.id,
204
+ });
205
+ }
206
+ else {
207
+ choices.push({
208
+ name,
209
+ value: database.id,
210
+ });
211
+ }
212
+ }
213
+ choices.push(new Separator(), { name: 'Go back', value: '__go_back' });
214
+ sourceDatabaseId = (await this.prompt({
215
+ choices,
216
+ message: 'Select the source database:',
217
+ name: 'database',
218
+ type: 'list',
219
+ })).database;
220
+ return sourceDatabaseId;
221
+ };
222
+ const selectTarget = async () => {
223
+ const choices = [];
224
+ for (const database of this.advancedDatabases) {
225
+ const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
226
+ if (this.migrationTargets.some(migration => migration.target_id === database.id && this.isActiveMigration(migration))) {
227
+ choices.push({
228
+ disabled: 'already a destination database for an active migration',
229
+ name: color.gray(name),
230
+ value: database.id,
172
231
  });
232
+ }
233
+ else if (database.info?.status === DatabaseStatus.AVAILABLE) {
234
+ choices.push({
235
+ name,
236
+ value: database.id,
237
+ });
238
+ }
239
+ else {
240
+ choices.push({
241
+ disabled: 'database isn\'t available',
242
+ name: color.gray(name),
243
+ value: database.id,
244
+ });
245
+ }
246
+ }
247
+ if (this.advancedDatabases.length === 0) {
248
+ choices.push({
249
+ disabled: true,
250
+ name: color.gray(`No Heroku Postgres Advanced databases available for migration on ${color.app(this.appName)}`),
251
+ value: '__no_advanced_databases',
252
+ });
253
+ }
254
+ choices.push(new Separator(), { name: 'Create a new Advanced database', value: '__create_database' }, { name: 'Go back', value: '__go_back' });
255
+ targetDatabaseId = (await this.prompt({
256
+ choices,
257
+ message: 'Select the destination database:',
258
+ name: 'database',
259
+ type: 'list',
260
+ })).database;
261
+ targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name;
262
+ return targetDatabaseId;
263
+ };
264
+ while (currentStep !== '__exit') {
265
+ switch (currentStep) {
266
+ case '__confirm_migration': {
267
+ const action = await confirmMigration();
173
268
  if (action === '__go_back') {
174
269
  currentStep = '__select_target';
175
270
  }
@@ -177,81 +272,34 @@ export default class DataPgMigrate extends BaseCommand {
177
272
  ux.stdout('');
178
273
  ux.action.start('Configuring migration');
179
274
  await this.dataApi.post(`/data/postgres/v1/${targetDatabaseId}/migrations`, {
180
- body: { method: this.migrationMethod, source_id: sourceDatabaseId },
275
+ body: {
276
+ method: this.selectedMigrationMethod,
277
+ source_id: sourceDatabaseId,
278
+ },
181
279
  });
182
280
  ux.action.stop();
183
281
  currentStep = '__exit';
184
282
  }
185
283
  break;
186
284
  }
187
- case '__select_source': {
188
- const choices = [];
189
- for (const database of this.classicDatabases) {
190
- const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
191
- if (this.migrationTargets.some(migration => migration.source_id === database.id && this.isActiveMigration(migration))) {
192
- choices.push({
193
- disabled: 'already a source database for an active migration',
194
- name: color.gray(name),
195
- value: database.id,
196
- });
197
- }
198
- else {
199
- choices.push({
200
- name,
201
- value: database.id,
202
- });
203
- }
285
+ case '__select_method': {
286
+ const method = await selectMethod();
287
+ if (method === '__go_back') {
288
+ currentStep = '__select_target';
204
289
  }
205
- choices.push(new Separator(), { name: 'Go back', value: '__go_back' });
206
- sourceDatabaseId = (await this.prompt({
207
- choices,
208
- message: 'Select the source database:',
209
- name: 'database',
210
- type: 'list',
211
- })).database;
290
+ else {
291
+ this.selectedMigrationMethod = method === '__snapshot' ? MigrationMethod.FULL_LOAD : MigrationMethod.CDC;
292
+ currentStep = '__confirm_migration';
293
+ }
294
+ break;
295
+ }
296
+ case '__select_source': {
297
+ const sourceDatabaseId = await selectSource();
212
298
  currentStep = sourceDatabaseId === '__go_back' ? '__exit' : '__select_target';
213
299
  break;
214
300
  }
215
301
  case '__select_target': {
216
- const choices = [];
217
- for (const database of this.advancedDatabases) {
218
- const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
219
- if (this.migrationTargets.some(migration => migration.target_id === database.id && this.isActiveMigration(migration))) {
220
- choices.push({
221
- disabled: 'already a destination database for an active migration',
222
- name: color.gray(name),
223
- value: database.id,
224
- });
225
- }
226
- else if (database.info?.status === DatabaseStatus.AVAILABLE) {
227
- choices.push({
228
- name,
229
- value: database.id,
230
- });
231
- }
232
- else {
233
- choices.push({
234
- disabled: 'database isn\'t available',
235
- name: color.gray(name),
236
- value: database.id,
237
- });
238
- }
239
- }
240
- if (this.advancedDatabases.length === 0) {
241
- choices.push({
242
- disabled: true,
243
- name: color.gray(`No Heroku Postgres Advanced databases available for migration on ${color.app(this.appName)}`),
244
- value: '__no_advanced_databases',
245
- });
246
- }
247
- choices.push(new Separator(), { name: 'Create a new Advanced database', value: '__create_database' }, { name: 'Go back', value: '__go_back' });
248
- targetDatabaseId = (await this.prompt({
249
- choices,
250
- message: 'Select the destination database:',
251
- name: 'database',
252
- type: 'list',
253
- })).database;
254
- targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name;
302
+ await selectTarget();
255
303
  if (targetDatabaseId === '__go_back') {
256
304
  currentStep = '__select_source';
257
305
  }
@@ -260,14 +308,14 @@ export default class DataPgMigrate extends BaseCommand {
260
308
  if (addon) {
261
309
  targetDatabaseId = addon.id;
262
310
  targetDatabaseName = addon.name;
263
- currentStep = '__confirm_migration';
311
+ currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration';
264
312
  }
265
313
  else {
266
314
  currentStep = '__select_target';
267
315
  }
268
316
  }
269
317
  else {
270
- currentStep = '__confirm_migration';
318
+ currentStep = this.selectedMigrationMethod === undefined ? '__select_method' : '__confirm_migration';
271
319
  }
272
320
  break;
273
321
  }
@@ -185,7 +185,7 @@ www.example.com CNAME www.example.herokudns.com`];
185
185
  else {
186
186
  hux.table(customDomains, tableConfig, {
187
187
  ...huxTableNoWrapOptions(flags['no-wrap']),
188
- sort: flags.sort ? { [sortProperty]: 'asc' } : undefined,
188
+ sort: flags.sort,
189
189
  });
190
190
  }
191
191
  }
@@ -147,7 +147,7 @@ export default class Index extends Command {
147
147
  }
148
148
  hux.styledHeader(header);
149
149
  const sortedReleases = releases.sort((a, b) => (b.version ?? 0) - (a.version ?? 0));
150
- hux.table(sortedReleases, columns);
150
+ hux.table(sortedReleases, columns, { extended });
151
151
  }
152
152
  }
153
153
  }
@@ -4,7 +4,7 @@ export interface AccountEntry {
4
4
  username: string;
5
5
  }
6
6
  export interface IAccountsWrapper {
7
- add(name: string, username: string, password: string): void;
7
+ add(name: string, username: string, password?: string): void;
8
8
  current(heroku: APIClient): Promise<null | string>;
9
9
  currentNetrc(): Promise<null | string>;
10
10
  getStorageConfig(): ReturnType<typeof getStorageConfig>;
@@ -15,7 +15,7 @@ export interface IAccountsWrapper {
15
15
  }
16
16
  export declare class AccountsWrapper implements IAccountsWrapper {
17
17
  private netrc;
18
- add(name: string, username: string, password: string): void;
18
+ add(name: string, username: string, password?: string): void;
19
19
  current(heroku: APIClient): Promise<null | string>;
20
20
  currentNetrc(): Promise<null | string>;
21
21
  getStorageConfig(): import("@heroku-cli/command").StorageConfig;
@@ -8,8 +8,10 @@ export class AccountsWrapper {
8
8
  netrc;
9
9
  add(name, username, password) {
10
10
  fs.mkdirSync(this.accountsDir(), { recursive: true });
11
- // eslint-disable-next-line perfectionist/sort-objects
12
- this.writeAccountFile(name, { username, password });
11
+ const content = password === undefined
12
+ ? { username }
13
+ : { username, password }; // eslint-disable-line perfectionist/sort-objects
14
+ this.writeAccountFile(name, content);
13
15
  }
14
16
  async current(heroku) {
15
17
  const config = this.getStorageConfig();
@@ -58,11 +60,15 @@ export class AccountsWrapper {
58
60
  const config = this.getStorageConfig();
59
61
  if (account.name) {
60
62
  if (config.credentialStore) {
63
+ // Keychain mode: only update login state, skip netrc
61
64
  const email = this.getAliasEmail(account.name);
62
- if (email) {
63
- await this.writeLoginState(dataDir, email);
65
+ if (!email) {
66
+ throw new Error(`We can't find the alias file for ${account.name}.`);
64
67
  }
68
+ await this.writeLoginState(dataDir, email);
69
+ return;
65
70
  }
71
+ // Netrc mode: update both login state and netrc files
66
72
  const netrcInstance = await this.initNetrc();
67
73
  let current;
68
74
  try {
@@ -30,6 +30,10 @@ export declare enum MigrationStatus {
30
30
  READY = "ready",
31
31
  UNKNOWN = "unknown"
32
32
  }
33
+ export declare enum MigrationMethod {
34
+ CDC = "cdc",
35
+ FULL_LOAD = "full-load"
36
+ }
33
37
  export declare enum PoolStatus {
34
38
  AVAILABLE = "available",
35
39
  MODIFYING = "modifying",
@@ -34,6 +34,11 @@ export var MigrationStatus;
34
34
  MigrationStatus["READY"] = "ready";
35
35
  MigrationStatus["UNKNOWN"] = "unknown";
36
36
  })(MigrationStatus || (MigrationStatus = {}));
37
+ export var MigrationMethod;
38
+ (function (MigrationMethod) {
39
+ MigrationMethod["CDC"] = "cdc";
40
+ MigrationMethod["FULL_LOAD"] = "full-load";
41
+ })(MigrationMethod || (MigrationMethod = {}));
37
42
  export var PoolStatus;
38
43
  (function (PoolStatus) {
39
44
  PoolStatus["AVAILABLE"] = "available";
@@ -26,7 +26,7 @@ export const constructSortFilterTableOptions = (flags, tableColumns) => {
26
26
  if (!columnNames.has(lowerCaseSort)) {
27
27
  throw new Error(`Invalid sort key: ${sort}`);
28
28
  }
29
- tableOptions.sort = { [lowerCaseSort]: 'asc' };
29
+ tableOptions.sort = lowerCaseSort;
30
30
  }
31
31
  return tableOptions;
32
32
  };