@heroku/sdk 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/core/create-client.d.ts +7 -2
  2. package/dist/core/create-client.js +5 -2
  3. package/dist/core/extend-resource.d.ts +52 -2
  4. package/dist/core/extend-resource.js +27 -0
  5. package/dist/core/extensions-proxy.js +51 -1
  6. package/dist/core/heroku-sdk.js +44 -1
  7. package/dist/core/with-signal.d.ts +19 -0
  8. package/dist/core/with-signal.js +20 -0
  9. package/dist/resources/data/database.js +19 -6
  10. package/dist/resources/data/internal/resolve-pg-database.js +4 -7
  11. package/dist/resources/data/maintenance.js +4 -1
  12. package/dist/resources/data/postgres-database.js +4 -1
  13. package/dist/resources/data/redis/resolve-by-app.js +2 -1
  14. package/dist/resources/data/redis/wait-for-ready.d.ts +1 -1
  15. package/dist/resources/data/redis/wait-for-ready.js +3 -2
  16. package/dist/resources/data/transfer/capture-and-wait.js +4 -1
  17. package/dist/resources/data/transfer/restore-and-wait.js +4 -1
  18. package/dist/resources/data/transfer/wait-for-transfer.js +8 -1
  19. package/dist/resources/data/transfer-schedule.js +10 -3
  20. package/dist/resources/extensions/platform.d.ts +3 -0
  21. package/dist/resources/extensions/platform.js +3 -0
  22. package/dist/resources/platform/account.d.ts +22 -0
  23. package/dist/resources/platform/account.js +34 -0
  24. package/dist/resources/platform/add-on/create-and-wait.js +5 -1
  25. package/dist/resources/platform/add-on/describe.js +5 -2
  26. package/dist/resources/platform/add-on/destroy-and-wait.js +2 -3
  27. package/dist/resources/platform/add-on/list-plans.js +8 -2
  28. package/dist/resources/platform/add-on/resolve.js +5 -1
  29. package/dist/resources/platform/add-on/upgrade.js +5 -2
  30. package/dist/resources/platform/add-on/wait-for-provisioning.js +4 -1
  31. package/dist/resources/platform/add-on-attachment/resolve.js +2 -3
  32. package/dist/resources/platform/app/create-and-setup.d.ts +3 -4
  33. package/dist/resources/platform/app/create-and-setup.js +5 -5
  34. package/dist/resources/platform/app/diff.js +2 -3
  35. package/dist/resources/platform/app/index.js +9 -3
  36. package/dist/resources/platform/app/info.d.ts +4 -0
  37. package/dist/resources/platform/app/info.js +21 -7
  38. package/dist/resources/platform/app/transfer.js +2 -1
  39. package/dist/resources/platform/app/wait-for-acm-certificates.js +2 -1
  40. package/dist/resources/platform/container/ensure-container-stack.js +2 -1
  41. package/dist/resources/platform/container/release-images.js +2 -1
  42. package/dist/resources/platform/container/remove-process-types.js +2 -1
  43. package/dist/resources/platform/delinquency.d.ts +23 -0
  44. package/dist/resources/platform/delinquency.js +41 -0
  45. package/dist/resources/platform/domain/clear-custom-domains.js +2 -1
  46. package/dist/resources/platform/domain/create-and-wait.js +2 -1
  47. package/dist/resources/platform/domain/wait-for-ready.js +2 -1
  48. package/dist/resources/platform/dyno/exec.d.ts +12 -2
  49. package/dist/resources/platform/dyno/exec.js +27 -12
  50. package/dist/resources/platform/dyno/index.js +9 -6
  51. package/dist/resources/platform/dyno/list-extended.d.ts +7 -1
  52. package/dist/resources/platform/dyno/list-extended.js +8 -3
  53. package/dist/resources/platform/dyno/run.d.ts +1 -1
  54. package/dist/resources/platform/dyno/run.js +5 -3
  55. package/dist/resources/platform/dyno/wait-for-info.js +2 -1
  56. package/dist/resources/platform/dyno/wait-for-release.js +2 -1
  57. package/dist/resources/platform/log-session/index.d.ts +1 -1
  58. package/dist/resources/platform/log-session/index.js +12 -11
  59. package/dist/resources/platform/pipeline/coupling.js +5 -2
  60. package/dist/resources/platform/pipeline/index.d.ts +2 -2
  61. package/dist/resources/platform/pipeline/index.js +6 -4
  62. package/dist/resources/platform/pipeline/promotion.d.ts +1 -1
  63. package/dist/resources/platform/pipeline/promotion.js +22 -18
  64. package/dist/resources/platform/review-app-config/resolve-repo-name.js +4 -3
  65. package/dist/resources/platform/sni-endpoint/create-and-associate.js +2 -1
  66. package/dist/resources/platform/space/wait-for-allocated.js +2 -4
  67. package/dist/resources/platform/team.d.ts +22 -0
  68. package/dist/resources/platform/team.js +34 -0
  69. package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +2 -1
  70. package/dist/resources/platform/test-run/wait-for-state.js +2 -1
  71. package/dist/resources/platform/vpn-connection/wait-for-active.js +2 -1
  72. package/dist/utils/is-abort-error.d.ts +11 -0
  73. package/dist/utils/is-abort-error.js +14 -0
  74. package/dist/version.d.ts +1 -0
  75. package/dist/version.js +2 -0
  76. package/package.json +9 -4
@@ -0,0 +1,22 @@
1
+ import type { Account, AccountDelinquency } from '@heroku/types/3.sdk';
2
+ import type { ResourceCtx } from '../../core/extend-resource.js';
3
+ export type GetAccountWithDelinquencyOptions = {
4
+ signal?: AbortSignal;
5
+ };
6
+ export type AccountWithDelinquency = {
7
+ account: Account;
8
+ delinquency: AccountDelinquency | null;
9
+ };
10
+ /**
11
+ * Fetch the authenticated account together with its delinquency status.
12
+ *
13
+ * Runs the two reads concurrently. The account lookup hard-fails — there is no
14
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
15
+ * that secondary call must not sink an otherwise-successful account fetch (it
16
+ * backs commands like `whoami`). Callers decide whether and how to surface a
17
+ * warning from `delinquency` — `formatDelinquencyWarning` renders the sentence.
18
+ */
19
+ export declare function getAccountWithDelinquency(ctx: Pick<ResourceCtx, 'platform'>, options?: GetAccountWithDelinquencyOptions): Promise<AccountWithDelinquency>;
20
+ export declare const accountExtensions: import("../../core/extend-resource.js").ResourceExtension<"platform", "account", {
21
+ getWithDelinquency: (options?: GetAccountWithDelinquencyOptions) => Promise<AccountWithDelinquency>;
22
+ }>;
@@ -0,0 +1,34 @@
1
+ import createDebug from 'debug';
2
+ import { extendResource } from '../../core/extend-resource.js';
3
+ const debug = createDebug('heroku:sdk:resources:account');
4
+ /**
5
+ * Fetch the authenticated account together with its delinquency status.
6
+ *
7
+ * Runs the two reads concurrently. The account lookup hard-fails — there is no
8
+ * result without it. The delinquency lookup soft-fails to `null`: a hiccup on
9
+ * that secondary call must not sink an otherwise-successful account fetch (it
10
+ * backs commands like `whoami`). Callers decide whether and how to surface a
11
+ * warning from `delinquency` — `formatDelinquencyWarning` renders the sentence.
12
+ */
13
+ export async function getAccountWithDelinquency(ctx, options = {}) {
14
+ options.signal?.throwIfAborted();
15
+ const platform = options.signal
16
+ ? ctx.platform.withOptions({ signal: options.signal })
17
+ : ctx.platform;
18
+ const [account, delinquency] = await Promise.all([
19
+ platform.account.info(),
20
+ platform.accountDelinquency.info().catch((error) => {
21
+ // Soft-fail a secondary-call hiccup to null, but rethrow a cancellation:
22
+ // if the caller aborted, returning null would misreport a delinquent
23
+ // account as current. Anything else is logged and swallowed.
24
+ if (options.signal?.aborted)
25
+ throw error;
26
+ debug('account delinquency lookup failed; treating as not delinquent: %O', error);
27
+ return null;
28
+ }),
29
+ ]);
30
+ return { account, delinquency };
31
+ }
32
+ export const accountExtensions = extendResource('platform', 'account', ctx => ({
33
+ getWithDelinquency: (options) => getAccountWithDelinquency(ctx, options),
34
+ }));
@@ -1,4 +1,5 @@
1
1
  import { HerokuApiError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { wait } from '../../../utils/wait.js';
3
4
  import { AddonConfirmationRequiredError, AddonProvisioningFailedError } from './errors.js';
4
5
  const DEFAULT_CREATE_WAIT_INTERVAL_MS = 5000;
@@ -28,7 +29,10 @@ const DEFAULT_CREATE_WAIT_INTERVAL_MS = 5000;
28
29
  */
29
30
  export async function createAndWait(ctx, appIdentity, body, options = {}) {
30
31
  options.signal?.throwIfAborted();
31
- const platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
32
+ // Bind the signal to the client so the create and poll requests abort in
33
+ // flight, not just the delay between polls.
34
+ const platform = withSignal(ctx.platform, options.signal)
35
+ .withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
32
36
  let addon;
33
37
  try {
34
38
  addon = await platform
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { resolveAddonInternal } from './resolve.js';
2
3
  /**
3
4
  * Resolve, describe, and decorate an add-on for display.
@@ -13,11 +14,13 @@ import { resolveAddonInternal } from './resolve.js';
13
14
  */
14
15
  export async function describeAddon(ctx, addonIdentity, options = {}) {
15
16
  options.signal?.throwIfAborted();
17
+ // Bind the signal so both requests abort in flight, not just at the checkpoints.
18
+ const base = withSignal(ctx.platform, options.signal);
16
19
  // Accept-Expansion is scoped to the resolve call (addOn.resolution
17
20
  // serializer accepts addon_service + plan expansions). The
18
21
  // /addons/:id/addon-attachments endpoint uses Accept-Inclusion, not
19
22
  // Accept-Expansion, and rejects an unknown expansion header.
20
- const resolvingPlatform = ctx.platform.withHeaders({
23
+ const resolvingPlatform = base.withHeaders({
21
24
  Accept: 'application/vnd.heroku+json; version=3.sdk',
22
25
  'Accept-Expansion': 'addon_service,plan',
23
26
  });
@@ -26,7 +29,7 @@ export async function describeAddon(ctx, addonIdentity, options = {}) {
26
29
  appIdentity: options.appIdentity,
27
30
  });
28
31
  options.signal?.throwIfAborted();
29
- const attachments = await ctx.platform.addOnAttachment.listByAddOn(addon.id);
32
+ const attachments = await base.addOnAttachment.listByAddOn(addon.id);
30
33
  const plan = addon.plan;
31
34
  return {
32
35
  ...addon,
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { AddonProvisioningFailedError } from './errors.js';
3
4
  const DEFAULT_DESTROY_WAIT_INTERVAL_MS = 5000;
@@ -21,9 +22,7 @@ const DEFAULT_DESTROY_WAIT_INTERVAL_MS = 5000;
21
22
  */
22
23
  export async function destroyAndWait(ctx, appIdentity, addonIdentity, options = {}) {
23
24
  options.signal?.throwIfAborted();
24
- const platform = ctx.platform
25
- .withHeaders({ 'Accept-Expansion': 'plan' })
26
- .withOptions({ signal: options.signal });
25
+ const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Expansion': 'plan' }), options.signal);
27
26
  // The force body is scoped to the delete call only — a sticky body on the
28
27
  // shared client would leak onto the GET poll requests below.
29
28
  const deleteClient = options.force
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { AddonNotFoundError } from './errors.js';
2
3
  import { resolveAddonInternal } from './resolve.js';
3
4
  /**
@@ -7,7 +8,9 @@ import { resolveAddonInternal } from './resolve.js';
7
8
  */
8
9
  export async function listPlans(ctx, serviceIdentity, options = {}) {
9
10
  options.signal?.throwIfAborted();
10
- const plans = await ctx.platform.plan.listByAddOn(serviceIdentity);
11
+ // Bind the signal so the request aborts in flight.
12
+ const platform = withSignal(ctx.platform, options.signal);
13
+ const plans = await platform.plan.listByAddOn(serviceIdentity);
11
14
  return [...plans].sort(byPriceCentsAsc);
12
15
  }
13
16
  /**
@@ -26,7 +29,10 @@ export async function listPlans(ctx, serviceIdentity, options = {}) {
26
29
  */
27
30
  export async function listPlansForAddon(ctx, addonIdentity, options = {}) {
28
31
  options.signal?.throwIfAborted();
29
- const addon = await resolveAddonInternal(ctx.platform, addonIdentity, {
32
+ // Bind the signal so the resolve request aborts in flight (the helper itself
33
+ // doesn't thread options.signal into the client).
34
+ const platform = withSignal(ctx.platform, options.signal);
35
+ const addon = await resolveAddonInternal(platform, addonIdentity, {
30
36
  appIdentity: options.appIdentity,
31
37
  });
32
38
  // The route accepts either name or id; name is stable across
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
3
4
  /**
@@ -25,7 +26,10 @@ import { AddonAmbiguousError, AddonNotFoundError } from './errors.js';
25
26
  */
26
27
  export async function resolveAddon(ctx, addonIdentity, options = {}) {
27
28
  options.signal?.throwIfAborted();
28
- return resolveAddonInternal(ctx.platform, addonIdentity, options);
29
+ // Bind the signal so the resolve request aborts in flight (the helper itself
30
+ // doesn't thread options.signal into the client).
31
+ const platform = withSignal(ctx.platform, options.signal);
32
+ return resolveAddonInternal(platform, addonIdentity, options);
29
33
  }
30
34
  export async function resolveAddonInternal(platform, addonIdentity, options = {}) {
31
35
  const { addonService, appIdentity } = options;
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { debug } from './debug.js';
2
3
  import { resolveAddonInternal } from './resolve.js';
3
4
  /**
@@ -18,7 +19,9 @@ import { resolveAddonInternal } from './resolve.js';
18
19
  */
19
20
  export async function upgrade(ctx, addonIdentity, plan, options = {}) {
20
21
  options.signal?.throwIfAborted();
21
- const addon = await resolveAddonInternal(ctx.platform, addonIdentity, {
22
+ // Bind the signal so the resolve and update requests abort in flight.
23
+ const platform = withSignal(ctx.platform, options.signal);
24
+ const addon = await resolveAddonInternal(platform, addonIdentity, {
22
25
  addonService: options.addonService,
23
26
  appIdentity: options.appIdentity,
24
27
  });
@@ -31,5 +34,5 @@ export async function upgrade(ctx, addonIdentity, plan, options = {}) {
31
34
  debug('upgrade plan qualified plan=%s qualified=%s', plan, qualifiedPlan);
32
35
  }
33
36
  debug('upgrade addon=%s app=%s plan=%s', addon.id, addon.app.id, qualifiedPlan);
34
- return ctx.platform.addOn.update(addon.app.id, addon.id, { plan: qualifiedPlan });
37
+ return platform.addOn.update(addon.app.id, addon.id, { plan: qualifiedPlan });
35
38
  }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  import { AddonProvisioningFailedError } from './errors.js';
3
4
  const DEFAULT_WAIT_INTERVAL_MS = 5000;
@@ -19,7 +20,9 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
19
20
  export async function waitForProvisioning(ctx, addon, options = {}) {
20
21
  options.signal?.throwIfAborted();
21
22
  const intervalMs = options.waitIntervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
22
- const platform = ctx.platform.withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
23
+ // Bind the signal so each poll request aborts in flight, not just the delay.
24
+ const platform = withSignal(ctx.platform, options.signal)
25
+ .withHeaders({ 'Accept-Expansion': 'addon_service,plan' });
23
26
  let current = addon;
24
27
  /* eslint-disable no-await-in-loop */
25
28
  while (current.state === 'provisioning' || current.state === 'deprovisioning') {
@@ -1,4 +1,5 @@
1
1
  import createDebug from 'debug';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  const debug = createDebug('heroku:sdk:resources:add-on-attachment');
3
4
  export class AddonAttachmentNotFoundError extends Error {
4
5
  resource;
@@ -47,9 +48,7 @@ export async function resolveAddonAttachment(ctx, appIdentity, attachmentName, o
47
48
  const { addonService, signal } = options;
48
49
  signal?.throwIfAborted();
49
50
  debug('resolve app=%s attachment=%s service=%s', appIdentity ?? '<global>', attachmentName, addonService ?? '<any>');
50
- const platform = ctx.platform
51
- .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
52
- .withOptions({ signal });
51
+ const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' }), signal);
53
52
  const matches = await platform.addOnAttachment.resolution({
54
53
  // eslint-disable-next-line camelcase
55
54
  addon_attachment: attachmentName,
@@ -39,10 +39,9 @@ export type CreateAndSetupInput = {
39
39
  * spinner. Per the poller contract, `onStart` fires BEFORE any request is
40
40
  * dispatched, and `onStop` fires ONLY on success — if a step rejects, the
41
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()`.
42
+ * the caller's error path stops the spinner). Signal threading binds the
43
+ * signal to a scoped client (via `withSignal`) so an abort cancels in-flight
44
+ * requests, not just the pre-flight `throwIfAborted()`.
46
45
  *
47
46
  * The CLI parses flags / heroku.yml into `input` and owns git-remote setup
48
47
  * and rendering; this method owns the API orchestration only.
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Create an app and run its post-create setup in one call.
3
4
  *
@@ -13,17 +14,16 @@
13
14
  * spinner. Per the poller contract, `onStart` fires BEFORE any request is
14
15
  * dispatched, and `onStop` fires ONLY on success — if a step rejects, the
15
16
  * 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()`.
17
+ * the caller's error path stops the spinner). Signal threading binds the
18
+ * signal to a scoped client (via `withSignal`) so an abort cancels in-flight
19
+ * requests, not just the pre-flight `throwIfAborted()`.
20
20
  *
21
21
  * The CLI parses flags / heroku.yml into `input` and owns git-remote setup
22
22
  * and rendering; this method owns the API orchestration only.
23
23
  */
24
24
  export async function createAndSetup(ctx, input, options = {}) {
25
25
  options.signal?.throwIfAborted();
26
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
26
+ const platform = withSignal(ctx.platform, options.signal);
27
27
  const { addons, buildpack, configVars, ...createParams } = input;
28
28
  const app = (createParams.space || createParams.team)
29
29
  ? (await platform.teamApp.create(createParams))
@@ -1,4 +1,5 @@
1
1
  import { NotFoundError } from '@heroku/heroku-fetch';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  async function checksum(platform, app) {
3
4
  try {
4
5
  const releases = await platform.withHeaders({ Range: 'version ..; max=1, order=desc' }).release.list(app);
@@ -95,9 +96,7 @@ async function diffFeatures(platform, app1, app2) {
95
96
  */
96
97
  export async function diffApps(ctx, app1, app2, options = {}) {
97
98
  options.signal?.throwIfAborted();
98
- const platform = options.signal
99
- ? ctx.platform.withOptions({ signal: options.signal })
100
- : ctx.platform;
99
+ const platform = withSignal(ctx.platform, options.signal);
101
100
  const files = await diffFiles(platform, app1, app2);
102
101
  const [env, stack, bp, addons, features] = await Promise.all([
103
102
  diffEnv(platform, app1, app2),
@@ -1,4 +1,5 @@
1
1
  import { extendResource } from '../../../core/extend-resource.js';
2
+ import { withSignal } from '../../../core/with-signal.js';
2
3
  import { createAndSetup } from './create-and-setup.js';
3
4
  import { diffApps } from './diff.js';
4
5
  import { describeApp } from './info.js';
@@ -11,11 +12,15 @@ export { transferApp } from './transfer.js';
11
12
  export { waitForACMCertificates } from './wait-for-acm-certificates.js';
12
13
  export async function enableMaintenance(ctx, appIdentity, options = {}) {
13
14
  options.signal?.throwIfAborted();
14
- return ctx.platform.app.update(appIdentity, { maintenance: true });
15
+ // Bind the signal so the request aborts in flight.
16
+ const platform = withSignal(ctx.platform, options.signal);
17
+ return platform.app.update(appIdentity, { maintenance: true });
15
18
  }
16
19
  export async function disableMaintenance(ctx, appIdentity, options = {}) {
17
20
  options.signal?.throwIfAborted();
18
- return ctx.platform.app.update(appIdentity, { maintenance: false });
21
+ // Bind the signal so the request aborts in flight.
22
+ const platform = withSignal(ctx.platform, options.signal);
23
+ return platform.app.update(appIdentity, { maintenance: false });
19
24
  }
20
25
  /**
21
26
  * Look up the platform generation (`cedar` or `fir`) of an app.
@@ -28,7 +33,8 @@ export async function disableMaintenance(ctx, appIdentity, options = {}) {
28
33
  */
29
34
  export async function getGeneration(ctx, appIdentity, options = {}) {
30
35
  options.signal?.throwIfAborted();
31
- const platform = ctx.platform.withHeaders({
36
+ // Bind the signal so the request aborts in flight.
37
+ const platform = withSignal(ctx.platform, options.signal).withHeaders({
32
38
  Accept: 'application/vnd.heroku+json; version=3.sdk',
33
39
  });
34
40
  const app = await platform.app.info(appIdentity);
@@ -26,5 +26,9 @@ export type AppInfo = {
26
26
  * any error collapses to an empty default. The broad .catch is
27
27
  * intentional v1 fidelity with the Heroku CLI's apps:info; a future
28
28
  * tightening to specific status codes is non-breaking.
29
+ *
30
+ * Cancellation is exempt from the soft-fail: an aborted sub-request
31
+ * rethrows so the whole describe rejects instead of silently returning a
32
+ * partial view when the caller cancelled.
29
33
  */
30
34
  export declare function describeApp(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: DescribeAppOptions): Promise<AppInfo>;
@@ -1,3 +1,5 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
2
+ import { isAbortError } from '../../../utils/is-abort-error.js';
1
3
  /**
2
4
  * Composite "describe this app" view: parallel-fetches add-ons, the app
3
5
  * itself, dynos, collaborators, and the pipeline coupling.
@@ -7,19 +9,31 @@
7
9
  * any error collapses to an empty default. The broad .catch is
8
10
  * intentional v1 fidelity with the Heroku CLI's apps:info; a future
9
11
  * tightening to specific status codes is non-breaking.
12
+ *
13
+ * Cancellation is exempt from the soft-fail: an aborted sub-request
14
+ * rethrows so the whole describe rejects instead of silently returning a
15
+ * partial view when the caller cancelled.
10
16
  */
11
17
  export async function describeApp(ctx, appIdentity, options = {}) {
12
- options.signal?.throwIfAborted();
13
- const platform = options.signal
14
- ? ctx.platform.withOptions({ signal: options.signal })
15
- : ctx.platform;
18
+ const { signal } = options;
19
+ signal?.throwIfAborted();
20
+ const platform = withSignal(ctx.platform, signal);
21
+ // Collapse an expected soft error to a default, but never swallow an abort.
22
+ // Checks the signal's `aborted` flag as well as the error shape so a
23
+ // custom-reason abort (`controller.abort(reason)`), which `isAbortError`
24
+ // alone can't detect, still rejects instead of returning a partial view.
25
+ const softFail = (fallback) => (error) => {
26
+ if (signal?.aborted || isAbortError(error))
27
+ throw error;
28
+ return fallback;
29
+ };
16
30
  const [addons, app, dynos, collaborators, pipelineCoupling] = await Promise.all([
17
31
  platform.addOn.listByApp(appIdentity),
18
32
  platform.app.info(appIdentity),
19
- platform.dyno.list(appIdentity).catch(() => []),
20
- platform.collaborator.list(appIdentity).catch(() => []),
33
+ platform.dyno.list(appIdentity).catch(softFail([])),
34
+ platform.collaborator.list(appIdentity).catch(softFail([])),
21
35
  platform.pipelineCoupling.infoByApp(appIdentity)
22
- .catch(() => null),
36
+ .catch(softFail(null)),
23
37
  ]);
24
38
  return {
25
39
  addons, app, collaborators, dynos, pipelineCoupling,
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Transfer an app to another user or team.
3
4
  *
@@ -15,7 +16,7 @@
15
16
  */
16
17
  export async function transferApp(ctx, appIdentity, recipient, options = {}) {
17
18
  options.signal?.throwIfAborted();
18
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
19
+ const platform = withSignal(ctx.platform, options.signal);
19
20
  if (options.personalToPersonal) {
20
21
  const body = { app: appIdentity, recipient };
21
22
  if (options.silent !== undefined)
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_WAIT_INTERVAL_MS = 15_000;
3
4
  /**
@@ -16,7 +17,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 15_000;
16
17
  export async function waitForACMCertificates(ctx, appIdentity, options = {}) {
17
18
  const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, signal, timeoutMs } = options;
18
19
  signal?.throwIfAborted();
19
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
20
+ const platform = withSignal(ctx.platform, signal);
20
21
  function terminal(domains) {
21
22
  return domains
22
23
  .filter(domain => domain.kind === 'custom')
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  export class NotAContainerAppError extends Error {
2
3
  app;
3
4
  id = 'not_a_container_app';
@@ -14,7 +15,7 @@ export class NotAContainerAppError extends Error {
14
15
  export async function ensureContainerStack(ctx, appIdentity, options = {}) {
15
16
  const { signal } = options;
16
17
  signal?.throwIfAborted();
17
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
18
+ const platform = withSignal(ctx.platform, signal);
18
19
  const app = await platform.app.info(appIdentity);
19
20
  const allowedStack = 'container';
20
21
  if (app.build_stack.name !== allowedStack && app.stack.name !== allowedStack) {
@@ -1,10 +1,11 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { ensureContainerStack } from './ensure-container-stack.js';
2
3
  /**
3
4
  * Release Docker images to a container app.
4
5
  */
5
6
  export async function releaseDockerImages(ctx, appIdentity, imagesByProcessType, options = {}) {
6
7
  await ensureContainerStack(ctx, appIdentity, options);
7
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
8
+ const platform = withSignal(ctx.platform, options.signal);
8
9
  const oldReleases = await platform
9
10
  .withHeaders({ Range: 'version ..; max=1, order=desc' })
10
11
  .release.list(appIdentity);
@@ -1,10 +1,11 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { ensureContainerStack } from './ensure-container-stack.js';
2
3
  /**
3
4
  * Remove one or more process types from a container app
4
5
  */
5
6
  export async function removeProcessTypes(ctx, appIdentity, processTypes, options = {}) {
6
7
  await ensureContainerStack(ctx, appIdentity, options);
7
- const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
8
+ const platform = withSignal(ctx.platform, options.signal);
8
9
  // eslint-disable-next-line camelcase
9
10
  const requestBody = { docker_image: null };
10
11
  const updatedFormations = [];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared shape of the platform's delinquency payloads. `AccountDelinquency`
3
+ * and `TeamDelinquency` are structurally identical — each carries the two
4
+ * scheduled timestamps (or `null` when nothing is scheduled) — so a single
5
+ * formatter serves both.
6
+ */
7
+ export interface DelinquencySchedule {
8
+ scheduled_deletion_time: null | string;
9
+ scheduled_suspension_time: null | string;
10
+ }
11
+ /** The kind of resource a delinquency applies to; used in the message text. */
12
+ export type DelinquencyResource = 'account' | 'team';
13
+ /**
14
+ * Format the delinquency warning sentence for a resource, or `undefined` when
15
+ * there is nothing to say. Pure — formatting only; the caller emits it.
16
+ *
17
+ * Produces a message for an upcoming suspension, a past suspension with an
18
+ * upcoming deletion, or an upcoming deletion. Returns `undefined` when neither
19
+ * timestamp is set, or when a suspension is already past with no deletion
20
+ * scheduled. Dates render in UTC so the message reads the same for every
21
+ * consumer. `now` defaults to the wall clock and is injectable for testing.
22
+ */
23
+ export declare function formatDelinquencyWarning(resource: DelinquencyResource, delinquency: DelinquencySchedule | null | undefined, now?: number): string | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Format the delinquency warning sentence for a resource, or `undefined` when
3
+ * there is nothing to say. Pure — formatting only; the caller emits it.
4
+ *
5
+ * Produces a message for an upcoming suspension, a past suspension with an
6
+ * upcoming deletion, or an upcoming deletion. Returns `undefined` when neither
7
+ * timestamp is set, or when a suspension is already past with no deletion
8
+ * scheduled. Dates render in UTC so the message reads the same for every
9
+ * consumer. `now` defaults to the wall clock and is injectable for testing.
10
+ */
11
+ export function formatDelinquencyWarning(resource, delinquency, now = Date.now()) {
12
+ const suspension = parseTime(delinquency?.scheduled_suspension_time);
13
+ const deletion = parseTime(delinquency?.scheduled_deletion_time);
14
+ if (!suspension && !deletion)
15
+ return undefined;
16
+ if (suspension) {
17
+ if (suspension > now) {
18
+ return `This ${resource} is delinquent with payment and we'll suspend it on ${new Date(suspension).toUTCString()}.`;
19
+ }
20
+ if (deletion) {
21
+ return `This ${resource} is delinquent with payment and we suspended it on ${new Date(suspension).toUTCString()}.`
22
+ + ` If the ${resource} is still delinquent, we'll delete it on ${new Date(deletion).toUTCString()}.`;
23
+ }
24
+ return undefined;
25
+ }
26
+ if (deletion) {
27
+ return `This ${resource} is delinquent with payment and we'll delete it on ${new Date(deletion).toUTCString()}.`;
28
+ }
29
+ return undefined;
30
+ }
31
+ /**
32
+ * Parse an ISO timestamp to epoch ms, or `undefined` when it is absent or
33
+ * unparseable. A `0`/`NaN` result is treated as absent so it never trips the
34
+ * truthiness checks above.
35
+ */
36
+ function parseTime(time) {
37
+ if (!time)
38
+ return undefined;
39
+ const ms = Date.parse(time);
40
+ return Number.isNaN(ms) || ms === 0 ? undefined : ms;
41
+ }
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  /**
2
3
  * Delete all custom domains from an app.
3
4
  *
@@ -7,7 +8,7 @@
7
8
  export async function clearCustomDomains(ctx, appIdentity, options = {}) {
8
9
  const { signal } = options;
9
10
  signal?.throwIfAborted();
10
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
11
+ const platform = withSignal(ctx.platform, signal);
11
12
  // List and filter to custom domains
12
13
  const domains = await platform.domain.list(appIdentity);
13
14
  const customDomains = domains.filter(d => d.kind === 'custom');
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { waitForReady } from './wait-for-ready.js';
2
3
  /**
3
4
  * Create a domain with SNI endpoint resolution and optional waiting.
@@ -11,7 +12,7 @@ import { waitForReady } from './wait-for-ready.js';
11
12
  export async function createAndWait(ctx, appIdentity, hostname, options = {}) {
12
13
  const { poller, resolveSniEndpoint, signal, sniEndpoint, timeoutMs, wait: shouldWait, waitIntervalMs, } = options;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let resolvedSniEndpoint = null;
16
17
  if (sniEndpoint) {
17
18
  // Explicit SNI endpoint provided
@@ -1,3 +1,4 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
1
2
  import { wait } from '../../../utils/wait.js';
2
3
  const DEFAULT_WAIT_INTERVAL_MS = 5000;
3
4
  /**
@@ -11,7 +12,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 5000;
11
12
  export async function waitForReady(ctx, appIdentity, options = {}) {
12
13
  const { domain, hostname, poller, signal, timeoutMs, waitIntervalMs = DEFAULT_WAIT_INTERVAL_MS, } = options;
13
14
  signal?.throwIfAborted();
14
- const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
15
+ const platform = withSignal(ctx.platform, signal);
15
16
  let domains;
16
17
  if (domain) {
17
18
  domains = [domain];
@@ -109,8 +109,13 @@ export type ExchangeExecCredentialsOptions = {
109
109
  * `Authorization` header survive). The caller feeds the returned info,
110
110
  * plus the private key it holds, to its own SSH transport; the SDK does
111
111
  * not open the connection.
112
+ *
113
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
114
+ * resolved client identity and cancellation signal into the custom-host
115
+ * client. Standalone callers omit it, and the client falls back to the
116
+ * pre-threading bare construction.
112
117
  */
113
- export declare function exchangeExecCredentials(appIdentity: string, options: ExchangeExecCredentialsOptions): Promise<ExecCredentials>;
118
+ export declare function exchangeExecCredentials(appIdentity: string, options: ExchangeExecCredentialsOptions, ctx?: Pick<ResourceCtx, 'makeClient'>): Promise<ExecCredentials>;
114
119
  /** A running exec reservation, as reported by the exec-manager status endpoint. */
115
120
  export type ExecReservation = {
116
121
  [key: string]: unknown;
@@ -130,5 +135,10 @@ export type ExecStatusOptions = {
130
135
  * custom-host Basic-auth setup as {@link exchangeExecCredentials}. The
131
136
  * caller correlates each reservation's `dyno_name` with live dyno state
132
137
  * for display.
138
+ *
139
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
140
+ * resolved client identity and cancellation signal into the custom-host
141
+ * client. Standalone callers omit it, and the client falls back to the
142
+ * pre-threading bare construction.
133
143
  */
134
- export declare function execStatus(appIdentity: string, options: ExecStatusOptions): Promise<ExecReservation[]>;
144
+ export declare function execStatus(appIdentity: string, options: ExecStatusOptions, ctx?: Pick<ResourceCtx, 'makeClient'>): Promise<ExecReservation[]>;