@heroku/sdk 0.11.2 → 0.12.1

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 (41) 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/heroku-sdk.js +44 -1
  6. package/dist/resources/data/database-credential/create.d.ts +17 -0
  7. package/dist/resources/data/database-credential/create.js +28 -0
  8. package/dist/resources/data/database-credential/destroy.d.ts +17 -0
  9. package/dist/resources/data/database-credential/destroy.js +55 -0
  10. package/dist/resources/data/database-credential/get-url.d.ts +25 -0
  11. package/dist/resources/data/database-credential/get-url.js +68 -0
  12. package/dist/resources/data/database-credential/index.d.ts +24 -0
  13. package/dist/resources/data/database-credential/index.js +24 -0
  14. package/dist/resources/data/database-credential/list.d.ts +32 -0
  15. package/dist/resources/data/database-credential/list.js +44 -0
  16. package/dist/resources/data/database-credential/rotate.d.ts +56 -0
  17. package/dist/resources/data/database-credential/rotate.js +71 -0
  18. package/dist/resources/data/internal/parse-attachment-factors.d.ts +25 -0
  19. package/dist/resources/data/internal/parse-attachment-factors.js +37 -0
  20. package/dist/resources/data/internal/postgres-utils.d.ts +6 -0
  21. package/dist/resources/data/internal/postgres-utils.js +6 -0
  22. package/dist/resources/data/postgres-database.d.ts +3 -2
  23. package/dist/resources/data/postgres-database.js +2 -1
  24. package/dist/resources/extensions/data.d.ts +1 -0
  25. package/dist/resources/extensions/data.js +1 -0
  26. package/dist/resources/platform/dyno/exec.d.ts +12 -2
  27. package/dist/resources/platform/dyno/exec.js +23 -9
  28. package/dist/resources/platform/dyno/index.js +3 -3
  29. package/dist/resources/platform/dyno/list-extended.d.ts +7 -1
  30. package/dist/resources/platform/dyno/list-extended.js +8 -3
  31. package/dist/resources/platform/dyno/run.d.ts +1 -1
  32. package/dist/resources/platform/dyno/run.js +3 -2
  33. package/dist/resources/platform/log-session/index.d.ts +1 -1
  34. package/dist/resources/platform/log-session/index.js +6 -10
  35. package/dist/resources/platform/pipeline/index.d.ts +2 -2
  36. package/dist/resources/platform/pipeline/index.js +2 -3
  37. package/dist/resources/platform/pipeline/promotion.d.ts +1 -1
  38. package/dist/resources/platform/pipeline/promotion.js +9 -13
  39. package/dist/version.d.ts +1 -0
  40. package/dist/version.js +2 -0
  41. package/package.json +15 -6
@@ -0,0 +1,71 @@
1
+ import { withSignal } from '../../../core/with-signal.js';
2
+ import { resolveAddonInternal } from '../../platform/add-on/resolve.js';
3
+ import { getPostgresServiceName, isAdvancedDatabase, isEssentialDatabase, isLegacyEssentialDatabase, } from '../internal/postgres-utils.js';
4
+ export async function rotate(ctx, addonIdentity, body = {}, options = {}) {
5
+ const { appIdentity, beforeRotate, signal } = options;
6
+ signal?.throwIfAborted();
7
+ // Bind the signal so the resolve and rotate requests abort in flight.
8
+ const platform = withSignal(ctx.platform, signal);
9
+ const addon = await resolveAddonInternal(platform, addonIdentity, {
10
+ addonService: getPostgresServiceName(), appIdentity,
11
+ });
12
+ const { all, credName, forced } = body;
13
+ const isAdvancedTier = isAdvancedDatabase(addon);
14
+ // Check for essential tier only; legacy Essential-tier databases
15
+ // get their own message below and are otherwise treated like
16
+ // any other classic-tier database for credential selection.
17
+ const isEssentialTier = isEssentialDatabase(addon);
18
+ if (!isAdvancedTier && credName && credName !== 'default') {
19
+ if (isLegacyEssentialDatabase(addon)) {
20
+ throw new Error('Legacy Essential-tier databases do not support named credentials.');
21
+ }
22
+ if (isEssentialTier) {
23
+ throw new Error('Essential-tier databases do not support named credentials.');
24
+ }
25
+ }
26
+ const data = withSignal(ctx.data, signal);
27
+ const attachments = await platform.addOnAttachment.listByAddOn(addon.id);
28
+ let ownerCred;
29
+ let defaultCred;
30
+ let credToRotate;
31
+ if (isAdvancedTier) {
32
+ const { items: availableCreds } = await data.postgresCredential.list(addon.id);
33
+ ownerCred = availableCreds.find(cred => cred.type === 'owner');
34
+ credToRotate = credName
35
+ ? availableCreds.find(cred => cred.name === credName)
36
+ : ownerCred;
37
+ }
38
+ else {
39
+ const availableCreds = await data.postgresDatabase.listCredentials(addon.id);
40
+ defaultCred = isEssentialTier
41
+ ? availableCreds[0]
42
+ : availableCreds.find(cred => cred.name === 'default');
43
+ credToRotate = credName && !isEssentialTier
44
+ ? availableCreds.find(cred => cred.name === credName)
45
+ : defaultCred;
46
+ }
47
+ await beforeRotate?.({
48
+ addon,
49
+ attachments,
50
+ credToRotate,
51
+ defaultCred,
52
+ isAdvancedTier,
53
+ isEssentialTier,
54
+ ownerCred,
55
+ });
56
+ signal?.throwIfAborted();
57
+ if (all) {
58
+ const credentials = await (isAdvancedTier
59
+ ? data.postgres.rotateCredentials(addon.id, { forced })
60
+ : data.postgresDatabase.rotateCredentials(addon.id, { forced }));
61
+ return { addon, credentials };
62
+ }
63
+ const finalCredName = isEssentialTier ? 'default' : credToRotate?.name;
64
+ if (!finalCredName) {
65
+ throw new Error(`There are no credentials on the database ${addon.name}.`);
66
+ }
67
+ const credential = await (isAdvancedTier
68
+ ? data.postgresCredential.rotate(addon.id, finalCredName, { forced })
69
+ : data.postgresDatabase.rotateCredential(addon.id, finalCredName, { forced }));
70
+ return { addon, credential };
71
+ }
@@ -0,0 +1,25 @@
1
+ declare enum AttachmentFactorKeys {
2
+ POOL = "pool",
3
+ PROXY = "proxy",
4
+ ROLE = "role"
5
+ }
6
+ export type AttachmentFactors = {
7
+ [AttachmentFactorKeys.POOL]?: string;
8
+ [AttachmentFactorKeys.PROXY]?: boolean;
9
+ [AttachmentFactorKeys.ROLE]?: string;
10
+ };
11
+ /**
12
+ * Parses a Heroku add-on attachment `namespace` string into structured factors.
13
+ *
14
+ * The namespace is a list of segments separated by `|`. Each segment must look
15
+ * like `key:value`, using only the **first** `:` as the separator so values may
16
+ * contain additional colons. Whitespace around keys and values is trimmed.
17
+ *
18
+ * Only keys listed in {@link AttachmentFactorKeys} are applied; unknown keys are
19
+ * ignored. For `proxy`, the value is interpreted as a boolean (`true` only when
20
+ * the trimmed string is exactly `"true"`). For `pool` and `role`, the value is
21
+ * kept as a string. If the same key appears more than once, the last occurrence
22
+ * wins.
23
+ */
24
+ export declare function parseAttachmentFactors(namespace?: null | string): AttachmentFactors;
25
+ export {};
@@ -0,0 +1,37 @@
1
+ var AttachmentFactorKeys;
2
+ (function (AttachmentFactorKeys) {
3
+ AttachmentFactorKeys["POOL"] = "pool";
4
+ AttachmentFactorKeys["PROXY"] = "proxy";
5
+ AttachmentFactorKeys["ROLE"] = "role";
6
+ })(AttachmentFactorKeys || (AttachmentFactorKeys = {}));
7
+ /**
8
+ * Parses a Heroku add-on attachment `namespace` string into structured factors.
9
+ *
10
+ * The namespace is a list of segments separated by `|`. Each segment must look
11
+ * like `key:value`, using only the **first** `:` as the separator so values may
12
+ * contain additional colons. Whitespace around keys and values is trimmed.
13
+ *
14
+ * Only keys listed in {@link AttachmentFactorKeys} are applied; unknown keys are
15
+ * ignored. For `proxy`, the value is interpreted as a boolean (`true` only when
16
+ * the trimmed string is exactly `"true"`). For `pool` and `role`, the value is
17
+ * kept as a string. If the same key appears more than once, the last occurrence
18
+ * wins.
19
+ */
20
+ export function parseAttachmentFactors(namespace) {
21
+ const result = {};
22
+ if (!namespace?.trim()) {
23
+ return result;
24
+ }
25
+ for (const factor of namespace.split('|')) {
26
+ const parts = factor.match(/^([^:]+):(.*)$/) ?? [];
27
+ if (parts.length !== 3) {
28
+ continue;
29
+ }
30
+ const key = parts[1].trim();
31
+ const value = (key === AttachmentFactorKeys.PROXY ? parts[2].trim() === 'true' : parts[2].trim());
32
+ if (Object.values(AttachmentFactorKeys).includes(key)) {
33
+ Object.assign(result, { [key]: value });
34
+ }
35
+ }
36
+ return result;
37
+ }
@@ -1,10 +1,13 @@
1
1
  import type { AddOn } from '@heroku/types/3.sdk';
2
+ import { PostgresCredentialInfoResult as AdvancedCredentialInfo } from '@heroku/types/data';
2
3
  import type { ResourceCtx } from '../../../core/extend-resource.js';
3
4
  import type { AddOnAttachmentWithInclusions } from '../../platform/add-on-attachment/resolve.js';
5
+ import { CredentialInfo as NonAdvancedCredentialInfo } from '../postgres-database.js';
4
6
  /**
5
7
  * Returns the first legacy database add-on on the app.
6
8
  */
7
9
  export declare function findLegacyDatabase(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string): Promise<AddOn>;
10
+ export declare function isAdvancedCredential(credential: AdvancedCredentialInfo | NonAdvancedCredentialInfo): credential is AdvancedCredentialInfo;
8
11
  export declare const getPostgresServiceName: () => string;
9
12
  /**
10
13
  * `AddOnAttachmentWithInclusions` is an internal type. The public `isPostgresAddon` takes `AddOn` only.
@@ -13,3 +16,6 @@ export declare const isPostgresAddonInternal: (addon: AddOn | AddOnAttachmentWit
13
16
  export declare const isPostgresAddon: (addon: AddOn) => boolean;
14
17
  export declare const isAdvancedDatabase: (addon: AddOn) => boolean;
15
18
  export declare const isLegacyDatabase: (addon: AddOn) => boolean;
19
+ export declare const isLegacyEssentialDatabase: (addon: AddOn) => boolean;
20
+ export declare const isEssentialDatabase: (addon: AddOn) => boolean;
21
+ export declare const isEssentialTier: (addon: AddOn) => boolean;
@@ -11,6 +11,9 @@ export async function findLegacyDatabase(ctx, appIdentity) {
11
11
  throw new Error(`No Heroku Postgres legacy database on ${appIdentity}`);
12
12
  return addon;
13
13
  }
14
+ export function isAdvancedCredential(credential) {
15
+ return 'roles' in credential;
16
+ }
14
17
  export const getPostgresServiceName = () => process.env.HEROKU_POSTGRESQL_ADDON_NAME || process.env.HEROKU_DATA_SERVICE || 'heroku-postgresql';
15
18
  /**
16
19
  * `AddOnAttachmentWithInclusions` is an internal type. The public `isPostgresAddon` takes `AddOn` only.
@@ -19,3 +22,6 @@ export const isPostgresAddonInternal = (addon) => addon.plan?.name?.split(':', 2
19
22
  export const isPostgresAddon = (addon) => isPostgresAddonInternal(addon);
20
23
  export const isAdvancedDatabase = (addon) => isPostgresAddonInternal(addon) && /^(advanced|performance)/.test(addon.plan?.name?.split(':', 2)[1] ?? '');
21
24
  export const isLegacyDatabase = (addon) => isPostgresAddonInternal(addon) && !isAdvancedDatabase(addon);
25
+ export const isLegacyEssentialDatabase = (addon) => isLegacyDatabase(addon) && /^(dev|basic|mini)/.test(addon.plan?.name?.split(':', 2)[1] ?? '');
26
+ export const isEssentialDatabase = (addon) => isLegacyDatabase(addon) && (addon.plan?.name?.split(':', 2)[1] ?? '').startsWith('essential');
27
+ export const isEssentialTier = (addon) => isEssentialDatabase(addon) || isLegacyEssentialDatabase(addon);
@@ -12,7 +12,7 @@ export type CredentialInfo = {
12
12
  database: string;
13
13
  host: string;
14
14
  name: string;
15
- port: string;
15
+ port: number;
16
16
  state: string;
17
17
  uuid: string;
18
18
  };
@@ -20,4 +20,5 @@ export declare function listCredentials(ctx: Pick<ResourceCtx, 'data' | 'platfor
20
20
  export declare const postgresDatabaseExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresDatabase", {
21
21
  listCredentials: (appIdentity: string, addonIdentity?: string, options?: ListCredentialsOptions) => Promise<CredentialInfo[]>;
22
22
  }>;
23
- export { findLegacyDatabase, getPostgresServiceName, isAdvancedDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
23
+ export { type AttachmentFactors, parseAttachmentFactors, } from './internal/parse-attachment-factors.js';
24
+ export { findLegacyDatabase, getPostgresServiceName, isAdvancedCredential, isAdvancedDatabase, isEssentialDatabase, isEssentialTier, isLegacyDatabase, isLegacyEssentialDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
@@ -11,4 +11,5 @@ export async function listCredentials(ctx, appIdentity, addonIdentity, options =
11
11
  export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', ctx => ({
12
12
  listCredentials: (appIdentity, addonIdentity, options) => listCredentials(ctx, appIdentity, addonIdentity, options),
13
13
  }));
14
- export { findLegacyDatabase, getPostgresServiceName, isAdvancedDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
14
+ export { parseAttachmentFactors, } from './internal/parse-attachment-factors.js';
15
+ export { findLegacyDatabase, getPostgresServiceName, isAdvancedCredential, isAdvancedDatabase, isEssentialDatabase, isEssentialTier, isLegacyDatabase, isLegacyEssentialDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
@@ -1,3 +1,4 @@
1
+ export { databaseCredentialExtensions } from '../data/database-credential/index.js';
1
2
  export { databaseExtensions } from '../data/database.js';
2
3
  export { maintenanceExtensions } from '../data/maintenance.js';
3
4
  export { postgresDatabaseExtensions } from '../data/postgres-database.js';
@@ -1,3 +1,4 @@
1
+ export { databaseCredentialExtensions } from '../data/database-credential/index.js';
1
2
  export { databaseExtensions } from '../data/database.js';
2
3
  export { maintenanceExtensions } from '../data/maintenance.js';
3
4
  export { postgresDatabaseExtensions } from '../data/postgres-database.js';
@@ -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[]>;
@@ -1,4 +1,5 @@
1
- import { HerokuApiClient, NotFoundError } from '@heroku/heroku-fetch';
1
+ import { NotFoundError } from '@heroku/heroku-fetch';
2
+ import { makeCustomHostClient } from '../../../core/extend-resource.js';
2
3
  import { withSignal } from '../../../core/with-signal.js';
3
4
  import { wait } from '../../../utils/wait.js';
4
5
  import { waitForInfo } from './wait-for-info.js';
@@ -107,11 +108,16 @@ export async function restartForExec(ctx, appIdentity, dynoIdentity, options = {
107
108
  * `Authorization` header survive). The caller feeds the returned info,
108
109
  * plus the private key it holds, to its own SSH transport; the SDK does
109
110
  * not open the connection.
111
+ *
112
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
113
+ * resolved client identity and cancellation signal into the custom-host
114
+ * client. Standalone callers omit it, and the client falls back to the
115
+ * pre-threading bare construction.
110
116
  */
111
- export async function exchangeExecCredentials(appIdentity, options) {
117
+ export async function exchangeExecCredentials(appIdentity, options, ctx = {}) {
112
118
  options.signal?.throwIfAborted();
113
119
  const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
114
- const client = newExecClient(endpoint.baseUrl, options.clientOptions);
120
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions, ctx);
115
121
  const path = `${endpoint.apiPath}/${encodeURIComponent(options.dyno)}`;
116
122
  const response = await client.put(path, { client_key: options.publicKey }, {
117
123
  headers: {
@@ -130,11 +136,16 @@ export async function exchangeExecCredentials(appIdentity, options) {
130
136
  * custom-host Basic-auth setup as {@link exchangeExecCredentials}. The
131
137
  * caller correlates each reservation's `dyno_name` with live dyno state
132
138
  * for display.
139
+ *
140
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
141
+ * resolved client identity and cancellation signal into the custom-host
142
+ * client. Standalone callers omit it, and the client falls back to the
143
+ * pre-threading bare construction.
133
144
  */
134
- export async function execStatus(appIdentity, options) {
145
+ export async function execStatus(appIdentity, options, ctx = {}) {
135
146
  options.signal?.throwIfAborted();
136
147
  const endpoint = resolveExecEndpoint(appIdentity, options.apiKey, options.configVars);
137
- const client = newExecClient(endpoint.baseUrl, options.clientOptions);
148
+ const client = newExecClient(endpoint.baseUrl, options.clientOptions, ctx);
138
149
  const response = await client.get(endpoint.apiPath, {
139
150
  headers: { ...options.headers, Authorization: endpoint.authorization },
140
151
  signal: options.signal,
@@ -169,10 +180,13 @@ function resolveExecEndpoint(appIdentity, apiKey, configVars) {
169
180
  baseUrl: `${url.protocol}//${url.host}`,
170
181
  };
171
182
  }
172
- function newExecClient(baseUrl, clientOptions) {
173
- return new HerokuApiClient({
174
- ...clientOptions, baseUrl, service: 'custom', token: '',
175
- });
183
+ function newExecClient(baseUrl, clientOptions, ctx = {}) {
184
+ // exec-manager is a custom host with Basic auth embedded per request. The
185
+ // custom-host client forces `token: ''` and inherits only the SDK's identity
186
+ // (User-Agent) and signal — never `reauthorize`/headers, so a bad-Basic 401
187
+ // can't drive a spurious `heroku login`. The caller's `clientOptions` still
188
+ // apply on top.
189
+ return makeCustomHostClient(ctx, baseUrl, clientOptions);
176
190
  }
177
191
  function toBasicAuth(username, password) {
178
192
  return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
@@ -35,10 +35,10 @@ export async function restartDynos(ctx, appIdentity, target, options = {}) {
35
35
  }
36
36
  export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
37
37
  enableExec: (appIdentity, options) => (enableExec(ctx, appIdentity, options)),
38
- exchangeExecCredentials: (appIdentity, options) => (exchangeExecCredentials(appIdentity, options)),
38
+ exchangeExecCredentials: (appIdentity, options) => (exchangeExecCredentials(appIdentity, options, ctx)),
39
39
  execPrereqs: (appIdentity, options) => (execPrereqs(ctx, appIdentity, options)),
40
- execStatus: (appIdentity, options) => (execStatus(appIdentity, options)),
41
- listExtended: (appIdentity, options) => (listExtended(appIdentity, options)),
40
+ execStatus: (appIdentity, options) => (execStatus(appIdentity, options, ctx)),
41
+ listExtended: (appIdentity, options) => (listExtended(appIdentity, options, ctx)),
42
42
  restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
43
43
  restartForExec: (appIdentity, dynoIdentity, options) => (restartForExec(ctx, appIdentity, dynoIdentity, options)),
44
44
  run: (appIdentity, command, options) => (runDyno(ctx, appIdentity, command, options)),
@@ -1,5 +1,6 @@
1
1
  import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
2
2
  import type { Dyno } from '@heroku/types/3.sdk';
3
+ import type { ResourceCtx } from '../../../core/extend-resource.js';
3
4
  /**
4
5
  * Operational detail the platform attaches to each dyno only when the
5
6
  * `?extended=true` query is used with the `version=3.sdk` Accept
@@ -46,5 +47,10 @@ export type ListExtendedOptions = {
46
47
  *
47
48
  * Unlike the routes client, this does not auto-paginate — it issues a
48
49
  * single GET, matching the historical CLI behavior.
50
+ *
51
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
52
+ * resolved client identity and cancellation signal into the bare client.
53
+ * Standalone callers omit it, and the client falls back to the pre-threading
54
+ * bare construction.
49
55
  */
50
- export declare function listExtended(appIdentity: string, options?: ListExtendedOptions): Promise<DynoExtended[]>;
56
+ export declare function listExtended(appIdentity: string, options?: ListExtendedOptions, ctx?: Pick<ResourceCtx, 'makeClient'>): Promise<DynoExtended[]>;
@@ -1,4 +1,4 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
1
+ import { makeCtxClient } from '../../../core/extend-resource.js';
2
2
  const ACCEPT_EXTENDED = 'application/vnd.heroku+json; version=3.sdk';
3
3
  /**
4
4
  * List an app's dynos with the extended operational detail block.
@@ -12,10 +12,15 @@ const ACCEPT_EXTENDED = 'application/vnd.heroku+json; version=3.sdk';
12
12
  *
13
13
  * Unlike the routes client, this does not auto-paginate — it issues a
14
14
  * single GET, matching the historical CLI behavior.
15
+ *
16
+ * @param ctx - Internal: supplied by the SDK's dyno extension to thread the
17
+ * resolved client identity and cancellation signal into the bare client.
18
+ * Standalone callers omit it, and the client falls back to the pre-threading
19
+ * bare construction.
15
20
  */
16
- export async function listExtended(appIdentity, options = {}) {
21
+ export async function listExtended(appIdentity, options = {}, ctx = {}) {
17
22
  options.signal?.throwIfAborted();
18
- const apiClient = new HerokuApiClient({
23
+ const apiClient = makeCtxClient(ctx, 'platform', {
19
24
  ...options.clientOptions,
20
25
  service: 'platform',
21
26
  });
@@ -73,4 +73,4 @@ export type RunDynoOptions = {
73
73
  * open their own interactive session; the SDK does not establish the
74
74
  * session, spawn ssh, or manage TTY state.
75
75
  */
76
- export declare function runDyno(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
76
+ export declare function runDyno(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
@@ -1,4 +1,5 @@
1
- import { HerokuApiClient, HerokuApiError } from '@heroku/heroku-fetch';
1
+ import { HerokuApiError } from '@heroku/heroku-fetch';
2
+ import { makeCtxClient } from '../../../core/extend-resource.js';
2
3
  import { withSignal } from '../../../core/with-signal.js';
3
4
  const DEFAULT_RUN_RETRIES = 2;
4
5
  const ACCEPT_RUN_INSIDE = 'application/vnd.heroku+json; version=3.sdk';
@@ -59,7 +60,7 @@ export async function runDyno(ctx, appIdentity, command, options = {}) {
59
60
  }
60
61
  async function postRun(ctx, appIdentity, body, options) {
61
62
  if (options.dyno) {
62
- const apiClient = new HerokuApiClient({
63
+ const apiClient = makeCtxClient(ctx, 'platform', {
63
64
  ...options.clientOptions,
64
65
  service: 'platform',
65
66
  });
@@ -95,7 +95,7 @@ export type StreamLogsOptions = {
95
95
  * - non-2xx response from the logplex URL
96
96
  * - errors thrown by `logSession.create` (auth, app-not-found, etc.)
97
97
  */
98
- export declare function streamLogs(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
98
+ export declare function streamLogs(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, appIdentity: string, options?: StreamLogsOptions): AsyncGenerator<string, void, unknown>;
99
99
  export declare const logSessionExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "logSession", {
100
100
  parseHerokuLogLine: typeof parseHerokuLogLine;
101
101
  streamLogs: (appIdentity: string, options?: StreamLogsOptions) => AsyncGenerator<string, void, unknown>;
@@ -1,6 +1,5 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
1
  import createDebug from 'debug';
3
- import { extendResource } from '../../../core/extend-resource.js';
2
+ import { extendResource, makeCustomHostClient } from '../../../core/extend-resource.js';
4
3
  import { withSignal } from '../../../core/with-signal.js';
5
4
  import { getGeneration } from '../app/index.js';
6
5
  import { parseHerokuLogLine } from './parser.js';
@@ -68,15 +67,12 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
68
67
  try {
69
68
  // Logplex URLs hit a region-specific stream host (e.g.
70
69
  // virginia.sessions.logs.heroku.com). The token is embedded in
71
- // the URL, so we use a custom-service client with no auth and
72
- // a per-call baseUrl; this also picks up heroku-fetch's
73
- // env-var proxy support.
70
+ // the URL, so we use a custom-host client with no auth and a
71
+ // per-call baseUrl, inheriting only the SDK's identity and
72
+ // cancellation; this also picks up heroku-fetch's env-var proxy
73
+ // support.
74
74
  const parsed = new URL(session.logplex_url);
75
- const logplexClient = new HerokuApiClient({
76
- baseUrl: parsed.origin,
77
- service: 'custom',
78
- token: '',
79
- });
75
+ const logplexClient = makeCustomHostClient(ctx, parsed.origin);
80
76
  // Fir's telemetry-proxy sends newline-delimited plain text by
81
77
  // default, but concatenates records with zero delimiter bytes —
82
78
  // splitting on '\n' yields nothing. Requesting SSE flips the
@@ -1,5 +1,5 @@
1
+ import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
1
2
  import type { Pipeline } from '@heroku/types/3.sdk';
2
- import { type HerokuApiClientOptions } from '@heroku/heroku-fetch';
3
3
  import type { ResourceCtx } from '../../../core/extend-resource.js';
4
4
  export type ResolvePipelineOptions = {
5
5
  clientOptions?: HerokuApiClientOptions;
@@ -47,7 +47,7 @@ export declare class PipelineAmbiguousError extends Error {
47
47
  * that want to prompt the user should catch this and inspect
48
48
  * `error.matches`.
49
49
  */
50
- export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
50
+ export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
51
51
  export declare const pipelineExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "pipeline", {
52
52
  resolve: (identity: string, options?: ResolvePipelineOptions) => Promise<Pipeline>;
53
53
  }>;
@@ -1,5 +1,4 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
- import { extendResource } from '../../../core/extend-resource.js';
1
+ import { extendResource, makeCtxClient } from '../../../core/extend-resource.js';
3
2
  import { withSignal } from '../../../core/with-signal.js';
4
3
  export class PipelineNotFoundError extends Error {
5
4
  identity;
@@ -56,7 +55,7 @@ export async function resolvePipeline(ctx, identity, options = {}) {
56
55
  // Bind the signal so the lookup aborts in flight.
57
56
  return withSignal(ctx.platform, options.signal).pipeline.info(identity);
58
57
  }
59
- const apiClient = new HerokuApiClient({
58
+ const apiClient = makeCtxClient(ctx, 'platform', {
60
59
  ...options.clientOptions,
61
60
  service: 'platform',
62
61
  });
@@ -15,7 +15,7 @@ export type PromotePipelineResult = {
15
15
  promotion: PipelinePromotion;
16
16
  targets: PipelinePromotionTarget[];
17
17
  };
18
- export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
18
+ export declare function promotePipeline(ctx: Pick<ResourceCtx, 'makeClient' | 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
19
19
  export declare const pipelinePromotionExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "pipelinePromotion", {
20
20
  promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
21
21
  }>;
@@ -1,6 +1,5 @@
1
- import { HerokuApiClient } from '@heroku/heroku-fetch';
2
1
  import createDebug from 'debug';
3
- import { extendResource } from '../../../core/extend-resource.js';
2
+ import { extendResource, makeCustomHostClient } from '../../../core/extend-resource.js';
4
3
  import { withSignal } from '../../../core/with-signal.js';
5
4
  import { isAbortError } from '../../../utils/is-abort-error.js';
6
5
  import { wait } from '../../../utils/wait.js';
@@ -20,7 +19,7 @@ export async function promotePipeline(ctx, body, options = {}) {
20
19
  debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
21
20
  const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
22
21
  const streamRunner = onReleaseStream
23
- ? createReleaseStreamRunner(platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
22
+ ? createReleaseStreamRunner(ctx, platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
24
23
  : undefined;
25
24
  let poll = 0;
26
25
  while (true) {
@@ -48,7 +47,7 @@ export async function promotePipeline(ctx, body, options = {}) {
48
47
  * output stream and hands it to `onReleaseStream`. Subsequent calls are
49
48
  * no-ops; the stream is one-shot per promotion.
50
49
  */
51
- function createReleaseStreamRunner(platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
50
+ function createReleaseStreamRunner(ctx, platform, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
52
51
  let handled = false;
53
52
  return async (targets) => {
54
53
  if (handled || targets.length !== 1)
@@ -64,24 +63,21 @@ function createReleaseStreamRunner(platform, onReleaseStream, releaseStreamMaxAt
64
63
  return;
65
64
  }
66
65
  debug('promote release-stream fetching url=%s maxAttempts=%d', release.output_stream_url, releaseStreamMaxAttempts);
67
- const stream = await fetchReleaseOutput(release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
66
+ const stream = await fetchReleaseOutput(ctx, release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
68
67
  await onReleaseStream({ stream, target });
69
68
  };
70
69
  }
71
70
  export const pipelinePromotionExtensions = extendResource('platform', 'pipelinePromotion', ctx => ({
72
71
  promote: (body, options) => promotePipeline(ctx, body, options),
73
72
  }));
74
- async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
73
+ async function fetchReleaseOutput(ctx, url, maxAttempts, intervalMs, signal) {
75
74
  const parsed = new URL(url);
76
75
  const path = `${parsed.pathname}${parsed.search}`;
77
76
  // The release output URL points at a third-party stream host (e.g. busl).
78
- // Use a custom-service client so the platform prefixUrl and bearer token
79
- // don't leak across origins.
80
- const buslClient = new HerokuApiClient({
81
- baseUrl: parsed.origin,
82
- service: 'custom',
83
- token: '',
84
- });
77
+ // Use a custom-host client so the platform prefixUrl, bearer token, and
78
+ // other auth/routing options don't leak across origins, while still
79
+ // inheriting the SDK's identity (User-Agent) and cancellation signal.
80
+ const buslClient = makeCustomHostClient(ctx, parsed.origin);
85
81
  let attempt = 0;
86
82
  while (true) {
87
83
  signal?.throwIfAborted();
@@ -0,0 +1 @@
1
+ export declare const SDK_VERSION = "0.12.1";
@@ -0,0 +1,2 @@
1
+ // Generated from package.json by scripts/write-version.mjs (build prebuild). Do not edit.
2
+ export const SDK_VERSION = '0.12.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -102,6 +102,10 @@
102
102
  "types": "./dist/resources/platform/vpn-connection/index.d.ts",
103
103
  "default": "./dist/resources/platform/vpn-connection/index.js"
104
104
  },
105
+ "./resources/data/database-credential": {
106
+ "types": "./dist/resources/data/database-credential/index.d.ts",
107
+ "default": "./dist/resources/data/database-credential/index.js"
108
+ },
105
109
  "./resources/data/redis": {
106
110
  "types": "./dist/resources/data/redis/index.d.ts",
107
111
  "default": "./dist/resources/data/redis/index.js"
@@ -123,19 +127,24 @@
123
127
  },
124
128
  "scripts": {
125
129
  "clean": "rimraf dist",
130
+ "generate:version": "node scripts/write-version.mjs",
131
+ "prebuild": "npm run generate:version",
126
132
  "build": "npm run clean && tsc",
133
+ "prelint": "npm run generate:version",
127
134
  "lint": "eslint .",
128
135
  "lint:fix": "eslint . --fix",
136
+ "pretest": "npm run generate:version",
129
137
  "test": "vitest run",
130
138
  "test:watch": "vitest",
131
139
  "test:coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=lcovonly",
140
+ "pretest:types": "npm run generate:version",
132
141
  "test:types": "tsc --noEmit -p tsconfig.type-tests.json",
133
142
  "prepare": "npm run build",
134
143
  "example": "tsx"
135
144
  },
136
145
  "license": "Apache-2.0",
137
146
  "engines": {
138
- "node": ">=20"
147
+ "node": ">=22"
139
148
  },
140
149
  "files": [
141
150
  "dist"
@@ -144,17 +153,17 @@
144
153
  "@heroku-cli/test-utils": "^1.0.3",
145
154
  "@types/debug": "^4.1.12",
146
155
  "@types/node": "^26.5.1",
147
- "@vitest/coverage-v8": "^4.1.8",
156
+ "@vitest/coverage-v8": "^5.0.5",
157
+ "@vitest/eslint-plugin": "^1.6.0",
148
158
  "eslint": "^9.39.4",
149
159
  "eslint-config-oclif": "^6.0.167",
150
- "@vitest/eslint-plugin": "^1.6.0",
151
160
  "rimraf": "^6.1.3",
152
161
  "tsx": "^4.22.4",
153
162
  "typescript": "^6.0.2",
154
- "vitest": "^4.1.4"
163
+ "vitest": "^5.0.5"
155
164
  },
156
165
  "dependencies": {
157
- "@heroku/heroku-fetch": "^0.2.0",
166
+ "@heroku/heroku-fetch": "^0.2.3",
158
167
  "@heroku/types": "^5.0.0",
159
168
  "debug": "^4.4.0"
160
169
  }