@heroku/sdk 0.6.2 → 0.6.4

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.
@@ -1,11 +1,8 @@
1
- import type { DatabaseInfoResult } from '@heroku/types/data';
1
+ import type { DatabaseCancelUpgradeResult, DatabaseDryRunUpgradeOpts, DatabaseDryRunUpgradeResult, DatabaseInfoResult, DatabasePrepareUpgradeOpts, DatabasePrepareUpgradeResult, DatabaseRunUpgradeOpts, DatabaseRunUpgradeResult, DatabaseUpgradeWaitStatusResult } from '@heroku/types/data';
2
2
  import type { ResourceCtx } from '../../core/extend-resource.js';
3
3
  export type DatabaseOptions = {
4
4
  signal?: AbortSignal;
5
5
  };
6
- export type DatabaseUpgradeBody = {
7
- version?: string;
8
- };
9
6
  export type DatabaseDescribeResult = DatabaseInfoResult & {
10
7
  following?: string;
11
8
  'hot_standby?'?: boolean;
@@ -14,41 +11,17 @@ export type DatabaseDescribeResult = DatabaseInfoResult & {
14
11
  postgres_version?: string;
15
12
  'standalone?'?: boolean;
16
13
  };
17
- export type DatabaseUpgradeWaitResult = {
18
- 'error?': boolean;
19
- message: string;
20
- step: string;
21
- 'waiting?': boolean;
22
- };
23
- export type DatabaseUpgradeResponse = {
24
- message: string;
25
- };
26
- type DatabaseDataClient = {
27
- database: {
28
- cancelUpgrade(name: string): Promise<DatabaseUpgradeResponse>;
29
- dryRunUpgrade(name: string, body: DatabaseUpgradeBody): Promise<DatabaseUpgradeResponse>;
30
- info(name: string): Promise<DatabaseDescribeResult>;
31
- prepareUpgrade(name: string, body: DatabaseUpgradeBody): Promise<DatabaseUpgradeResponse>;
32
- runUpgrade(name: string, body: DatabaseUpgradeBody): Promise<DatabaseUpgradeResponse>;
33
- upgradeWaitStatus(name: string): Promise<DatabaseUpgradeWaitResult>;
34
- };
35
- };
36
- type DatabaseCtx = {
37
- data: DatabaseDataClient;
38
- platform: ResourceCtx['platform'];
39
- };
40
- export declare function describe(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseDescribeResult>;
41
- export declare function upgradeWaitStatus(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseUpgradeWaitResult>;
42
- export declare function dryRunUpgrade(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions): Promise<DatabaseUpgradeResponse>;
43
- export declare function runUpgrade(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions): Promise<DatabaseUpgradeResponse>;
44
- export declare function prepareUpgrade(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions): Promise<DatabaseUpgradeResponse>;
45
- export declare function cancelUpgrade(ctx: DatabaseCtx, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseUpgradeResponse>;
14
+ export declare function describe(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseDescribeResult>;
15
+ export declare function upgradeWaitStatus(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseUpgradeWaitStatusResult>;
16
+ export declare function dryRunUpgrade(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, body?: DatabaseDryRunUpgradeOpts, options?: DatabaseOptions): Promise<DatabaseDryRunUpgradeResult>;
17
+ export declare function runUpgrade(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, body?: DatabaseRunUpgradeOpts, options?: DatabaseOptions): Promise<DatabaseRunUpgradeResult>;
18
+ export declare function prepareUpgrade(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, body?: DatabasePrepareUpgradeOpts, options?: DatabaseOptions): Promise<DatabasePrepareUpgradeResult>;
19
+ export declare function cancelUpgrade(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: DatabaseOptions): Promise<DatabaseCancelUpgradeResult>;
46
20
  export declare const databaseExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "database", {
47
- cancelUpgrade: (appIdentity: string, addonIdentity?: string, options?: DatabaseOptions) => Promise<DatabaseUpgradeResponse>;
21
+ cancelUpgrade: (appIdentity: string, addonIdentity?: string, options?: DatabaseOptions) => Promise<DatabaseCancelUpgradeResult>;
48
22
  describe: (appIdentity: string, addonIdentity?: string, options?: DatabaseOptions) => Promise<DatabaseDescribeResult>;
49
- dryRunUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions) => Promise<DatabaseUpgradeResponse>;
50
- prepareUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions) => Promise<DatabaseUpgradeResponse>;
51
- runUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabaseUpgradeBody, options?: DatabaseOptions) => Promise<DatabaseUpgradeResponse>;
52
- upgradeWaitStatus: (appIdentity: string, addonIdentity?: string, options?: DatabaseOptions) => Promise<DatabaseUpgradeWaitResult>;
23
+ dryRunUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabaseDryRunUpgradeOpts, options?: DatabaseOptions) => Promise<DatabaseDryRunUpgradeResult>;
24
+ prepareUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabasePrepareUpgradeOpts, options?: DatabaseOptions) => Promise<DatabasePrepareUpgradeResult>;
25
+ runUpgrade: (appIdentity: string, addonIdentity?: string, body?: DatabaseRunUpgradeOpts, options?: DatabaseOptions) => Promise<DatabaseRunUpgradeResult>;
26
+ upgradeWaitStatus: (appIdentity: string, addonIdentity?: string, options?: DatabaseOptions) => Promise<DatabaseUpgradeWaitStatusResult>;
53
27
  }>;
54
- export {};
@@ -30,15 +30,11 @@ export async function cancelUpgrade(ctx, appIdentity, addonIdentity, options = {
30
30
  const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
31
31
  return ctx.data.database.cancelUpgrade(addon.id);
32
32
  }
33
- export const databaseExtensions = extendResource('data', 'database', rawCtx => {
34
- // Bridge heroku-types' loose client types to the adapter's strongly-typed contract
35
- const ctx = rawCtx;
36
- return {
37
- cancelUpgrade: (appIdentity, addonIdentity, options) => cancelUpgrade(ctx, appIdentity, addonIdentity, options),
38
- describe: (appIdentity, addonIdentity, options) => describe(ctx, appIdentity, addonIdentity, options),
39
- dryRunUpgrade: (appIdentity, addonIdentity, body, options) => dryRunUpgrade(ctx, appIdentity, addonIdentity, body, options),
40
- prepareUpgrade: (appIdentity, addonIdentity, body, options) => prepareUpgrade(ctx, appIdentity, addonIdentity, body, options),
41
- runUpgrade: (appIdentity, addonIdentity, body, options) => runUpgrade(ctx, appIdentity, addonIdentity, body, options),
42
- upgradeWaitStatus: (appIdentity, addonIdentity, options) => upgradeWaitStatus(ctx, appIdentity, addonIdentity, options),
43
- };
44
- });
33
+ export const databaseExtensions = extendResource('data', 'database', ctx => ({
34
+ cancelUpgrade: (appIdentity, addonIdentity, options) => cancelUpgrade(ctx, appIdentity, addonIdentity, options),
35
+ describe: (appIdentity, addonIdentity, options) => describe(ctx, appIdentity, addonIdentity, options),
36
+ dryRunUpgrade: (appIdentity, addonIdentity, body, options) => dryRunUpgrade(ctx, appIdentity, addonIdentity, body, options),
37
+ prepareUpgrade: (appIdentity, addonIdentity, body, options) => prepareUpgrade(ctx, appIdentity, addonIdentity, body, options),
38
+ runUpgrade: (appIdentity, addonIdentity, body, options) => runUpgrade(ctx, appIdentity, addonIdentity, body, options),
39
+ upgradeWaitStatus: (appIdentity, addonIdentity, options) => upgradeWaitStatus(ctx, appIdentity, addonIdentity, options),
40
+ }));
@@ -16,17 +16,7 @@ export type CredentialInfo = {
16
16
  state: string;
17
17
  uuid: string;
18
18
  };
19
- type CredentialsDataClient = {
20
- postgresDatabase: {
21
- listCredentials(name: string): Promise<CredentialInfo[]>;
22
- };
23
- };
24
- type CredentialsCtx = {
25
- data: CredentialsDataClient;
26
- platform: ResourceCtx['platform'];
27
- };
28
- export declare function listCredentials(ctx: CredentialsCtx, appIdentity: string, addonIdentity?: string, options?: ListCredentialsOptions): Promise<CredentialInfo[]>;
19
+ export declare function listCredentials(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: ListCredentialsOptions): Promise<CredentialInfo[]>;
29
20
  export declare const postgresDatabaseExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresDatabase", {
30
21
  listCredentials: (appIdentity: string, addonIdentity?: string, options?: ListCredentialsOptions) => Promise<CredentialInfo[]>;
31
22
  }>;
32
- export {};
@@ -5,10 +5,6 @@ export async function listCredentials(ctx, appIdentity, addonIdentity, options =
5
5
  const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
6
6
  return ctx.data.postgresDatabase.listCredentials(addon.id);
7
7
  }
8
- export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', rawCtx => {
9
- // Bridge heroku-types' loose client types to the adapter's strongly-typed contract
10
- const ctx = rawCtx;
11
- return {
12
- listCredentials: (appIdentity, addonIdentity, options) => listCredentials(ctx, appIdentity, addonIdentity, options),
13
- };
14
- });
8
+ export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', ctx => ({
9
+ listCredentials: (appIdentity, addonIdentity, options) => listCredentials(ctx, appIdentity, addonIdentity, options),
10
+ }));
@@ -1,49 +1,9 @@
1
+ import type { TransferListByAppResult } from '@heroku/types/data';
1
2
  import type { ResourceCtx } from '../../core/extend-resource.js';
2
3
  export type TransferOptions = {
3
4
  signal?: AbortSignal;
4
5
  };
5
- export type BackupTransfer = {
6
- canceled_at: string;
7
- created_at: string;
8
- deleted_at: string;
9
- finished_at: string;
10
- from_name: string;
11
- from_type: string;
12
- from_url: string;
13
- logs: Array<{
14
- created_at: string;
15
- level: string;
16
- message: string;
17
- }>;
18
- num: number;
19
- num_keep: number;
20
- options: Record<string, unknown>;
21
- processed_bytes: number;
22
- purged_at: string;
23
- schedule?: {
24
- uuid: string;
25
- };
26
- source_bytes: number;
27
- started_at: string;
28
- succeeded: boolean;
29
- to_name: string;
30
- to_type: string;
31
- to_url: string;
32
- updated_at: string;
33
- uuid: string;
34
- warnings: number;
35
- };
36
- type TransferDataClient = {
37
- transfer: {
38
- listByApp(name: string): Promise<BackupTransfer[]>;
39
- };
40
- };
41
- type TransferCtx = {
42
- data: TransferDataClient;
43
- platform: ResourceCtx['platform'];
44
- };
45
- export declare function listByApp(ctx: TransferCtx, appIdentity: string, addonIdentity?: string, options?: TransferOptions): Promise<BackupTransfer[]>;
6
+ export declare function listByApp(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, addonIdentity?: string, options?: TransferOptions): Promise<TransferListByAppResult>;
46
7
  export declare const transferExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "transfer", {
47
- listByApp: (appIdentity: string, addonIdentity?: string, options?: TransferOptions) => Promise<BackupTransfer[]>;
8
+ listByApp: (appIdentity: string, addonIdentity?: string, options?: TransferOptions) => Promise<TransferListByAppResult>;
48
9
  }>;
49
- export {};
@@ -14,10 +14,6 @@ export async function listByApp(ctx, appIdentity, addonIdentity, options = {}) {
14
14
  }
15
15
  return ctx.data.transfer.listByApp(addon.id);
16
16
  }
17
- export const transferExtensions = extendResource('data', 'transfer', rawCtx => {
18
- // Bridge heroku-types' loose client types to the adapter's strongly-typed contract
19
- const ctx = rawCtx;
20
- return {
21
- listByApp: (appIdentity, addonIdentity, options) => listByApp(ctx, appIdentity, addonIdentity, options),
22
- };
23
- });
17
+ export const transferExtensions = extendResource('data', 'transfer', ctx => ({
18
+ listByApp: (appIdentity, addonIdentity, options) => listByApp(ctx, appIdentity, addonIdentity, options),
19
+ }));
@@ -2,7 +2,7 @@ export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions, type AppInfo, type DiffAppsOptions, type DiffRow, type PipelineCouplingDetail, } from '../platform/app/index.js';
3
3
  export { containerExtensions, type DockerReleasesBatchUpdateOpts, NotAContainerAppError, type ReleaseDockerImagesResult, type RemoveProcessTypesOpts, } from '../platform/container/index.js';
4
4
  export { type CreateAndWaitOptions, domainExtensions, type DomainOptions, type WaitForReadyOptions, } from '../platform/domain/index.js';
5
- export { dynoExtensions, DynoNotReadyError, type DynoState, runDyno, type RunDynoOptions, waitForInfo, type WaitForInfoOptions, } from '../platform/dyno/index.js';
5
+ export { DynoCrashedError, type DynoExtended, type DynoExtendedFields, dynoExtensions, DynoNotReadyError, type DynoState, enableExec, exchangeExecCredentials, type ExchangeExecCredentialsOptions, type ExecCredentials, type ExecOptions, type ExecPrereqs, execPrereqs, type ExecReservation, execStatus, type ExecStatusOptions, listExtended, type ListExtendedOptions, restartForExec, type RestartForExecOptions, runDyno, type RunDynoOptions, waitForInfo, type WaitForInfoOptions, waitForRelease, type WaitForReleaseOptions, type WaitForReleaseProgress, type WaitForReleaseResult, } from '../platform/dyno/index.js';
6
6
  export { type AttachmentAttachedEvent, type AttachmentDetachedEvent, type AttachmentUpdatedEvent, type HerokuLogEvent, logSessionExtensions, parseHerokuLogLine, type ProvisioningCompletedEvent, type ScaledToEntry, type ScaledToEvent, type StartingProcessEvent, type StateChangedEvent, } from '../platform/log-session/index.js';
7
7
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
8
8
  export { pipelineExtensions } from '../platform/pipeline/index.js';
@@ -2,7 +2,7 @@ export { addOnExtensions } from '../platform/add-on/index.js';
2
2
  export { appExtensions, } from '../platform/app/index.js';
3
3
  export { containerExtensions, NotAContainerAppError, } from '../platform/container/index.js';
4
4
  export { domainExtensions, } from '../platform/domain/index.js';
5
- export { dynoExtensions, DynoNotReadyError, runDyno, waitForInfo, } from '../platform/dyno/index.js';
5
+ export { DynoCrashedError, dynoExtensions, DynoNotReadyError, enableExec, exchangeExecCredentials, execPrereqs, execStatus, listExtended, restartForExec, runDyno, waitForInfo, waitForRelease, } from '../platform/dyno/index.js';
6
6
  export { logSessionExtensions, parseHerokuLogLine, } from '../platform/log-session/index.js';
7
7
  export { pipelineCouplingExtensions } from '../platform/pipeline/coupling.js';
8
8
  export { pipelineExtensions } from '../platform/pipeline/index.js';
@@ -0,0 +1,134 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
+ import type { App, BuildpackInstallation, ConfigVar, Dyno } from '@heroku/types/3.sdk';
3
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
4
+ import { type WaitForInfoOptions } from './wait-for-info.js';
5
+ export type ExecOptions = {
6
+ signal?: AbortSignal;
7
+ };
8
+ /**
9
+ * Raw facts the CLI needs to decide whether — and how — Heroku Exec can
10
+ * be enabled for an app. This helper only gathers; every branch
11
+ * (Shield restriction, container-stack warning, missing-buildpack
12
+ * error, the deprecated `HEROKU_EXEC_URL` add-on notice) stays in the
13
+ * caller so the SDK owns no presentation or prompting.
14
+ */
15
+ export type ExecPrereqs = {
16
+ /** Installed buildpacks, for the exec-buildpack presence check. */
17
+ buildpacks: BuildpackInstallation[];
18
+ /** `build_stack.name`, e.g. `'container'`. */
19
+ buildStack: string | undefined;
20
+ /** Full config-var map; the caller reads `HEROKU_EXEC_URL` off it. */
21
+ configVars: ConfigVar;
22
+ /** Whether the `runtime-heroku-exec` feature is already enabled. */
23
+ featureEnabled: boolean;
24
+ /** App generation; `'fir'` apps do not support exec. */
25
+ generation: string;
26
+ /** App's Private Space, if any (carries `shield`). */
27
+ space: App['space'];
28
+ };
29
+ /**
30
+ * Gather the app facts that gate Heroku Exec in one parallel round-trip:
31
+ * app info, buildpack installations, config vars, and the exec feature.
32
+ *
33
+ * Returns raw facts only — the caller branches on them. `configVars` is
34
+ * returned whole because the caller inspects `HEROKU_EXEC_URL` (the
35
+ * deprecated add-on) as well as passing config through to the exchange.
36
+ */
37
+ export declare function execPrereqs(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ExecOptions): Promise<ExecPrereqs>;
38
+ /**
39
+ * Enable the `runtime-heroku-exec` feature on an app. Used both on the
40
+ * first-run restart path and on the Private-Space buildpack path, so it
41
+ * stays a bare enable — the caller decides whether a restart follows.
42
+ */
43
+ export declare function enableExec(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ExecOptions): Promise<void>;
44
+ /**
45
+ * Thrown by {@link restartForExec} the moment the awaited dyno reports
46
+ * `crashed`, so the caller can surface the failure immediately rather
47
+ * than waiting out the remaining poll attempts.
48
+ */
49
+ export declare class DynoCrashedError extends Error {
50
+ readonly dyno: Dyno;
51
+ readonly id = "dyno_crashed";
52
+ constructor(dyno: Dyno);
53
+ }
54
+ export type RestartForExecOptions = WaitForInfoOptions & {
55
+ /**
56
+ * Milliseconds to wait after enabling the feature before restarting,
57
+ * letting the enable propagate. Defaults to 2000, matching the
58
+ * historical CLI behavior.
59
+ */
60
+ settleMs?: number;
61
+ };
62
+ /**
63
+ * Restart all of an app's dynos and wait for the named dyno to come
64
+ * back `up`. Enabling Heroku Exec for the first time requires a dyno
65
+ * restart, so this bundles the settle delay, `dyno.restartAll`, and the
66
+ * wait-for-`up` poll into one step. A `crashed` dyno throws
67
+ * {@link DynoCrashedError} at once instead of exhausting the attempts.
68
+ *
69
+ * The wait is bounded by {@link WaitForInfoOptions.attempts} (default
70
+ * 20). Callers that need to tolerate a slow boot should raise it.
71
+ */
72
+ export declare function restartForExec(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, dynoIdentity: string, options?: RestartForExecOptions): Promise<Dyno>;
73
+ /** Connection info the exec-manager returns for a client-key exchange. */
74
+ export type ExecCredentials = {
75
+ client_user: string;
76
+ dyno_ip: string;
77
+ proxy_public_key: string;
78
+ tunnel_host: string;
79
+ };
80
+ export type ExchangeExecCredentialsOptions = {
81
+ /** API key used as the Basic-auth password (username is the app). */
82
+ apiKey: string;
83
+ /**
84
+ * Options forwarded to the raw `HerokuApiClient`. The exec-manager is
85
+ * not in the routes registry, so the call is issued via a bare client
86
+ * with `service: 'custom'`.
87
+ */
88
+ clientOptions?: Omit<HerokuApiClientOptions, 'baseUrl' | 'service' | 'token'>;
89
+ /**
90
+ * App config vars. When `HEROKU_EXEC_URL` is present (the deprecated
91
+ * add-on), its embedded credentials and the `/api/v1` prefix are used.
92
+ */
93
+ configVars?: ConfigVar;
94
+ /** Target dyno, e.g. `'web.1'`. */
95
+ dyno: string;
96
+ /** Extra request headers (e.g. from `HEROKU_HEADERS`). */
97
+ headers?: Record<string, string>;
98
+ /** OpenSSH-format public key generated by the caller. */
99
+ publicKey: string;
100
+ signal?: AbortSignal;
101
+ };
102
+ /**
103
+ * Exchange a client public key for exec connection info.
104
+ *
105
+ * Issues `PUT {execHost}{apiPath}/{dyno}` with `{client_key}` and Basic
106
+ * auth (`app:apiKey`, or the credentials embedded in `HEROKU_EXEC_URL`)
107
+ * via a bare `HerokuApiClient` — the exec-manager is a custom host with
108
+ * no routes entry and no bearer token (the empty `token` lets the Basic
109
+ * `Authorization` header survive). The caller feeds the returned info,
110
+ * plus the private key it holds, to its own SSH transport; the SDK does
111
+ * not open the connection.
112
+ */
113
+ export declare function exchangeExecCredentials(appIdentity: string, options: ExchangeExecCredentialsOptions): Promise<ExecCredentials>;
114
+ /** A running exec reservation, as reported by the exec-manager status endpoint. */
115
+ export type ExecReservation = {
116
+ [key: string]: unknown;
117
+ dyno_name: string;
118
+ };
119
+ export type ExecStatusOptions = {
120
+ apiKey: string;
121
+ clientOptions?: Omit<HerokuApiClientOptions, 'baseUrl' | 'service' | 'token'>;
122
+ configVars?: ConfigVar;
123
+ headers?: Record<string, string>;
124
+ signal?: AbortSignal;
125
+ };
126
+ /**
127
+ * List the app's running exec reservations.
128
+ *
129
+ * Issues `GET {execHost}{apiPath}` (no dyno segment) with the same
130
+ * custom-host Basic-auth setup as {@link exchangeExecCredentials}. The
131
+ * caller correlates each reservation's `dyno_name` with live dyno state
132
+ * for display.
133
+ */
134
+ export declare function execStatus(appIdentity: string, options: ExecStatusOptions): Promise<ExecReservation[]>;
@@ -0,0 +1,187 @@
1
+ import { HerokuApiClient, NotFoundError } from '@heroku/heroku-fetch';
2
+ import { wait } from '../../../utils/wait.js';
3
+ import { waitForInfo } from './wait-for-info.js';
4
+ /** The app feature that gates Heroku Exec. */
5
+ const EXEC_FEATURE = 'runtime-heroku-exec';
6
+ /** Default exec-manager host used when no override is configured. */
7
+ const DEFAULT_EXEC_HOST = 'https://exec-manager.heroku.com/';
8
+ /** Exec-manager API prefixes. v1 is the legacy add-on path; v2 is current. */
9
+ const EXEC_API_PATH_LEGACY = '/api/v1';
10
+ const EXEC_API_PATH_CURRENT = '/api/v2';
11
+ /** Milliseconds to let the feature-enable settle before restarting dynos. */
12
+ const DEFAULT_SETTLE_MS = 2000;
13
+ /**
14
+ * Gather the app facts that gate Heroku Exec in one parallel round-trip:
15
+ * app info, buildpack installations, config vars, and the exec feature.
16
+ *
17
+ * Returns raw facts only — the caller branches on them. `configVars` is
18
+ * returned whole because the caller inspects `HEROKU_EXEC_URL` (the
19
+ * deprecated add-on) as well as passing config through to the exchange.
20
+ */
21
+ export async function execPrereqs(ctx, appIdentity, options = {}) {
22
+ options.signal?.throwIfAborted();
23
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
24
+ const [app, buildpacks, configVars, feature] = await Promise.all([
25
+ platform.app.info(appIdentity),
26
+ platform.buildpackInstallation.list(appIdentity),
27
+ platform.configVar.infoForApp(appIdentity),
28
+ // fir apps have no runtime-heroku-exec feature, so this 404s there. A
29
+ // missing feature must not sink the whole gather — the caller reads
30
+ // `generation` to reject fir. Only a 404 degrades to disabled; other
31
+ // errors still propagate.
32
+ platform.appFeature.info(appIdentity, EXEC_FEATURE).catch((error) => {
33
+ if (error instanceof NotFoundError)
34
+ return { enabled: false };
35
+ throw error;
36
+ }),
37
+ ]);
38
+ return {
39
+ buildpacks,
40
+ buildStack: app.build_stack?.name,
41
+ configVars,
42
+ featureEnabled: Boolean(feature.enabled),
43
+ generation: app.generation,
44
+ space: app.space,
45
+ };
46
+ }
47
+ /**
48
+ * Enable the `runtime-heroku-exec` feature on an app. Used both on the
49
+ * first-run restart path and on the Private-Space buildpack path, so it
50
+ * stays a bare enable — the caller decides whether a restart follows.
51
+ */
52
+ export async function enableExec(ctx, appIdentity, options = {}) {
53
+ options.signal?.throwIfAborted();
54
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
55
+ await platform.appFeature.update(appIdentity, EXEC_FEATURE, { enabled: true });
56
+ }
57
+ /**
58
+ * Thrown by {@link restartForExec} the moment the awaited dyno reports
59
+ * `crashed`, so the caller can surface the failure immediately rather
60
+ * than waiting out the remaining poll attempts.
61
+ */
62
+ export class DynoCrashedError extends Error {
63
+ dyno;
64
+ id = 'dyno_crashed';
65
+ constructor(dyno) {
66
+ super(`Dyno '${dyno.name}' crashed while waiting for Heroku Exec to start.`);
67
+ this.dyno = dyno;
68
+ this.name = 'DynoCrashedError';
69
+ }
70
+ }
71
+ /**
72
+ * Restart all of an app's dynos and wait for the named dyno to come
73
+ * back `up`. Enabling Heroku Exec for the first time requires a dyno
74
+ * restart, so this bundles the settle delay, `dyno.restartAll`, and the
75
+ * wait-for-`up` poll into one step. A `crashed` dyno throws
76
+ * {@link DynoCrashedError} at once instead of exhausting the attempts.
77
+ *
78
+ * The wait is bounded by {@link WaitForInfoOptions.attempts} (default
79
+ * 20). Callers that need to tolerate a slow boot should raise it.
80
+ */
81
+ export async function restartForExec(ctx, appIdentity, dynoIdentity, options = {}) {
82
+ const { settleMs = DEFAULT_SETTLE_MS, ...waitOptions } = options;
83
+ const { signal } = waitOptions;
84
+ signal?.throwIfAborted();
85
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
86
+ await wait(settleMs, signal);
87
+ await platform.dyno.restartAll(appIdentity);
88
+ return waitForInfo(ctx, appIdentity, dynoIdentity, {
89
+ ...waitOptions,
90
+ onPoll(dyno) {
91
+ if (dyno.state === 'crashed') {
92
+ throw new DynoCrashedError(dyno);
93
+ }
94
+ waitOptions.onPoll?.(dyno);
95
+ },
96
+ states: ['up'],
97
+ });
98
+ }
99
+ /**
100
+ * Exchange a client public key for exec connection info.
101
+ *
102
+ * Issues `PUT {execHost}{apiPath}/{dyno}` with `{client_key}` and Basic
103
+ * auth (`app:apiKey`, or the credentials embedded in `HEROKU_EXEC_URL`)
104
+ * via a bare `HerokuApiClient` — the exec-manager is a custom host with
105
+ * no routes entry and no bearer token (the empty `token` lets the Basic
106
+ * `Authorization` header survive). The caller feeds the returned info,
107
+ * plus the private key it holds, to its own SSH transport; the SDK does
108
+ * not open the connection.
109
+ */
110
+ export async function exchangeExecCredentials(appIdentity, options) {
111
+ options.signal?.throwIfAborted();
112
+ const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
113
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions);
114
+ const path = `${endpoint.apiPath}/${encodeURIComponent(options.dyno)}`;
115
+ const response = await client.put(path, { client_key: options.publicKey }, {
116
+ headers: {
117
+ ...options.headers,
118
+ Authorization: endpoint.authorization,
119
+ 'Content-Type': 'application/json',
120
+ },
121
+ signal: options.signal,
122
+ });
123
+ return parseExecBody(response);
124
+ }
125
+ /**
126
+ * List the app's running exec reservations.
127
+ *
128
+ * Issues `GET {execHost}{apiPath}` (no dyno segment) with the same
129
+ * custom-host Basic-auth setup as {@link exchangeExecCredentials}. The
130
+ * caller correlates each reservation's `dyno_name` with live dyno state
131
+ * for display.
132
+ */
133
+ export async function execStatus(appIdentity, options) {
134
+ options.signal?.throwIfAborted();
135
+ const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
136
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions);
137
+ const response = await client.get(endpoint.apiPath, {
138
+ headers: { ...options.headers, Authorization: endpoint.authorization },
139
+ signal: options.signal,
140
+ });
141
+ return parseExecBody(response);
142
+ }
143
+ /**
144
+ * Resolve the exec-manager base URL, API prefix, and Basic-auth header.
145
+ *
146
+ * When `HEROKU_EXEC_URL` is set as a config var (the deprecated add-on),
147
+ * that URL carries its own embedded credentials and uses the `/api/v1`
148
+ * prefix. Otherwise the host comes from the `HEROKU_EXEC_URL`
149
+ * environment override or the default exec-manager, authenticated as
150
+ * `app:apiKey` against `/api/v2`.
151
+ */
152
+ function resolveExecEndpoint(appIdentity, apiKey, configVars) {
153
+ const configUrl = configVars?.HEROKU_EXEC_URL;
154
+ if (configUrl) {
155
+ const url = new URL(configUrl);
156
+ // URL.username/password are percent-encoded; decode before Basic auth so
157
+ // credentials with reserved characters (e.g. `p%40ss`) authenticate.
158
+ return {
159
+ apiPath: EXEC_API_PATH_LEGACY,
160
+ authorization: toBasicAuth(decodeURIComponent(url.username), decodeURIComponent(url.password)),
161
+ baseUrl: `${url.protocol}//${url.host}`,
162
+ };
163
+ }
164
+ const url = new URL(process.env.HEROKU_EXEC_URL ?? DEFAULT_EXEC_HOST);
165
+ return {
166
+ apiPath: EXEC_API_PATH_CURRENT,
167
+ authorization: toBasicAuth(appIdentity, apiKey),
168
+ baseUrl: `${url.protocol}//${url.host}`,
169
+ };
170
+ }
171
+ function newExecClient(baseUrl, clientOptions) {
172
+ return new HerokuApiClient({
173
+ ...clientOptions, baseUrl, service: 'custom', token: '',
174
+ });
175
+ }
176
+ function toBasicAuth(username, password) {
177
+ return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
178
+ }
179
+ /**
180
+ * Parse an exec-manager response body, tolerating either a JSON value or
181
+ * a JSON string (double-encoded), so behavior matches the historical
182
+ * raw `JSON.parse` regardless of the response `Content-Type`.
183
+ */
184
+ async function parseExecBody(response) {
185
+ const body = (await response.json());
186
+ return (typeof body === 'string' ? JSON.parse(body) : body);
187
+ }
@@ -1,10 +1,16 @@
1
1
  import type { Formation } from '@heroku/types/3.sdk';
2
2
  import type { StickyRouteOptions } from '../../../core/create-client.js';
3
3
  import type { ResourceCtx } from '../../../core/extend-resource.js';
4
+ import { type ExchangeExecCredentialsOptions, type ExecOptions, type ExecStatusOptions, type RestartForExecOptions } from './exec.js';
5
+ import { type ListExtendedOptions } from './list-extended.js';
4
6
  import { type RunDynoOptions } from './run.js';
5
7
  import { type WaitForInfoOptions } from './wait-for-info.js';
8
+ import { type WaitForReleaseOptions } from './wait-for-release.js';
9
+ export { DynoCrashedError, enableExec, exchangeExecCredentials, type ExchangeExecCredentialsOptions, type ExecCredentials, type ExecOptions, type ExecPrereqs, execPrereqs, type ExecReservation, execStatus, type ExecStatusOptions, restartForExec, type RestartForExecOptions, } from './exec.js';
10
+ export { type DynoExtended, type DynoExtendedFields, listExtended, type ListExtendedOptions, } from './list-extended.js';
6
11
  export { runDyno, type RunDynoOptions } from './run.js';
7
12
  export { DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from './wait-for-info.js';
13
+ export { waitForRelease, type WaitForReleaseOptions, type WaitForReleaseProgress, type WaitForReleaseResult, } from './wait-for-release.js';
8
14
  export type DynoOptions = {
9
15
  signal?: AbortSignal;
10
16
  };
@@ -40,11 +46,18 @@ export declare function scaleDynos(ctx: {
40
46
  }, appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
41
47
  export declare function restartDynos(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions): Promise<void>;
42
48
  export declare const dynoExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "dyno", {
49
+ enableExec: (appIdentity: string, options?: ExecOptions) => Promise<void>;
50
+ exchangeExecCredentials: (appIdentity: string, options: ExchangeExecCredentialsOptions) => Promise<import("./exec.js").ExecCredentials>;
51
+ execPrereqs: (appIdentity: string, options?: ExecOptions) => Promise<import("./exec.js").ExecPrereqs>;
52
+ execStatus: (appIdentity: string, options: ExecStatusOptions) => Promise<import("./exec.js").ExecReservation[]>;
53
+ listExtended: (appIdentity: string, options?: ListExtendedOptions) => Promise<import("./list-extended.js").DynoExtended[]>;
43
54
  restart: (appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions) => Promise<void>;
55
+ restartForExec: (appIdentity: string, dynoIdentity: string, options?: RestartForExecOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
44
56
  run: (appIdentity: string, command: string, options?: RunDynoOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
45
57
  scale: {
46
58
  (appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
47
59
  (appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
48
60
  };
49
61
  waitForInfo: (appIdentity: string, dynoIdentity: string, options?: WaitForInfoOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
62
+ waitForRelease: (appIdentity: string, options?: WaitForReleaseOptions) => Promise<import("./wait-for-release.js").WaitForReleaseResult | undefined>;
50
63
  }>;
@@ -1,8 +1,14 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { enableExec, exchangeExecCredentials, execPrereqs, execStatus, restartForExec, } from './exec.js';
3
+ import { listExtended } from './list-extended.js';
2
4
  import { runDyno } from './run.js';
3
5
  import { waitForInfo } from './wait-for-info.js';
6
+ import { waitForRelease } from './wait-for-release.js';
7
+ export { DynoCrashedError, enableExec, exchangeExecCredentials, execPrereqs, execStatus, restartForExec, } from './exec.js';
8
+ export { listExtended, } from './list-extended.js';
4
9
  export { runDyno } from './run.js';
5
10
  export { DynoNotReadyError, waitForInfo, } from './wait-for-info.js';
11
+ export { waitForRelease, } from './wait-for-release.js';
6
12
  export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
7
13
  options.signal?.throwIfAborted();
8
14
  const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
@@ -25,8 +31,15 @@ export async function restartDynos(ctx, appIdentity, target, options = {}) {
25
31
  await ctx.platform.dyno.restartFormation(appIdentity, target.type);
26
32
  }
27
33
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
34
+ enableExec: (appIdentity, options) => (enableExec(ctx, appIdentity, options)),
35
+ exchangeExecCredentials: (appIdentity, options) => (exchangeExecCredentials(appIdentity, options)),
36
+ execPrereqs: (appIdentity, options) => (execPrereqs(ctx, appIdentity, options)),
37
+ execStatus: (appIdentity, options) => (execStatus(appIdentity, options)),
38
+ listExtended: (appIdentity, options) => (listExtended(appIdentity, options)),
28
39
  restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
40
+ restartForExec: (appIdentity, dynoIdentity, options) => (restartForExec(ctx, appIdentity, dynoIdentity, options)),
29
41
  run: (appIdentity, command, options) => (runDyno(ctx, appIdentity, command, options)),
30
42
  scale: ((appIdentity, updates, options) => scaleDynos(ctx, appIdentity, updates, options)),
31
43
  waitForInfo: (appIdentity, dynoIdentity, options) => (waitForInfo(ctx, appIdentity, dynoIdentity, options)),
44
+ waitForRelease: (appIdentity, options) => (waitForRelease(ctx, appIdentity, options)),
32
45
  }));
@@ -0,0 +1,50 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
+ import type { Dyno } from '@heroku/types/3.sdk';
3
+ /**
4
+ * Operational detail the platform attaches to each dyno only when the
5
+ * `?extended=true` query is used with the `version=3.sdk` Accept
6
+ * variant. Every field is nullable — the platform omits values it
7
+ * cannot resolve for a given dyno (e.g. a `run` dyno has no `route`).
8
+ */
9
+ export type DynoExtendedFields = {
10
+ az: null | string;
11
+ execution_plane: null | string;
12
+ fleet: null | string;
13
+ instance: null | string;
14
+ ip: null | string;
15
+ port: null | number;
16
+ region: null | string;
17
+ route: null | string;
18
+ };
19
+ /**
20
+ * A {@link Dyno} plus the optional {@link DynoExtendedFields} block
21
+ * returned by {@link listExtended}. `extended` is absent unless the
22
+ * request was made with sudo privileges against a supporting stack.
23
+ */
24
+ export type DynoExtended = Dyno & {
25
+ extended?: DynoExtendedFields;
26
+ };
27
+ export type ListExtendedOptions = {
28
+ /**
29
+ * Options forwarded to the raw `HerokuApiClient`. The route registry
30
+ * has no entry for `GET /apps/{app}/dynos?extended=true`, so this
31
+ * call is issued via a bare client (like the exec-inside path in
32
+ * {@link runDyno}).
33
+ */
34
+ clientOptions?: HerokuApiClientOptions;
35
+ signal?: AbortSignal;
36
+ };
37
+ /**
38
+ * List an app's dynos with the extended operational detail block.
39
+ *
40
+ * Issues `GET /apps/{app}/dynos?extended=true` with
41
+ * `Accept: version=3.sdk` via a bare `HerokuApiClient`, because the
42
+ * routes registry has no entry for the `extended` query variant. The
43
+ * `extended` block is only populated for callers with sudo
44
+ * privileges; without them the platform returns ordinary dynos and
45
+ * `extended` is absent.
46
+ *
47
+ * Unlike the routes client, this does not auto-paginate — it issues a
48
+ * single GET, matching the historical CLI behavior.
49
+ */
50
+ export declare function listExtended(appIdentity: string, options?: ListExtendedOptions): Promise<DynoExtended[]>;
@@ -0,0 +1,28 @@
1
+ import { HerokuApiClient } from '@heroku/heroku-fetch';
2
+ const ACCEPT_EXTENDED = 'application/vnd.heroku+json; version=3.sdk';
3
+ /**
4
+ * List an app's dynos with the extended operational detail block.
5
+ *
6
+ * Issues `GET /apps/{app}/dynos?extended=true` with
7
+ * `Accept: version=3.sdk` via a bare `HerokuApiClient`, because the
8
+ * routes registry has no entry for the `extended` query variant. The
9
+ * `extended` block is only populated for callers with sudo
10
+ * privileges; without them the platform returns ordinary dynos and
11
+ * `extended` is absent.
12
+ *
13
+ * Unlike the routes client, this does not auto-paginate — it issues a
14
+ * single GET, matching the historical CLI behavior.
15
+ */
16
+ export async function listExtended(appIdentity, options = {}) {
17
+ options.signal?.throwIfAborted();
18
+ const apiClient = new HerokuApiClient({
19
+ ...options.clientOptions,
20
+ service: 'platform',
21
+ });
22
+ const path = `/apps/${encodeURIComponent(appIdentity)}/dynos?extended=true`;
23
+ const response = await apiClient.get(path, {
24
+ headers: { Accept: ACCEPT_EXTENDED },
25
+ signal: options.signal,
26
+ });
27
+ return (await response.json());
28
+ }
@@ -0,0 +1,76 @@
1
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
2
+ /**
3
+ * Progress reported on each poll where the fleet has NOT yet fully
4
+ * converged on the target release. `onLatest` of `total` relevant
5
+ * dynos are running a version `>= version`.
6
+ */
7
+ export type WaitForReleaseProgress = {
8
+ onLatest: number;
9
+ total: number;
10
+ version: number;
11
+ };
12
+ export type WaitForReleaseOptions = {
13
+ /**
14
+ * Maximum number of polls before giving up with
15
+ * {@link ReleaseNotConvergedError}. Omitted (the default) means
16
+ * unbounded — the wait runs until the fleet converges or the
17
+ * caller's {@link WaitForReleaseOptions.signal} aborts, matching a
18
+ * long-running `ps:wait`.
19
+ */
20
+ attempts?: number;
21
+ /** Delay between polls in milliseconds. Defaults to 10000. */
22
+ delayMs?: number;
23
+ /**
24
+ * Fires after every poll where the fleet has NOT yet converged
25
+ * (i.e. `onLatest < total`), before the next wait. It never fires
26
+ * on the converged poll — a fleet already on the latest release
27
+ * when the first poll runs produces no `onPoll` calls at all. Use
28
+ * it to drive a live progress indicator; the converged result is
29
+ * the resolved value, not an `onPoll` call.
30
+ */
31
+ onPoll?: (progress: WaitForReleaseProgress) => void;
32
+ signal?: AbortSignal;
33
+ /** Only wait for dynos of this process type. */
34
+ type?: string;
35
+ /** Include one-off `run` dynos in the wait. Mutually exclusive with `type`. */
36
+ withRun?: boolean;
37
+ };
38
+ export type WaitForReleaseResult = {
39
+ total: number;
40
+ version: number;
41
+ };
42
+ /**
43
+ * Thrown by {@link waitForRelease} when {@link WaitForReleaseOptions.attempts}
44
+ * was set and the fleet did not converge on the target release within
45
+ * that many polls. Carries the last observed progress so callers can
46
+ * report how far the fleet got.
47
+ */
48
+ export declare class ReleaseNotConvergedError extends Error {
49
+ readonly attempts: number;
50
+ readonly progress: undefined | WaitForReleaseProgress;
51
+ readonly id = "release_not_converged";
52
+ constructor(attempts: number, progress: undefined | WaitForReleaseProgress);
53
+ }
54
+ /**
55
+ * Poll an app's dynos until every relevant one is `up` and running a
56
+ * release version at least the app's latest, then resolve with the
57
+ * converged `{total, version}`.
58
+ *
59
+ * "Relevant" excludes `release` dynos always, and one-off `run` dynos
60
+ * unless {@link WaitForReleaseOptions.withRun} is set or
61
+ * {@link WaitForReleaseOptions.type} names `'run'`; when
62
+ * {@link WaitForReleaseOptions.type} is given, only that process type
63
+ * is considered.
64
+ *
65
+ * The latest release is resolved once up front via
66
+ * `GET /apps/{app}/releases` scoped to `Range: version ..; max=1,
67
+ * order=desc`. If the app has no releases (or the latest has no
68
+ * version), resolves `undefined` without polling — the caller should
69
+ * report "no releases" rather than wait forever.
70
+ *
71
+ * Polling is unbounded by default (runs until convergence or an
72
+ * aborted {@link WaitForReleaseOptions.signal}); pass
73
+ * {@link WaitForReleaseOptions.attempts} to cap it, after which it
74
+ * throws {@link ReleaseNotConvergedError}.
75
+ */
76
+ export declare function waitForRelease(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: WaitForReleaseOptions): Promise<undefined | WaitForReleaseResult>;
@@ -0,0 +1,83 @@
1
+ import { wait } from '../../../utils/wait.js';
2
+ const DEFAULT_DELAY_MS = 10_000;
3
+ const LATEST_RELEASE_RANGE = 'version ..; max=1, order=desc';
4
+ /**
5
+ * Thrown by {@link waitForRelease} when {@link WaitForReleaseOptions.attempts}
6
+ * was set and the fleet did not converge on the target release within
7
+ * that many polls. Carries the last observed progress so callers can
8
+ * report how far the fleet got.
9
+ */
10
+ export class ReleaseNotConvergedError extends Error {
11
+ attempts;
12
+ progress;
13
+ id = 'release_not_converged';
14
+ constructor(attempts, progress) {
15
+ super(`Fleet did not converge on release version ${progress?.version ?? '?'} `
16
+ + `within ${attempts} attempts`
17
+ + (progress ? ` (last: ${progress.onLatest}/${progress.total} on latest).` : '.'));
18
+ this.attempts = attempts;
19
+ this.progress = progress;
20
+ this.name = 'ReleaseNotConvergedError';
21
+ }
22
+ }
23
+ /**
24
+ * Poll an app's dynos until every relevant one is `up` and running a
25
+ * release version at least the app's latest, then resolve with the
26
+ * converged `{total, version}`.
27
+ *
28
+ * "Relevant" excludes `release` dynos always, and one-off `run` dynos
29
+ * unless {@link WaitForReleaseOptions.withRun} is set or
30
+ * {@link WaitForReleaseOptions.type} names `'run'`; when
31
+ * {@link WaitForReleaseOptions.type} is given, only that process type
32
+ * is considered.
33
+ *
34
+ * The latest release is resolved once up front via
35
+ * `GET /apps/{app}/releases` scoped to `Range: version ..; max=1,
36
+ * order=desc`. If the app has no releases (or the latest has no
37
+ * version), resolves `undefined` without polling — the caller should
38
+ * report "no releases" rather than wait forever.
39
+ *
40
+ * Polling is unbounded by default (runs until convergence or an
41
+ * aborted {@link WaitForReleaseOptions.signal}); pass
42
+ * {@link WaitForReleaseOptions.attempts} to cap it, after which it
43
+ * throws {@link ReleaseNotConvergedError}.
44
+ */
45
+ export async function waitForRelease(ctx, appIdentity, options = {}) {
46
+ const { attempts, delayMs = DEFAULT_DELAY_MS, onPoll, signal, type, withRun, } = options;
47
+ signal?.throwIfAborted();
48
+ const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
49
+ const releases = await platform
50
+ .withHeaders({ Range: LATEST_RELEASE_RANGE })
51
+ .release.list(appIdentity);
52
+ const version = releases[0]?.version;
53
+ if (version === undefined) {
54
+ return undefined;
55
+ }
56
+ let lastProgress;
57
+ let attempt = 0;
58
+ while (true) {
59
+ const dynos = await platform.dyno.list(appIdentity);
60
+ const relevant = dynos
61
+ .filter(dyno => dyno.type !== 'release')
62
+ // Keep run dynos only when explicitly requested — via withRun, or a
63
+ // type filter that names 'run'. Ordering matters: this must not drop
64
+ // run dynos before the type filter below can select them.
65
+ .filter(dyno => withRun || type === 'run' || dyno.type !== 'run')
66
+ .filter(dyno => !type || dyno.type === type);
67
+ const onLatest = relevant.filter(dyno => (dyno.state === 'up'
68
+ && dyno.release?.version !== undefined
69
+ && dyno.release.version >= version)).length;
70
+ if (onLatest === relevant.length) {
71
+ return { total: relevant.length, version };
72
+ }
73
+ lastProgress = { onLatest, total: relevant.length, version };
74
+ onPoll?.(lastProgress);
75
+ // Give up once the (opt-in) attempts cap is reached, before the next
76
+ // wait — an unbounded wait only exits on convergence or an aborted signal.
77
+ attempt++;
78
+ if (attempts !== undefined && attempt >= attempts) {
79
+ throw new ReleaseNotConvergedError(attempts, lastProgress);
80
+ }
81
+ await wait(delayMs, signal);
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -145,7 +145,7 @@
145
145
  },
146
146
  "dependencies": {
147
147
  "@heroku/heroku-fetch": "^0.1.4",
148
- "@heroku/types": "^4.8.0",
148
+ "@heroku/types": "^5.0.0",
149
149
  "debug": "^4.4.0"
150
150
  }
151
151
  }