@heroku/sdk 0.5.4 → 0.5.7

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.
Files changed (32) hide show
  1. package/README.md +4 -1
  2. package/dist/core/dispatcher.js +16 -2
  3. package/dist/core/extend-resource.d.ts +3 -1
  4. package/dist/core/heroku-sdk.d.ts +2 -0
  5. package/dist/core/heroku-sdk.js +15 -0
  6. package/dist/resources/platform/add-on/index.d.ts +4 -3
  7. package/dist/resources/platform/add-on/index.js +3 -2
  8. package/dist/resources/platform/add-on/pricing.d.ts +39 -10
  9. package/dist/resources/platform/add-on/pricing.js +56 -29
  10. package/dist/resources/platform/add-on/resolve.d.ts +22 -6
  11. package/dist/resources/platform/add-on/resolve.js +33 -13
  12. package/dist/resources/platform/add-on/types.d.ts +18 -0
  13. package/dist/resources/platform/app/create-and-setup.d.ts +50 -0
  14. package/dist/resources/platform/app/create-and-setup.js +60 -0
  15. package/dist/resources/platform/app/index.d.ts +6 -0
  16. package/dist/resources/platform/app/index.js +6 -0
  17. package/dist/resources/platform/app/transfer.d.ts +23 -0
  18. package/dist/resources/platform/app/transfer.js +35 -0
  19. package/dist/resources/platform/container/remove-process-types.d.ts +6 -8
  20. package/dist/resources/platform/container/remove-process-types.js +2 -2
  21. package/dist/resources/platform/domain/create-and-wait.js +3 -2
  22. package/dist/resources/platform/domain/test-utils.js +1 -0
  23. package/dist/resources/platform/domain/types.d.ts +13 -0
  24. package/dist/resources/platform/domain/wait-for-ready.js +9 -5
  25. package/dist/resources/platform/sni-endpoint/create-and-associate.d.ts +7 -0
  26. package/dist/resources/platform/sni-endpoint/create-and-associate.js +3 -1
  27. package/dist/services/metrics.d.ts +5 -0
  28. package/dist/services/metrics.js +23 -0
  29. package/dist/services/platform.js +15 -1
  30. package/dist/utils/poller.d.ts +12 -0
  31. package/dist/utils/poller.js +1 -0
  32. package/package.json +7 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Heroku SDK
2
2
 
3
- A TypeScript SDK for the [Heroku Platform API](https://devcenter.heroku.com/articles/platform-api-reference) and [Heroku Data API](https://devcenter.heroku.com/articles/heroku-postgres-api-reference). It generates fully-typed clients at runtime from route definitions — no hand-written method per endpoint.
3
+ A TypeScript SDK for the [Heroku Platform API](https://devcenter.heroku.com/articles/platform-api-reference), [Heroku Data API](https://devcenter.heroku.com/articles/heroku-postgres-api-reference), and the Heroku Metrics API. It generates fully-typed clients at runtime from route definitions — no hand-written method per endpoint.
4
4
 
5
5
  ## Installation
6
6
 
@@ -40,9 +40,11 @@ If you only need one service (and want the smallest possible bundle), import the
40
40
  ```ts
41
41
  import { createPlatformClient } from '@heroku/sdk/platform'
42
42
  import { createDataClient } from '@heroku/sdk/data'
43
+ import { createMetricsClient } from '@heroku/sdk/metrics'
43
44
 
44
45
  const platform = createPlatformClient()
45
46
  const data = createDataClient()
47
+ const metrics = createMetricsClient()
46
48
 
47
49
  const apps = await platform.app.list()
48
50
  ```
@@ -54,6 +56,7 @@ const apps = await platform.app.list()
54
56
  | `HerokuSDK`, `extendResource`, types | `@heroku/sdk` |
55
57
  | `createPlatformClient`, `PlatformClient` | `@heroku/sdk/platform` |
56
58
  | `createDataClient`, `DataClient` | `@heroku/sdk/data` |
59
+ | `createMetricsClient`, `MetricsClient` | `@heroku/sdk/metrics` |
57
60
  | `appExtensions`, `dynoExtensions`, … | `@heroku/sdk/extensions/platform` |
58
61
  | `databaseExtensions`, … | `@heroku/sdk/extensions/data` |
59
62
 
@@ -16,8 +16,22 @@ export async function dispatch(client, route, args, invocation, requestOptions)
16
16
  if (route.hasRequestBody && remaining.length > 0) {
17
17
  body = remaining[0];
18
18
  }
19
+ let effectiveOptions = requestOptions;
20
+ if (route.query && route.query.length > 0 && remaining.length > 0) {
21
+ const raw = remaining[0];
22
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
23
+ const searchParams = {};
24
+ for (const key of route.query) {
25
+ const value = raw[key];
26
+ if (value !== undefined && value !== null) {
27
+ searchParams[key] = value;
28
+ }
29
+ }
30
+ effectiveOptions = { ...requestOptions, searchParams };
31
+ }
32
+ }
19
33
  debug('%s %s %s -> %s hasBody=%s', invocation ?? 'dispatch', route.method, route.path, path, body !== undefined);
20
- const response = await callMethod(client, route.method, path, body, requestOptions);
34
+ const response = await callMethod(client, route.method, path, body, effectiveOptions);
21
35
  const contentLength = response.headers.get('content-length');
22
36
  if (response.status === 204 || contentLength === '0') {
23
37
  debug('%s empty response status=%d content-length=%s', invocation ?? 'dispatch', response.status, contentLength ?? '');
@@ -30,7 +44,7 @@ export async function dispatch(client, route, args, invocation, requestOptions)
30
44
  const nextRange = response.headers.get('next-range');
31
45
  if (!nextRange)
32
46
  return result;
33
- return followPages(client, path, result, nextRange, invocation, requestOptions);
47
+ return followPages(client, path, result, nextRange, invocation, effectiveOptions);
34
48
  }
35
49
  /* eslint-disable no-await-in-loop -- pagination is inherently sequential; each page depends on the previous next-range header */
36
50
  async function followPages(client, path, firstPage, initialRange, invocation, requestOptions) {
@@ -1,8 +1,10 @@
1
1
  import type { DataClient } from '../services/data.js';
2
+ import type { MetricsClient } from '../services/metrics.js';
2
3
  import type { PlatformClient } from '../services/platform.js';
3
- export type ServiceName = 'data' | 'platform';
4
+ export type ServiceName = 'data' | 'metrics' | 'platform';
4
5
  export type ResourceCtx = {
5
6
  data: DataClient;
7
+ metrics: MetricsClient;
6
8
  platform: PlatformClient;
7
9
  };
8
10
  export type ResourceMethods = Record<string, (...args: any[]) => any>;
@@ -1,5 +1,6 @@
1
1
  import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
2
  import type { DataClient } from '../services/data.js';
3
+ import type { MetricsClient } from '../services/metrics.js';
3
4
  import type { PlatformClient } from '../services/platform.js';
4
5
  import type { ApplyExtensions, ExtensionsFor, ResourceExtension } from './extend-resource.js';
5
6
  export type HerokuSDKOptions<Exts extends readonly ResourceExtension[]> = {
@@ -10,5 +11,6 @@ export declare class HerokuSDK<const Exts extends readonly ResourceExtension[] =
10
11
  #private;
11
12
  constructor(options?: HerokuSDKOptions<Exts>);
12
13
  get data(): ApplyExtensions<DataClient, ExtensionsFor<Exts, 'data'>>;
14
+ get metrics(): ApplyExtensions<MetricsClient, ExtensionsFor<Exts, 'metrics'>>;
13
15
  get platform(): ApplyExtensions<PlatformClient, ExtensionsFor<Exts, 'platform'>>;
14
16
  }
@@ -1,4 +1,5 @@
1
1
  import { createDataClient } from '../services/data.js';
2
+ import { createMetricsClient } from '../services/metrics.js';
2
3
  import { createPlatformClient } from '../services/platform.js';
3
4
  import { mergeExtensions } from './extensions-proxy.js';
4
5
  function partitionByService(extensions) {
@@ -15,8 +16,10 @@ export class HerokuSDK {
15
16
  #ctx;
16
17
  #data;
17
18
  #extensionsByService;
19
+ #metrics;
18
20
  #platform;
19
21
  #rawData;
22
+ #rawMetrics;
20
23
  #rawPlatform;
21
24
  constructor(options = {}) {
22
25
  this.#clientOptions = options.clientOptions ?? {};
@@ -26,6 +29,10 @@ export class HerokuSDK {
26
29
  this.#data ??= mergeExtensions(this.#getRawData(), this.#extensionsByService.get('data') ?? [], this.#getCtx());
27
30
  return this.#data;
28
31
  }
32
+ get metrics() {
33
+ this.#metrics ??= mergeExtensions(this.#getRawMetrics(), this.#extensionsByService.get('metrics') ?? [], this.#getCtx());
34
+ return this.#metrics;
35
+ }
29
36
  get platform() {
30
37
  this.#platform ??= mergeExtensions(this.#getRawPlatform(), this.#extensionsByService.get('platform') ?? [], this.#getCtx());
31
38
  return this.#platform;
@@ -36,6 +43,10 @@ export class HerokuSDK {
36
43
  enumerable: true,
37
44
  get: () => this.#getRawData(),
38
45
  },
46
+ metrics: {
47
+ enumerable: true,
48
+ get: () => this.#getRawMetrics(),
49
+ },
39
50
  platform: {
40
51
  enumerable: true,
41
52
  get: () => this.#getRawPlatform(),
@@ -47,6 +58,10 @@ export class HerokuSDK {
47
58
  this.#rawData ??= createDataClient(this.#clientOptions);
48
59
  return this.#rawData;
49
60
  }
61
+ #getRawMetrics() {
62
+ this.#rawMetrics ??= createMetricsClient(this.#clientOptions);
63
+ return this.#rawMetrics;
64
+ }
50
65
  #getRawPlatform() {
51
66
  this.#rawPlatform ??= createPlatformClient(this.#clientOptions);
52
67
  return this.#rawPlatform;
@@ -7,14 +7,15 @@ export { describeAddon } from './describe.js';
7
7
  export { destroyAndWait } from './destroy-and-wait.js';
8
8
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
9
9
  export { listPlans, listPlansForAddon, type ListPlansForAddonOptions } from './list-plans.js';
10
- export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, priceForPlan, } from './pricing.js';
11
- export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
12
- export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
10
+ export { formatPlanPriceLabel, type FormatPlanPriceLabelOptions, type PlanPriceBreakdown, type PlanPriceKind, priceForPlan, } from './pricing.js';
11
+ export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
12
+ export type { AddOnOptions, CreateAndWaitOptions, DescribedAddOn, DestroyAndWaitOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment, UpgradeAddOnOptions, WaitForProvisioningOptions, } from './types.js';
13
13
  export { upgrade } from './upgrade.js';
14
14
  export { waitForProvisioning } from './wait-for-provisioning.js';
15
15
  export declare const addOnExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "addOn", {
16
16
  createAndWait: (appIdentity: string, body: AddOnCreateOpts, options?: CreateAndWaitOptions) => Promise<AddOn>;
17
17
  describe: (addonIdentity: string, options?: ResolveAddonOptions) => Promise<import("./types.js").DescribedAddOn>;
18
+ describeAttachment: (appIdentity: string, attachmentName: string, options?: AddOnOptions) => Promise<import("./types.js").ResolvedAddOnAttachment>;
18
19
  destroyAndWait: (appIdentity: string, addonIdentity: string, options?: DestroyAndWaitOptions) => Promise<AddOn>;
19
20
  formatPlanPriceLabel: typeof formatPlanPriceLabel;
20
21
  listPlans: (serviceIdentity: string, options?: AddOnOptions) => Promise<import("@heroku/types/3.sdk").Plan[]>;
@@ -4,7 +4,7 @@ import { describeAddon } from './describe.js';
4
4
  import { destroyAndWait } from './destroy-and-wait.js';
5
5
  import { listPlans, listPlansForAddon } from './list-plans.js';
6
6
  import { formatPlanPriceLabel, priceForPlan } from './pricing.js';
7
- import { resolveAddon, resolveAddonByAttachment } from './resolve.js';
7
+ import { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
8
8
  import { upgrade } from './upgrade.js';
9
9
  import { waitForProvisioning } from './wait-for-provisioning.js';
10
10
  export { createAndWait } from './create-and-wait.js';
@@ -13,12 +13,13 @@ export { destroyAndWait } from './destroy-and-wait.js';
13
13
  export { AddonAmbiguousError, AddonConfirmationRequiredError, AddonNotFoundError, AddonProvisioningFailedError, } from './errors.js';
14
14
  export { listPlans, listPlansForAddon } from './list-plans.js';
15
15
  export { formatPlanPriceLabel, priceForPlan, } from './pricing.js';
16
- export { resolveAddon, resolveAddonByAttachment } from './resolve.js';
16
+ export { describeAttachment, resolveAddon, resolveAddonByAttachment } from './resolve.js';
17
17
  export { upgrade } from './upgrade.js';
18
18
  export { waitForProvisioning } from './wait-for-provisioning.js';
19
19
  export const addOnExtensions = extendResource('platform', 'addOn', ctx => ({
20
20
  createAndWait: (appIdentity, body, options) => createAndWait(ctx, appIdentity, body, options),
21
21
  describe: (addonIdentity, options) => describeAddon(ctx, addonIdentity, options),
22
+ describeAttachment: (appIdentity, attachmentName, options) => describeAttachment(ctx, appIdentity, attachmentName, options),
22
23
  destroyAndWait: (appIdentity, addonIdentity, options) => destroyAndWait(ctx, appIdentity, addonIdentity, options),
23
24
  formatPlanPriceLabel,
24
25
  listPlans: (serviceIdentity, options) => listPlans(ctx, serviceIdentity, options),
@@ -1,9 +1,36 @@
1
1
  import type { Plan } from '@heroku/types/3.sdk';
2
+ /**
3
+ * How a plan is priced, classified once so every consumer agrees. The
4
+ * classification — not the raw `cents`/`contract`/`metered` fields — is what
5
+ * a caller should branch on to decide how to present a plan:
6
+ *
7
+ * - `'contract'` — negotiated outside monthly add-on billing. `cents` is not a
8
+ * meaningful price and may be absent.
9
+ * - `'metered'` — billed per use. A flat `cents` (often `0`) does NOT reflect
10
+ * real cost; per-use rates live on the plan's meters (fetched separately).
11
+ * - `'free'` — a flat plan that genuinely costs nothing (`cents === 0`).
12
+ * - `'flat'` — a fixed recurring price of `cents` per `unit`.
13
+ *
14
+ * The precedence (contract, then metered, then free, then flat) matches the
15
+ * Heroku CLI's long-standing production behavior: a contract- or metered-billed
16
+ * plan is reported as such even when its flat `cents` reads `0`, so a `cents: 0`
17
+ * metered/contract plan is never mistaken for free.
18
+ */
19
+ export type PlanPriceKind = 'contract' | 'flat' | 'free' | 'metered';
2
20
  export type PlanPriceBreakdown = {
3
- /** raw price as reported by the API, in cents */
4
- cents: number;
21
+ /**
22
+ * Raw price as reported by the API, in cents. `undefined` for a contract
23
+ * plan that reports no `cents` (the price is negotiated elsewhere).
24
+ */
25
+ cents: number | undefined;
5
26
  /** True when the price is negotiated outside monthly add-on billing */
6
27
  contract: boolean;
28
+ /**
29
+ * The price classification. Branch on this rather than re-deriving it from
30
+ * `cents`/`contract`/`metered` at each call site — that re-derivation is the
31
+ * source of the "0 cents ⇒ free" bug this field exists to prevent.
32
+ */
33
+ kind: PlanPriceKind;
7
34
  /** True when the price is billed per use rather than at a fixed cadence */
8
35
  metered: boolean;
9
36
  /**
@@ -31,15 +58,17 @@ export type PlanPriceBreakdown = {
31
58
  * keeps the 30-day-month assumption (Heroku's documented convention)
32
59
  * in one place.
33
60
  *
34
- * Returns `undefined` if the plan has no usable `price.cents` value.
35
- * `Plan.price` is typed as optional in `@heroku/types`, and consumers
36
- * occasionally receive partial responses depending on the Accept
37
- * variant.
61
+ * Returns `undefined` only when the plan carries no pricing signal at
62
+ * all — no `price`, and no `cents`/`contract`/`metered` to classify
63
+ * from. A contract- or metered-billed plan still classifies (its flag
64
+ * is the signal) even when `cents` is absent. `Plan.price` is typed as
65
+ * optional in `@heroku/types`, and consumers occasionally receive
66
+ * partial responses depending on the Accept variant.
38
67
  *
39
- * `perMonthCents` and `perHourCents` are themselves `undefined` when
40
- * the API reports a unit other than `'month'` or `'hour'` — callers
41
- * should omit those parts of their label rather than show fabricated
42
- * numbers.
68
+ * `perMonthCents` and `perHourCents` are populated only for a flat or
69
+ * free plan on a `'month'`/`'hour'` cadence; they are `undefined` for
70
+ * metered/contract plans and for unknown units, so callers omit those
71
+ * parts of their label rather than show fabricated numbers.
43
72
  *
44
73
  * @param plan The plan to break down. Pass the whole `Plan` rather
45
74
  * than just `plan.price` so the helper can pick up future fields
@@ -13,15 +13,17 @@ const HOURS_PER_MONTH = 24 * 30;
13
13
  * keeps the 30-day-month assumption (Heroku's documented convention)
14
14
  * in one place.
15
15
  *
16
- * Returns `undefined` if the plan has no usable `price.cents` value.
17
- * `Plan.price` is typed as optional in `@heroku/types`, and consumers
18
- * occasionally receive partial responses depending on the Accept
19
- * variant.
16
+ * Returns `undefined` only when the plan carries no pricing signal at
17
+ * all — no `price`, and no `cents`/`contract`/`metered` to classify
18
+ * from. A contract- or metered-billed plan still classifies (its flag
19
+ * is the signal) even when `cents` is absent. `Plan.price` is typed as
20
+ * optional in `@heroku/types`, and consumers occasionally receive
21
+ * partial responses depending on the Accept variant.
20
22
  *
21
- * `perMonthCents` and `perHourCents` are themselves `undefined` when
22
- * the API reports a unit other than `'month'` or `'hour'` — callers
23
- * should omit those parts of their label rather than show fabricated
24
- * numbers.
23
+ * `perMonthCents` and `perHourCents` are populated only for a flat or
24
+ * free plan on a `'month'`/`'hour'` cadence; they are `undefined` for
25
+ * metered/contract plans and for unknown units, so callers omit those
26
+ * parts of their label rather than show fabricated numbers.
25
27
  *
26
28
  * @param plan The plan to break down. Pass the whole `Plan` rather
27
29
  * than just `plan.price` so the helper can pick up future fields
@@ -29,35 +31,60 @@ const HOURS_PER_MONTH = 24 * 30;
29
31
  */
30
32
  export function priceForPlan(plan) {
31
33
  const { price } = plan;
32
- if (!price || typeof price.cents !== 'number')
34
+ if (!price)
33
35
  return undefined;
34
- const { cents } = price;
36
+ const contract = Boolean(price.contract);
37
+ const metered = Boolean(price.metered);
38
+ const hasCents = typeof price.cents === 'number';
39
+ // A plan with no `cents` and no contract/metered flag carries no usable
40
+ // pricing signal at all — treat it as unknown (undefined) rather than
41
+ // guessing. Contract/metered plans, by contrast, are fully classifiable
42
+ // without a `cents` value: the flag IS the price signal.
43
+ if (!hasCents && !contract && !metered)
44
+ return undefined;
45
+ const cents = hasCents ? price.cents : undefined;
35
46
  const unit = price.unit ?? '';
47
+ // Precedence matches the CLI's production formatter: contract and metered
48
+ // win over the raw cents, so a `cents: 0` contract/metered plan is never
49
+ // classified as free. `free` is a flat plan that genuinely costs nothing.
50
+ let kind;
51
+ if (contract)
52
+ kind = 'contract';
53
+ else if (metered)
54
+ kind = 'metered';
55
+ else if (cents === 0)
56
+ kind = 'free';
57
+ else
58
+ kind = 'flat';
59
+ // Per-month / per-hour equivalents are only meaningful for a flat recurring
60
+ // price on a known cadence. Metered/contract plans (and unknown units) leave
61
+ // them undefined so callers omit those labels instead of rendering
62
+ // fabricated numbers.
36
63
  let perMonthCents;
37
64
  let perHourCents;
38
- switch (unit) {
39
- case 'hour': {
40
- perHourCents = cents;
41
- perMonthCents = cents * HOURS_PER_MONTH;
42
- break;
43
- }
44
- case 'month': {
45
- perMonthCents = cents;
46
- perHourCents = cents / HOURS_PER_MONTH;
47
- break;
48
- }
49
- default: {
50
- // Unknown billing unit — leave per-month / per-hour undefined
51
- // so callers can omit those labels instead of rendering
52
- // fabricated numbers.
53
- perMonthCents = undefined;
54
- perHourCents = undefined;
65
+ if ((kind === 'flat' || kind === 'free') && cents !== undefined) {
66
+ switch (unit) {
67
+ case 'hour': {
68
+ perHourCents = cents;
69
+ perMonthCents = cents * HOURS_PER_MONTH;
70
+ break;
71
+ }
72
+ case 'month': {
73
+ perMonthCents = cents;
74
+ perHourCents = cents / HOURS_PER_MONTH;
75
+ break;
76
+ }
77
+ default: {
78
+ perMonthCents = undefined;
79
+ perHourCents = undefined;
80
+ }
55
81
  }
56
82
  }
57
83
  return {
58
84
  cents,
59
- contract: Boolean(price.contract),
60
- metered: Boolean(price.metered),
85
+ contract,
86
+ kind,
87
+ metered,
61
88
  perHourCents,
62
89
  perMonthCents,
63
90
  unit,
@@ -1,6 +1,6 @@
1
1
  import type { ResourceCtx } from '../../../core/extend-resource.js';
2
2
  import type { PlatformClient } from '../../../services/platform.js';
3
- import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn } from './types.js';
3
+ import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn, ResolvedAddOnAttachment } from './types.js';
4
4
  /**
5
5
  * Resolve a Platform add-on by identity.
6
6
  *
@@ -27,14 +27,30 @@ import type { AddOnOptions, ResolveAddonOptions, ResolvedAddOn } from './types.j
27
27
  */
28
28
  export declare function resolveAddon(ctx: Pick<ResourceCtx, 'platform'>, addonIdentity: string, options?: ResolveAddonOptions): Promise<ResolvedAddOn>;
29
29
  /**
30
- * Resolve a Platform add-on via one of its attachments on a given app.
30
+ * Describe an add-on *attachment* via its name on a given app.
31
31
  *
32
32
  * Use this when you have an attachment name (e.g. `DATABASE_URL`,
33
- * `HEROKU_POSTGRESQL_GREEN`) on a known app, rather than an add-on
34
- * identity. Calls `addOnAttachment.resolution` and returns the add-on
35
- * the matched attachment points to.
33
+ * `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
34
+ * itself — including its context-scoped `web_url` (the dashboard URL for
35
+ * opening the add-on *from this app*, which differs from the add-on's own
36
+ * billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
37
+ * the matched attachment with its resolved add-on.
36
38
  *
37
- * For resolving by add-on identity, use `resolveAddon`.
39
+ * `web_url` is `string | null` by design: it is `null` for add-ons that
40
+ * expose no web dashboard. A `null` here is a valid result, not a
41
+ * not-found — callers who need a URL must handle it (e.g. fall back to
42
+ * the add-on's own `web_url` via `resolveAddon`).
43
+ *
44
+ * To resolve just the add-on the attachment points to, use
45
+ * `resolveAddonByAttachment`. For resolving by add-on identity, use
46
+ * `resolveAddon`.
47
+ */
48
+ export declare function describeAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOnAttachment>;
49
+ /**
50
+ * Resolve the add-on that an attachment points to, by attachment name on a
51
+ * given app. A thin wrapper over `describeAttachment` that returns only the
52
+ * resolved add-on (not the attachment). Use `describeAttachment` when you
53
+ * need the attachment's context-scoped `web_url`.
38
54
  */
39
55
  export declare function resolveAddonByAttachment(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, attachmentName: string, options?: AddOnOptions): Promise<ResolvedAddOn>;
40
56
  export declare function resolveAddonInternal(platform: PlatformClient, addonIdentity: string, options?: {
@@ -30,31 +30,51 @@ export async function resolveAddon(ctx, addonIdentity, options = {}) {
30
30
  return resolveAddonInternal(ctx.platform, addonIdentity, options);
31
31
  }
32
32
  /**
33
- * Resolve a Platform add-on via one of its attachments on a given app.
33
+ * Describe an add-on *attachment* via its name on a given app.
34
34
  *
35
35
  * Use this when you have an attachment name (e.g. `DATABASE_URL`,
36
- * `HEROKU_POSTGRESQL_GREEN`) on a known app, rather than an add-on
37
- * identity. Calls `addOnAttachment.resolution` and returns the add-on
38
- * the matched attachment points to.
36
+ * `HEROKU_POSTGRESQL_GREEN`) on a known app and need the attachment
37
+ * itself — including its context-scoped `web_url` (the dashboard URL for
38
+ * opening the add-on *from this app*, which differs from the add-on's own
39
+ * billing-app `web_url`). Calls `addOnAttachment.resolution` and returns
40
+ * the matched attachment with its resolved add-on.
39
41
  *
40
- * For resolving by add-on identity, use `resolveAddon`.
42
+ * `web_url` is `string | null` by design: it is `null` for add-ons that
43
+ * expose no web dashboard. A `null` here is a valid result, not a
44
+ * not-found — callers who need a URL must handle it (e.g. fall back to
45
+ * the add-on's own `web_url` via `resolveAddon`).
46
+ *
47
+ * To resolve just the add-on the attachment points to, use
48
+ * `resolveAddonByAttachment`. For resolving by add-on identity, use
49
+ * `resolveAddon`.
41
50
  */
42
- export async function resolveAddonByAttachment(ctx, appIdentity, attachmentName, options = {}) {
51
+ export async function describeAttachment(ctx, appIdentity, attachmentName, options = {}) {
43
52
  options.signal?.throwIfAborted();
44
- debug('resolveByAttachment app=%s attachment=%s', appIdentity, attachmentName);
45
- const matches = await ctx.platform.addOnAttachment.resolution({
53
+ debug('describeAttachment app=%s attachment=%s', appIdentity, attachmentName);
54
+ // Scope the in-flight resolution call to the caller's signal so an abort
55
+ // mid-request is honored, not just before it starts.
56
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
57
+ const matches = await platform.addOnAttachment.resolution({
46
58
  // eslint-disable-next-line camelcase
47
59
  addon_attachment: attachmentName,
48
60
  app: appIdentity,
49
61
  });
50
62
  const attachment = matches[0];
51
- const addon = attachment?.addon;
52
- if (!addon?.id || !addon.app?.id) {
53
- debug('resolveByAttachment matches=%d (no usable add-on returned)', matches.length);
63
+ if (!attachment?.addon?.id || !attachment.addon.app?.id) {
64
+ debug('describeAttachment matches=%d (no usable add-on returned)', matches.length);
54
65
  throw new AddonNotFoundError();
55
66
  }
56
- debug('resolveByAttachment resolved addon=%s app=%s', addon.id, addon.app.id);
57
- return addon;
67
+ debug('describeAttachment resolved addon=%s app=%s', attachment.addon.id, attachment.addon.app.id);
68
+ return attachment;
69
+ }
70
+ /**
71
+ * Resolve the add-on that an attachment points to, by attachment name on a
72
+ * given app. A thin wrapper over `describeAttachment` that returns only the
73
+ * resolved add-on (not the attachment). Use `describeAttachment` when you
74
+ * need the attachment's context-scoped `web_url`.
75
+ */
76
+ export async function resolveAddonByAttachment(ctx, appIdentity, attachmentName, options = {}) {
77
+ return (await describeAttachment(ctx, appIdentity, attachmentName, options)).addon;
58
78
  }
59
79
  export async function resolveAddonInternal(platform, addonIdentity, options = {}) {
60
80
  const { addonService, appIdentity } = options;
@@ -18,6 +18,24 @@ export type ResolvedAddOn = AddOn & {
18
18
  };
19
19
  id: string;
20
20
  };
21
+ /**
22
+ * An attachment whose add-on `id`/`app.id` are guaranteed non-null (as the
23
+ * resolver enforces at runtime). `web_url` is scoped to the attached app's
24
+ * context — the dashboard URL for opening this add-on *from this app*, which
25
+ * differs from the add-on's own (billing-app) `web_url`.
26
+ *
27
+ * `web_url` remains `string | null` by design (inherited from
28
+ * `AddOnAttachment`): it is `null` for add-ons that expose no web dashboard.
29
+ * The resolver does not guarantee a URL — callers must handle the `null` case.
30
+ */
31
+ export type ResolvedAddOnAttachment = AddOnAttachment & {
32
+ addon: NonNullable<AddOnAttachment['addon']> & {
33
+ app: {
34
+ id: string;
35
+ };
36
+ id: string;
37
+ };
38
+ };
21
39
  export type DescribedAddOn = ResolvedAddOn & {
22
40
  attachments: AddOnAttachment[];
23
41
  };
@@ -0,0 +1,50 @@
1
+ import type { App, ConfigVarUpdateOpts } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type SetupStep = {
4
+ kind: 'addon' | 'buildpack' | 'config-vars' | 'setup';
5
+ label?: string;
6
+ };
7
+ export type CreateAndSetupOptions = {
8
+ poller?: {
9
+ onStart?: (step: SetupStep) => void;
10
+ onStop?: (step?: SetupStep) => void;
11
+ };
12
+ signal?: AbortSignal;
13
+ };
14
+ export type CreateAndSetupInput = {
15
+ addons?: Array<{
16
+ as?: string;
17
+ plan: string;
18
+ }>;
19
+ buildpack?: string;
20
+ configVars?: ConfigVarUpdateOpts;
21
+ name?: string;
22
+ region?: string;
23
+ space?: string;
24
+ stack?: string;
25
+ team?: string;
26
+ };
27
+ /**
28
+ * Create an app and run its post-create setup in one call.
29
+ *
30
+ * Creates the app (team/space → `teamApp.create`, otherwise `app.create`),
31
+ * then fans out the optional setup steps in parallel: add-ons
32
+ * (`addOn.create` per plan), config vars (`configVar.update`), and buildpack
33
+ * (`buildpackInstallation.update`). Returns the created `App`.
34
+ *
35
+ * Progress is reported via the `poller` convention. Because the steps run in
36
+ * parallel and a caller's progress indicator is typically a single global
37
+ * spinner, this method fires a single `onStart`/`onStop` pair around the whole
38
+ * setup batch rather than one per step, so parallel steps don't clobber the
39
+ * spinner. Per the poller contract, `onStart` fires BEFORE any request is
40
+ * dispatched, and `onStop` fires ONLY on success — if a step rejects, the
41
+ * error propagates and `onStop` is never called (there is no try/finally, so
42
+ * the caller's error path stops the spinner). Signal threading uses the
43
+ * standard `withOptions({signal})`
44
+ * scoped-client idiom so an abort cancels in-flight requests, not just the
45
+ * pre-flight `throwIfAborted()`.
46
+ *
47
+ * The CLI parses flags / heroku.yml into `input` and owns git-remote setup
48
+ * and rendering; this method owns the API orchestration only.
49
+ */
50
+ export declare function createAndSetup(ctx: Pick<ResourceCtx, 'platform'>, input: CreateAndSetupInput, options?: CreateAndSetupOptions): Promise<App>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Create an app and run its post-create setup in one call.
3
+ *
4
+ * Creates the app (team/space → `teamApp.create`, otherwise `app.create`),
5
+ * then fans out the optional setup steps in parallel: add-ons
6
+ * (`addOn.create` per plan), config vars (`configVar.update`), and buildpack
7
+ * (`buildpackInstallation.update`). Returns the created `App`.
8
+ *
9
+ * Progress is reported via the `poller` convention. Because the steps run in
10
+ * parallel and a caller's progress indicator is typically a single global
11
+ * spinner, this method fires a single `onStart`/`onStop` pair around the whole
12
+ * setup batch rather than one per step, so parallel steps don't clobber the
13
+ * spinner. Per the poller contract, `onStart` fires BEFORE any request is
14
+ * dispatched, and `onStop` fires ONLY on success — if a step rejects, the
15
+ * error propagates and `onStop` is never called (there is no try/finally, so
16
+ * the caller's error path stops the spinner). Signal threading uses the
17
+ * standard `withOptions({signal})`
18
+ * scoped-client idiom so an abort cancels in-flight requests, not just the
19
+ * pre-flight `throwIfAborted()`.
20
+ *
21
+ * The CLI parses flags / heroku.yml into `input` and owns git-remote setup
22
+ * and rendering; this method owns the API orchestration only.
23
+ */
24
+ export async function createAndSetup(ctx, input, options = {}) {
25
+ options.signal?.throwIfAborted();
26
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
27
+ const { addons, buildpack, configVars, ...createParams } = input;
28
+ const app = (createParams.space || createParams.team)
29
+ ? (await platform.teamApp.create(createParams))
30
+ : await platform.app.create(createParams);
31
+ if (!app.name)
32
+ throw new Error('createAndSetup: created app has no name');
33
+ const appName = app.name;
34
+ // Deferred thunks: nothing is dispatched until the batch is launched below,
35
+ // so `onStart` reliably fires before the first request.
36
+ const steps = [];
37
+ if (addons?.length) {
38
+ for (const addon of addons) {
39
+ steps.push(() => platform.addOn.create(appName, {
40
+ attachment: addon.as ? { name: addon.as } : undefined,
41
+ plan: addon.plan,
42
+ }));
43
+ }
44
+ }
45
+ if (configVars && Object.keys(configVars).length > 0) {
46
+ steps.push(() => platform.configVar.update(appName, configVars));
47
+ }
48
+ if (buildpack) {
49
+ steps.push(() => platform.buildpackInstallation.update(appName, { updates: [{ buildpack }] }));
50
+ }
51
+ if (steps.length > 0) {
52
+ // Single combined progress window — one spinner for the whole parallel
53
+ // setup batch, not one per step. onStart before dispatch; onStop only on
54
+ // success (no try/finally — a rejection skips onStop per contract).
55
+ options.poller?.onStart?.({ kind: 'setup', label: 'setup' });
56
+ await Promise.all(steps.map(step => step()));
57
+ options.poller?.onStop?.();
58
+ }
59
+ return app;
60
+ }
@@ -1,8 +1,12 @@
1
1
  import type { App } from '@heroku/types/3.sdk';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { CreateAndSetupInput, CreateAndSetupOptions } from './create-and-setup.js';
3
4
  import type { DescribeAppOptions } from './info.js';
5
+ import type { TransferOptions } from './transfer.js';
4
6
  import type { WaitForACMCertificatesOptions } from './wait-for-acm-certificates.js';
7
+ export { createAndSetup, type CreateAndSetupInput, type CreateAndSetupOptions, type SetupStep, } from './create-and-setup.js';
5
8
  export { type AppInfo, describeApp, type DescribeAppOptions, type PipelineCouplingDetail, } from './info.js';
9
+ export { transferApp, type TransferOptions } from './transfer.js';
6
10
  export { waitForACMCertificates, type WaitForACMCertificatesOptions } from './wait-for-acm-certificates.js';
7
11
  export type AppMaintenanceOptions = {
8
12
  signal?: AbortSignal;
@@ -51,11 +55,13 @@ export declare function getProcessTier(ctx: {
51
55
  platform: AppInfoPlatform;
52
56
  }, appIdentity: string, options?: GetProcessTierOptions): Promise<ProcessTier | undefined>;
53
57
  export declare const appExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "app", {
58
+ createAndSetup: (input: CreateAndSetupInput, options?: CreateAndSetupOptions) => Promise<App>;
54
59
  describe: (appIdentity: string, options?: DescribeAppOptions) => Promise<import("./info.js").AppInfo>;
55
60
  disableMaintenance: (appIdentity: string, options?: AppMaintenanceOptions) => Promise<App>;
56
61
  enableMaintenance: (appIdentity: string, options?: AppMaintenanceOptions) => Promise<App>;
57
62
  getGeneration: (appIdentity: string, options?: GetGenerationOptions) => Promise<GenerationKind | undefined>;
58
63
  getProcessTier: (appIdentity: string, options?: GetProcessTierOptions) => Promise<ProcessTier | undefined>;
59
64
  isShielded: (appIdentity: string, options?: IsShieldedOptions) => Promise<boolean>;
65
+ transfer: (appIdentity: string, recipient: string, options?: TransferOptions) => Promise<import("@heroku/types/3.sdk").TeamApp | import("@heroku/types/3.sdk").AppTransfer>;
60
66
  waitForACMCertificates: (appIdentity: string, options?: WaitForACMCertificatesOptions) => Promise<import("@heroku/types/3.sdk").Domain[]>;
61
67
  }>;
@@ -1,7 +1,11 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { createAndSetup } from './create-and-setup.js';
2
3
  import { describeApp } from './info.js';
4
+ import { transferApp } from './transfer.js';
3
5
  import { waitForACMCertificates } from './wait-for-acm-certificates.js';
6
+ export { createAndSetup, } from './create-and-setup.js';
4
7
  export { describeApp, } from './info.js';
8
+ export { transferApp } from './transfer.js';
5
9
  export { waitForACMCertificates } from './wait-for-acm-certificates.js';
6
10
  export async function enableMaintenance(ctx, appIdentity, options = {}) {
7
11
  options.signal?.throwIfAborted();
@@ -46,11 +50,13 @@ export async function getProcessTier(ctx, appIdentity, options = {}) {
46
50
  return app.process_tier;
47
51
  }
48
52
  export const appExtensions = extendResource('platform', 'app', ctx => ({
53
+ createAndSetup: (input, options) => createAndSetup(ctx, input, options),
49
54
  describe: (appIdentity, options) => describeApp(ctx, appIdentity, options),
50
55
  disableMaintenance: (appIdentity, options) => disableMaintenance(ctx, appIdentity, options),
51
56
  enableMaintenance: (appIdentity, options) => enableMaintenance(ctx, appIdentity, options),
52
57
  getGeneration: (appIdentity, options) => getGeneration(ctx, appIdentity, options),
53
58
  getProcessTier: (appIdentity, options) => getProcessTier(ctx, appIdentity, options),
54
59
  isShielded: (appIdentity, options) => isShielded(ctx, appIdentity, options),
60
+ transfer: (appIdentity, recipient, options) => transferApp(ctx, appIdentity, recipient, options),
55
61
  waitForACMCertificates: (appIdentity, options) => waitForACMCertificates(ctx, appIdentity, options),
56
62
  }));
@@ -0,0 +1,23 @@
1
+ import type { AppTransfer, TeamApp } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ export type TransferOptions = {
4
+ personalToPersonal?: boolean;
5
+ signal?: AbortSignal;
6
+ silent?: boolean;
7
+ };
8
+ /**
9
+ * Transfer an app to another user or team.
10
+ *
11
+ * Route selection mirrors the platform's two transfer surfaces:
12
+ * - personal-to-personal transfers go through `POST /account/app-transfers`
13
+ * (`appTransfer.create`), which emails the recipient for acceptance
14
+ * (response `state: 'pending'`).
15
+ * - team-involved transfers go through `PATCH /teams/apps/{name}`
16
+ * (`teamApp.transferToTeam` / `transferToAccount`), differing only by body.
17
+ *
18
+ * The caller (CLI) decides `personalToPersonal` and interprets the returned
19
+ * `state` for presentation. `personalToPersonal` defaults falsy → the team
20
+ * route, so a caller who forgets the flag on a genuine personal transfer
21
+ * silently hits the team path (a caller-owned footgun the SDK can't detect).
22
+ */
23
+ export declare function transferApp(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, recipient: string, options?: TransferOptions): Promise<AppTransfer | TeamApp>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Transfer an app to another user or team.
3
+ *
4
+ * Route selection mirrors the platform's two transfer surfaces:
5
+ * - personal-to-personal transfers go through `POST /account/app-transfers`
6
+ * (`appTransfer.create`), which emails the recipient for acceptance
7
+ * (response `state: 'pending'`).
8
+ * - team-involved transfers go through `PATCH /teams/apps/{name}`
9
+ * (`teamApp.transferToTeam` / `transferToAccount`), differing only by body.
10
+ *
11
+ * The caller (CLI) decides `personalToPersonal` and interprets the returned
12
+ * `state` for presentation. `personalToPersonal` defaults falsy → the team
13
+ * route, so a caller who forgets the flag on a genuine personal transfer
14
+ * silently hits the team path (a caller-owned footgun the SDK can't detect).
15
+ */
16
+ export async function transferApp(ctx, appIdentity, recipient, options = {}) {
17
+ options.signal?.throwIfAborted();
18
+ const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
19
+ if (options.personalToPersonal) {
20
+ const body = { app: appIdentity, recipient };
21
+ if (options.silent !== undefined)
22
+ body.silent = options.silent;
23
+ return platform.appTransfer.create(body);
24
+ }
25
+ // Team-involved (personalToPersonal=false): both routes are PATCH /teams/apps/{name}
26
+ // with body {owner}, differing only by semantics. Split by recipient shape: an
27
+ // email recipient transfers ownership to a personal ACCOUNT; anything else is a
28
+ // TEAM name. Both produce a byte-identical wire request to the legacy
29
+ // app-transfer.ts (a single PATCH with {owner: recipient}), so this is a
30
+ // spec-faithful split with no functional regression.
31
+ const looksLikeEmail = recipient.includes('@');
32
+ return looksLikeEmail
33
+ ? platform.teamApp.transferToAccount(appIdentity, { owner: recipient })
34
+ : platform.teamApp.transferToTeam(appIdentity, { owner: recipient });
35
+ }
@@ -1,20 +1,18 @@
1
1
  import type { Formation } from '@heroku/types/3.sdk';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { Poller } from '../../../utils/poller.js';
3
4
  import type { ContainerOptions } from './index.js';
4
5
  export type RemoveProcessTypesOpts = {
5
6
  /**
6
- * Fires `onStart` immediately before, and `onStop` immediately after,
7
- * each process type's `formation.update` call. Process types are
8
- * removed one at a time (not concurrently) so a caller can drive a
9
- * single shared UI element (e.g. a CLI spinner) across the whole
7
+ * Fires `poller.onStart` immediately before, and `poller.onStop`
8
+ * immediately after, each process type's `formation.update` call. Process
9
+ * types are removed one at a time (not concurrently) so a caller can
10
+ * drive a single shared UI element (e.g. a CLI spinner) across the whole
10
11
  * batch. If an update rejects, its `onStart` has already fired but
11
12
  * `onStop` never will — the rejection propagates immediately and any
12
13
  * remaining process types are not attempted.
13
14
  */
14
- onProgress?: {
15
- onStart?: (processType: string) => void;
16
- onStop?: (processType: string) => void;
17
- };
15
+ poller?: Poller<string>;
18
16
  } & ContainerOptions;
19
17
  /**
20
18
  * Remove one or more process types from a container app
@@ -9,11 +9,11 @@ export async function removeProcessTypes(ctx, appIdentity, processTypes, options
9
9
  const requestBody = { docker_image: null };
10
10
  const updatedFormations = [];
11
11
  for (const processType of processTypes) {
12
- options.onProgress?.onStart?.(processType);
12
+ options.poller?.onStart?.(processType);
13
13
  const formation = await platform
14
14
  .withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
15
15
  .formation.update(appIdentity, processType, requestBody);
16
- options.onProgress?.onStop?.(processType);
16
+ options.poller?.onStop?.(processType);
17
17
  updatedFormations.push(formation);
18
18
  }
19
19
  return updatedFormations;
@@ -9,7 +9,7 @@ import { waitForReady } from './wait-for-ready.js';
9
9
  * 4. If wait: true, poll until domain reaches ready status
10
10
  */
11
11
  export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
12
- const { resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
12
+ const { poller, resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
13
13
  signal?.throwIfAborted();
14
14
  const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
15
  let resolvedSniEndpoint = null;
@@ -33,9 +33,10 @@ export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
33
33
  // eslint-disable-next-line camelcase
34
34
  sni_endpoint: resolvedSniEndpoint,
35
35
  });
36
- if (shouldWait && domain.status !== 'succeeded' && domain.status !== 'none') {
36
+ if (shouldWait && domain.status !== 'none') {
37
37
  const [readyDomain] = await waitForReady(ctx, appIdentity, {
38
38
  domain,
39
+ poller,
39
40
  signal,
40
41
  timeoutMs,
41
42
  waitIntervalMs,
@@ -17,6 +17,7 @@ export function buildCtx(stubs = {}) {
17
17
  platform.withOptions.mockReturnValue(platform);
18
18
  return {
19
19
  data: {},
20
+ metrics: {},
20
21
  platform: platform,
21
22
  };
22
23
  }
@@ -1,4 +1,5 @@
1
1
  import type { Domain, SniEndpoint } from '@heroku/types/3.sdk';
2
+ import type { Poller } from '../../../utils/poller.js';
2
3
  export type DomainOptions = {
3
4
  /**
4
5
  * Abort signal to cancel the operation.
@@ -14,6 +15,12 @@ export type WaitForReadyOptions = DomainOptions & {
14
15
  * Hostname of the domain to wait for.
15
16
  */
16
17
  hostname?: string;
18
+ /**
19
+ * Progress hooks fired once per domain being waited for:
20
+ * `poller.onStart(domain)` before polling begins, `poller.onStop(domain)`
21
+ * after the domain becomes ready.
22
+ */
23
+ poller?: Poller<Domain>;
17
24
  /**
18
25
  * Maximum time in milliseconds to wait before giving up.
19
26
  * If undefined, waits indefinitely (until signal aborted).
@@ -25,6 +32,12 @@ export type WaitForReadyOptions = DomainOptions & {
25
32
  waitIntervalMs?: number;
26
33
  };
27
34
  export type CreateAndWaitOptions = DomainOptions & {
35
+ /**
36
+ * Progress hooks fired once per domain being waited for:
37
+ * `poller.onStart(domain)` before polling begins, `poller.onStop(domain)`
38
+ * after the domain becomes ready.
39
+ */
40
+ poller?: Poller<Domain>;
28
41
  /**
29
42
  * Callback to resolve which SNI endpoint to use
30
43
  * when one is not provided.
@@ -9,7 +9,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
9
9
  * @throws if the domain reaches a non-ready status (e.g., 'failed').
10
10
  */
11
11
  export async function waitForReady(ctx, appIdentity, options = {}) {
12
- const { domain, hostname, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS } = options;
12
+ const { domain, hostname, poller, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS, } = options;
13
13
  signal?.throwIfAborted();
14
14
  const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
15
  let domains;
@@ -24,10 +24,14 @@ export async function waitForReady(ctx, appIdentity, options = {}) {
24
24
  const apiDomains = await platform.domain.list(appIdentity);
25
25
  domains = apiDomains.filter(domain => domain.status === 'pending');
26
26
  }
27
- for (let i = 0; i < domains.length; i++) {
28
- domains[i] = await waitForDomain(platform, appIdentity, domains[i], waitIntervalMs, timeoutMs, signal);
27
+ const readyDomains = [];
28
+ for (const domain of domains) {
29
+ poller?.onStart?.(domain);
30
+ const readyDomain = await waitForDomain(platform, appIdentity, domain, waitIntervalMs, timeoutMs, signal);
31
+ poller?.onStop?.(domain);
32
+ readyDomains.push(readyDomain);
29
33
  }
30
- return domains;
34
+ return readyDomains;
31
35
  }
32
36
  async function waitForDomain(platform, appIdentity, domain, waitIntervalMs, timeoutMs, signal) {
33
37
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
@@ -35,7 +39,7 @@ async function waitForDomain(platform, appIdentity, domain, waitIntervalMs, time
35
39
  await wait(waitIntervalMs, signal);
36
40
  const updatedDomain = await platform.domain.info(appIdentity, domain.id);
37
41
  domain = updatedDomain;
38
- if (deadline !== undefined && Date.now() >= deadline) {
42
+ if (domain.status === 'pending' && deadline !== undefined && Date.now() >= deadline) {
39
43
  throw new Error(`Timed out waiting for domain ${domain.hostname}`);
40
44
  }
41
45
  }
@@ -1,6 +1,13 @@
1
1
  import type { SniEndpoint } from '@heroku/types/3.sdk';
2
2
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import type { Poller } from '../../../utils/poller.js';
3
4
  export type CreateAndAssociateOptions = {
5
+ /**
6
+ * Progress hooks for the domain readiness wait:
7
+ * `domainPoller.onStart()` before polling begins, `domainPoller.onStop()`
8
+ * once the app's domains reach ready status.
9
+ */
10
+ domainPoller?: Poller;
4
11
  intervalMs?: number;
5
12
  /** Required. Given the wildcard-matched candidate hostnames, returns the subset to associate. */
6
13
  resolveDomains: (candidates: string[]) => Promise<string[]>;
@@ -33,14 +33,16 @@ const DEFAULT_TIMEOUT_MS = 30_000;
33
33
  // createAndAssociate(app, certChain, privateKey, {resolveDomains}).
34
34
  // eslint-disable-next-line max-params
35
35
  export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
36
- const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
36
+ const { domainPoller, intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
37
37
  signal?.throwIfAborted();
38
38
  const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
39
39
  const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
40
40
  certificate_chain: certificateChain,
41
41
  private_key: privateKey,
42
42
  });
43
+ domainPoller?.onStart?.();
43
44
  await waitForReady(ctx, appIdentity, { signal, timeoutMs, waitIntervalMs: intervalMs });
45
+ domainPoller?.onStop?.();
44
46
  const apiDomains = await platform.domain.list(appIdentity);
45
47
  const appDomains = apiDomains.map(d => d.hostname);
46
48
  const certDomains = (sniEndpoint.ssl_cert.cert_domains ?? []);
@@ -0,0 +1,5 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
+ import type { HerokuClient } from '@heroku/types/metrics';
3
+ import type { RoutesClientExtras } from '../core/create-client.js';
4
+ export type MetricsClient = HerokuClient & RoutesClientExtras<HerokuClient>;
5
+ export declare function createMetricsClient(options?: HerokuApiClientOptions): MetricsClient;
@@ -0,0 +1,23 @@
1
+ import * as routes from '@heroku/types/metrics/routes';
2
+ import { createClient } from '../core/create-client.js';
3
+ const DEFAULT_METRICS_BASE_URL = 'https://api.metrics.heroku.com';
4
+ export function createMetricsClient(options = {}) {
5
+ const baseUrl = process.env.HEROKU_METRICS_HOST ?? DEFAULT_METRICS_BASE_URL;
6
+ // Metrics is a distinct host that heroku-fetch does not model as a first-class
7
+ // service, so we use `service: 'custom'` with an explicit base URL. Auth (bearer
8
+ // token) is resolved by heroku-fetch exactly as for platform/data — same
9
+ // HEROKU_API_KEY / netrc source, shared across services on one SDK instance.
10
+ //
11
+ // Unlike platform/data, `service: 'custom'` sends no default Accept header, so we
12
+ // set `application/vnd.heroku+json; version=3` explicitly to match what meetas
13
+ // expects — the same versioned vendor type platform/data get automatically via
14
+ // heroku-fetch's `defaultAccept`. A caller can still override it via `headers`:
15
+ // `...options` first, then an explicit `headers` key whose `...options.headers`
16
+ // spread lands AFTER the literal Accept, so caller headers win for any key they set.
17
+ return createClient(routes, {
18
+ baseUrl,
19
+ service: 'custom',
20
+ ...options,
21
+ headers: { Accept: 'application/vnd.heroku+json; version=3', ...options.headers },
22
+ });
23
+ }
@@ -1,5 +1,19 @@
1
1
  import * as routes from '@heroku/types/3.sdk/routes';
2
2
  import { createClient } from '../core/create-client.js';
3
+ /**
4
+ * The Platform API variant this client's routes and types are generated from
5
+ * (`@heroku/types/3.sdk`). Dispatch at the same variant so the wire matches the
6
+ * shipped types — otherwise `3.sdk`-only routes (e.g. `buildMetadata.info`) 404
7
+ * under heroku-fetch's shared `version=3` default, and fields the types describe
8
+ * in their `3.sdk` shape (e.g. `App.generation: string`) arrive in the older
9
+ * `version=3` shape. A caller can still override per request via `withHeaders`,
10
+ * or per client by passing their own `defaultAccept`.
11
+ */
12
+ const PLATFORM_SDK_ACCEPT = 'application/vnd.heroku+json; version=3.sdk';
3
13
  export function createPlatformClient(options = {}) {
4
- return createClient(routes, { service: 'platform', ...options });
14
+ return createClient(routes, {
15
+ defaultAccept: PLATFORM_SDK_ACCEPT,
16
+ service: 'platform',
17
+ ...options,
18
+ });
5
19
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Progress hooks for a polling loop, so a caller can drive UI (e.g. a CLI
3
+ * spinner) around each unit of work without reaching into the loop itself.
4
+ *
5
+ * `onStart` fires immediately before polling a unit begins; `onStop` fires
6
+ * once it settles into a terminal state. If polling that unit throws,
7
+ * `onStart` has already fired but `onStop` never will.
8
+ */
9
+ export type Poller<T = void> = {
10
+ onStart?: (arg: T) => void;
11
+ onStop?: (arg: T) => void;
12
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.5.4",
3
+ "version": "0.5.7",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -18,6 +18,10 @@
18
18
  "types": "./dist/services/data.d.ts",
19
19
  "default": "./dist/services/data.js"
20
20
  },
21
+ "./metrics": {
22
+ "types": "./dist/services/metrics.d.ts",
23
+ "default": "./dist/services/metrics.js"
24
+ },
21
25
  "./extensions/platform": {
22
26
  "types": "./dist/resources/extensions/platform.d.ts",
23
27
  "default": "./dist/resources/extensions/platform.js"
@@ -99,8 +103,8 @@
99
103
  "vitest": "^4.1.4"
100
104
  },
101
105
  "dependencies": {
102
- "@heroku/heroku-fetch": "^0.1.3",
103
- "@heroku/types": "^4.0.1",
106
+ "@heroku/heroku-fetch": "^0.1.4",
107
+ "@heroku/types": "^4.1.0",
104
108
  "debug": "^4.4.0"
105
109
  }
106
110
  }