@heroku/heroku-cli-util 10.0.0-beta.2 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,7 +102,7 @@ try {
102
102
 
103
103
  // PG types (for TypeScript)
104
104
  /**
105
- * types.pg.AddOnAttachmentWithConfigVarsAndPlan
105
+ * types.pg.ExtendedAddonAttachment
106
106
  * types.pg.AddOnWithRelatedData
107
107
  * types.pg.ConnectionDetails
108
108
  * types.pg.ConnectionDetailsWithAttachment
@@ -0,0 +1,16 @@
1
+ import type { ExtendedAddonAttachment } from '../types/pg/data-api.js';
2
+ /**
3
+ * This error is used internally to signal when the `AddonAttachmentResolver` cannot resolve
4
+ * to a single attachment.
5
+ */
6
+ export declare class AmbiguousError extends Error {
7
+ readonly matches: ExtendedAddonAttachment[];
8
+ readonly type: string;
9
+ readonly body: {
10
+ id: string;
11
+ message: string;
12
+ };
13
+ readonly message: string;
14
+ readonly statusCode = 422;
15
+ constructor(matches: ExtendedAddonAttachment[], type: string);
16
+ }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * This error is used internally to signal when the `AddonAttachmentResolver` cannot resolve
3
+ * to a single attachment.
4
+ */
1
5
  export class AmbiguousError extends Error {
2
6
  matches;
3
7
  type;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This error is used only when the Platform API add-on attachment resolver resolves to one or more matches, but
3
+ * a namespace (credential name) was provided and none of them had that exact namespace.
4
+ *
5
+ * We would've expected this to use the same error message the resolver returns when it throws a Not Found error:
6
+ * `"Couldn't find that add on attachment."`, because it's attachments and not add-ons that are being resolved.
7
+ *
8
+ * However, that's not the case here and we cannot refactor this to use the expected messaging because the only
9
+ * command checking credentials, `pg:psql`, has a check with a strict match on the error message text to change
10
+ * the error displayed to the user
11
+ * ([see here](https://github.com/heroku/cli/blob/b79f2c93d6f21eafd9d93983bcd377e4bc7f8438/packages/cli/src/commands/pg/psql.ts#L32)).
12
+ */
13
+ export declare class NotFound extends Error {
14
+ readonly body: {
15
+ id: string;
16
+ message: string;
17
+ };
18
+ readonly message = "Couldn't find that addon.";
19
+ readonly statusCode = 404;
20
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * This error is used only when the Platform API add-on attachment resolver resolves to one or more matches, but
3
+ * a namespace (credential name) was provided and none of them had that exact namespace.
4
+ *
5
+ * We would've expected this to use the same error message the resolver returns when it throws a Not Found error:
6
+ * `"Couldn't find that add on attachment."`, because it's attachments and not add-ons that are being resolved.
7
+ *
8
+ * However, that's not the case here and we cannot refactor this to use the expected messaging because the only
9
+ * command checking credentials, `pg:psql`, has a check with a strict match on the error message text to change
10
+ * the error displayed to the user
11
+ * ([see here](https://github.com/heroku/cli/blob/b79f2c93d6f21eafd9d93983bcd377e4bc7f8438/packages/cli/src/commands/pg/psql.ts#L32)).
12
+ */
13
+ export class NotFound extends Error {
14
+ body = { id: 'not_found', message: 'Couldn\'t find that addon.' };
15
+ message = 'Couldn\'t find that addon.';
16
+ statusCode = 404;
17
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { AmbiguousError } from './types/errors/ambiguous.js';
2
- import { NotFound } from './types/errors/not-found.js';
3
- import { AddOnAttachmentWithConfigVarsAndPlan, AddOnWithRelatedData, Link } from './types/pg/data-api.js';
1
+ import { APIClient } from '@heroku-cli/command';
2
+ import { AmbiguousError } from './errors/ambiguous.js';
3
+ import { NotFound } from './errors/not-found.js';
4
+ import { AddOnWithRelatedData, ExtendedAddonAttachment, Link } from './types/pg/data-api.js';
4
5
  import { ConnectionDetails, ConnectionDetailsWithAttachment, TunnelConfig } from './types/pg/tunnel.js';
5
- import { getDatabase } from './utils/pg/databases.js';
6
+ import DatabaseResolver from './utils/pg/databases.js';
6
7
  import getHost from './utils/pg/host.js';
7
- import { exec } from './utils/pg/psql.js';
8
8
  import { prompt } from './ux/prompt.js';
9
9
  import { styledHeader } from './ux/styled-header.js';
10
10
  import { styledJSON } from './ux/styled-json.js';
@@ -12,25 +12,28 @@ import { styledObject } from './ux/styled-object.js';
12
12
  import { table } from './ux/table.js';
13
13
  import { wait } from './ux/wait.js';
14
14
  export declare const types: {
15
- errors: {
16
- AmbiguousError: typeof AmbiguousError;
17
- NotFound: typeof NotFound;
18
- };
19
15
  pg: {
20
- AddOnAttachmentWithConfigVarsAndPlan: AddOnAttachmentWithConfigVarsAndPlan;
21
16
  AddOnWithRelatedData: AddOnWithRelatedData;
22
17
  ConnectionDetails: ConnectionDetails;
23
18
  ConnectionDetailsWithAttachment: ConnectionDetailsWithAttachment;
19
+ ExtendedAddonAttachment: ExtendedAddonAttachment;
24
20
  Link: Link;
25
21
  TunnelConfig: TunnelConfig;
26
22
  };
27
23
  };
28
24
  export declare const utils: {
25
+ errors: {
26
+ AmbiguousError: typeof AmbiguousError;
27
+ NotFound: typeof NotFound;
28
+ };
29
29
  pg: {
30
- databases: typeof getDatabase;
30
+ DatabaseResolver: typeof DatabaseResolver;
31
+ fetcher: {
32
+ database(heroku: APIClient, appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
33
+ };
31
34
  host: typeof getHost;
32
35
  psql: {
33
- exec: typeof exec;
36
+ exec(connectionDetails: ConnectionDetailsWithAttachment, query: string, psqlCmdArgs?: string[]): Promise<string>;
34
37
  };
35
38
  };
36
39
  };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { AmbiguousError } from './types/errors/ambiguous.js';
2
- import { NotFound } from './types/errors/not-found.js';
3
- import { getDatabase } from './utils/pg/databases.js';
1
+ import { AmbiguousError } from './errors/ambiguous.js';
2
+ import { NotFound } from './errors/not-found.js';
3
+ import DatabaseResolver from './utils/pg/databases.js';
4
4
  import getHost from './utils/pg/host.js';
5
- import { exec } from './utils/pg/psql.js';
5
+ import PsqlService from './utils/pg/psql.js';
6
6
  import { confirm } from './ux/confirm.js';
7
7
  import { prompt } from './ux/prompt.js';
8
8
  import { styledHeader } from './ux/styled-header.js';
@@ -11,25 +11,34 @@ import { styledObject } from './ux/styled-object.js';
11
11
  import { table } from './ux/table.js';
12
12
  import { wait } from './ux/wait.js';
13
13
  export const types = {
14
- errors: {
15
- AmbiguousError,
16
- NotFound,
17
- },
18
14
  pg: {
19
- AddOnAttachmentWithConfigVarsAndPlan: {},
20
15
  AddOnWithRelatedData: {},
21
16
  ConnectionDetails: {},
22
17
  ConnectionDetailsWithAttachment: {},
18
+ ExtendedAddonAttachment: {},
23
19
  Link: {},
24
20
  TunnelConfig: {},
25
21
  },
26
22
  };
27
23
  export const utils = {
24
+ errors: {
25
+ AmbiguousError,
26
+ NotFound, // This should be NotFoundError for consistency, but we're keeping it for backwards compatibility
27
+ },
28
28
  pg: {
29
- databases: getDatabase,
29
+ DatabaseResolver,
30
+ fetcher: {
31
+ database(heroku, appId, attachmentId, namespace) {
32
+ const databaseResolver = new DatabaseResolver(heroku);
33
+ return databaseResolver.getDatabase(appId, attachmentId, namespace);
34
+ },
35
+ },
30
36
  host: getHost,
31
37
  psql: {
32
- exec,
38
+ exec(connectionDetails, query, psqlCmdArgs = []) {
39
+ const psqlService = new PsqlService(connectionDetails);
40
+ return psqlService.execQuery(query, psqlCmdArgs);
41
+ },
33
42
  },
34
43
  },
35
44
  };
@@ -1,13 +1,41 @@
1
1
  import * as Heroku from '@heroku-cli/schema';
2
+ type DeepRequired<T> = T extends object ? {
3
+ [K in keyof T]-?: DeepRequired<T[K]>;
4
+ } : T;
5
+ /**
6
+ * This is the base type for the property `addon` on an `AddOnAttachment` as described in the Platform API Reference.
7
+ */
8
+ type AddonDescriptor = DeepRequired<Heroku.AddOnAttachment>['addon'];
9
+ /**
10
+ * This is the modified type for the `addon` property when the request to Platform API includes the value `addon:plan`
11
+ * in the `Accept-Inclusion` header.
12
+ */
13
+ type AddonDescriptorWithPlanInclusion = {
14
+ plan: {
15
+ id: string;
16
+ name: string;
17
+ };
18
+ } & AddonDescriptor;
19
+ /**
20
+ * This is the modified type for the `AddOnAttachment` type when the request to Platform API includes the value
21
+ * `config_vars` in the `Accept-Inclusion` header.
22
+ */
23
+ type AddonAttachmentWithConfigVarsInclusion = {
24
+ config_vars: string[];
25
+ } & DeepRequired<Heroku.AddOnAttachment>;
26
+ /**
27
+ * This is the modified type for the `AddOnAttachment` we use on these lib functions because all requests made to
28
+ * Platform API to get add-on attachments, either through the Add-on Attachment List endpoint or the
29
+ * add-on attachment resolver action endpoint, include the header `Accept-Inclusion: addon:plan,config_vars`.
30
+ */
31
+ export type ExtendedAddonAttachment = {
32
+ addon: AddonDescriptorWithPlanInclusion;
33
+ } & AddonAttachmentWithConfigVarsInclusion;
2
34
  export type AddOnWithRelatedData = {
3
35
  attachment_names?: string[];
4
36
  links?: Link[];
5
- plan: Required<Heroku.AddOn['plan']>;
6
- } & Required<Heroku.AddOnAttachment['addon']>;
7
- export type AddOnAttachmentWithConfigVarsAndPlan = {
8
- addon: AddOnWithRelatedData;
9
- config_vars: Heroku.AddOn['config_vars'];
10
- } & Required<Heroku.AddOnAttachment>;
37
+ plan: DeepRequired<Heroku.AddOn['plan']>;
38
+ } & AddonDescriptor;
11
39
  export type Link = {
12
40
  attachment_name?: string;
13
41
  created_at: string;
@@ -15,3 +43,4 @@ export type Link = {
15
43
  name: string;
16
44
  remote?: Link;
17
45
  };
46
+ export {};
@@ -1,11 +1,8 @@
1
- import type { AddOnAttachment } from '@heroku-cli/schema';
2
1
  import { Server } from 'node:net';
3
2
  import * as createTunnel from 'tunnel-ssh';
4
- import type { AddOnAttachmentWithConfigVarsAndPlan } from './data-api.js';
3
+ import type { ExtendedAddonAttachment } from './data-api.js';
5
4
  export type ConnectionDetails = {
6
5
  _tunnel?: Server;
7
- bastionHost?: string;
8
- bastionKey?: string;
9
6
  database: string;
10
7
  host: string;
11
8
  password: string;
@@ -13,10 +10,16 @@ export type ConnectionDetails = {
13
10
  port: string;
14
11
  url: string;
15
12
  user: string;
16
- };
13
+ } & BastionConfig;
17
14
  export type ConnectionDetailsWithAttachment = {
18
- attachment: Required<{
19
- addon: AddOnAttachmentWithConfigVarsAndPlan;
20
- } & AddOnAttachment>;
15
+ attachment: ExtendedAddonAttachment;
21
16
  } & ConnectionDetails;
22
17
  export type TunnelConfig = createTunnel.Config;
18
+ export interface BastionConfigResponse {
19
+ host: string;
20
+ private_key: string;
21
+ }
22
+ export type BastionConfig = {
23
+ bastionHost?: string;
24
+ bastionKey?: string;
25
+ };
@@ -1,9 +1,13 @@
1
1
  import type { APIClient } from '@heroku-cli/command';
2
- import type { AddOnAttachment } from '@heroku-cli/schema';
3
- import type { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api.js';
4
- export declare const appAttachment: (heroku: APIClient, app: string | undefined, id: string, options?: {
5
- addon_service?: string;
2
+ import type { ExtendedAddonAttachment } from '../../types/pg/data-api.js';
3
+ export interface AddonAttachmentResolverOptions {
4
+ addonService?: string;
6
5
  namespace?: string;
7
- }) => Promise<{
8
- addon: AddOnAttachmentWithConfigVarsAndPlan;
9
- } & AddOnAttachment>;
6
+ }
7
+ export default class AddonAttachmentResolver {
8
+ private readonly heroku;
9
+ private readonly attachmentHeaders;
10
+ constructor(heroku: APIClient);
11
+ resolve(appId: string | undefined, attachmentId: string, options?: AddonAttachmentResolverOptions): Promise<ExtendedAddonAttachment>;
12
+ private singularize;
13
+ }
@@ -1,24 +1,33 @@
1
- import { AmbiguousError } from '../../types/errors/ambiguous.js';
2
- import { NotFound } from '../../types/errors/not-found.js';
3
- export const appAttachment = async (heroku, app, id, options = {}) => {
4
- const result = await heroku.post('/actions/addon-attachments/resolve', {
5
- // eslint-disable-next-line camelcase
6
- body: { addon_attachment: id, addon_service: options.addon_service, app }, headers: attachmentHeaders,
7
- });
8
- return singularize('addon_attachment', options.namespace)(result.body);
9
- };
10
- const attachmentHeaders = {
11
- Accept: 'application/vnd.heroku+json; version=3.sdk',
12
- 'Accept-Inclusion': 'addon:plan,config_vars',
13
- };
14
- function singularize(type, namespace) {
15
- return (matches) => {
1
+ import { AmbiguousError } from '../../errors/ambiguous.js';
2
+ import { NotFound } from '../../errors/not-found.js';
3
+ export default class AddonAttachmentResolver {
4
+ heroku;
5
+ attachmentHeaders = {
6
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
7
+ 'Accept-Inclusion': 'addon:plan,config_vars',
8
+ };
9
+ constructor(heroku) {
10
+ this.heroku = heroku;
11
+ }
12
+ async resolve(appId, attachmentId, options = {}) {
13
+ const { body: attachments } = await this.heroku.post('/actions/addon-attachments/resolve', {
14
+ // eslint-disable-next-line camelcase
15
+ body: { addon_attachment: attachmentId, addon_service: options.addonService, app: appId },
16
+ headers: this.attachmentHeaders,
17
+ });
18
+ return this.singularize(attachments, options.namespace);
19
+ }
20
+ singularize(attachments, namespace) {
21
+ let matches;
16
22
  if (namespace) {
17
- matches = matches.filter(m => m.namespace === namespace);
23
+ matches = attachments.filter(m => m.namespace === namespace);
24
+ }
25
+ else if (attachments.length > 1) {
26
+ // In cases that aren't specific enough, keep only attachments without a namespace
27
+ matches = attachments.filter(m => !Reflect.has(m, 'namespace') || m.namespace === null);
18
28
  }
19
- else if (matches.length > 1) {
20
- // In cases that aren't specific enough, filter by namespace
21
- matches = matches.filter(m => !Reflect.has(m, 'namespace') || m.namespace === null);
29
+ else {
30
+ matches = attachments;
22
31
  }
23
32
  switch (matches.length) {
24
33
  case 0: {
@@ -28,8 +37,8 @@ function singularize(type, namespace) {
28
37
  return matches[0];
29
38
  }
30
39
  default: {
31
- throw new AmbiguousError(matches, type ?? '');
40
+ throw new AmbiguousError(matches, 'addon_attachment');
32
41
  }
33
42
  }
34
- };
43
+ }
35
44
  }
@@ -1,30 +1,62 @@
1
1
  import type { APIClient } from '@heroku-cli/command';
2
+ import { Server } from 'node:net';
2
3
  import * as createTunnel from 'tunnel-ssh';
3
- import { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api.js';
4
- import { ConnectionDetails } from '../../types/pg/tunnel.js';
5
- import { TunnelConfig } from '../../types/pg/tunnel.js';
6
- export declare const bastionKeyPlan: (a: AddOnAttachmentWithConfigVarsAndPlan) => boolean;
7
- export declare const env: (db: ConnectionDetails) => {
8
- TZ?: string;
9
- PGAPPNAME: string;
10
- PGSSLMODE: string;
11
- };
12
- export declare function fetchConfig(heroku: APIClient, db: {
13
- id: string;
14
- }): Promise<import("@heroku/http-call").HTTP<{
15
- host: string;
16
- private_key: string;
17
- }>>;
18
- export declare const getBastion: (config: Record<string, string>, baseName: string) => {
19
- bastionHost: string;
20
- bastionKey: string;
21
- } | {
22
- bastionHost?: undefined;
23
- bastionKey?: undefined;
24
- };
25
- export declare function getConfigs(db: ConnectionDetails): {
4
+ import { ExtendedAddonAttachment } from '../../types/pg/data-api.js';
5
+ import { BastionConfig, ConnectionDetailsWithAttachment, TunnelConfig } from '../../types/pg/tunnel.js';
6
+ /**
7
+ * Determines whether the attachment belongs to an add-on installed onto a non-shield Private Space.
8
+ * If true, the bastion information needs to be fetched from the Data API.
9
+ * For add-ons installed onto a Shield Private Space, the bastion information should be fetched from config vars.
10
+ *
11
+ * @param attachment - The add-on attachment to check
12
+ * @returns True if the attachment belongs to a non-shield Private Space, false otherwise
13
+ */
14
+ export declare function bastionKeyPlan(attachment: ExtendedAddonAttachment): boolean;
15
+ /**
16
+ * Fetches the bastion configuration from the Data API (only relevant for add-ons installed onto a
17
+ * non-shield Private Space).
18
+ * For add-ons installed onto a Shield Private Space, the bastion information is stored in the config vars.
19
+ *
20
+ * @param heroku - The Heroku API client
21
+ * @param addon - The add-on information
22
+ * @returns Promise that resolves to the bastion configuration
23
+ */
24
+ export declare function fetchBastionConfig(heroku: APIClient, addon: ExtendedAddonAttachment['addon']): Promise<BastionConfig>;
25
+ /**
26
+ * Returns the bastion configuration from the config vars for add-ons installed onto Shield
27
+ * Private Spaces.
28
+ *
29
+ * If there are bastions, extracts a host and a key from the config vars.
30
+ * If there are no bastions, returns an empty Object.
31
+ *
32
+ * We assert that _BASTIONS and _BASTION_KEY always exist together.
33
+ * If either is falsy, pretend neither exist.
34
+ *
35
+ * @param config - The configuration variables object
36
+ * @param baseName - The base name for the configuration variables
37
+ * @returns The bastion configuration object
38
+ */
39
+ export declare const getBastionConfig: (config: Record<string, string>, baseName: string) => BastionConfig;
40
+ /**
41
+ * Returns both the required environment variables to effect the psql command execution and the tunnel
42
+ * configuration according to the database connection details.
43
+ *
44
+ * @param connectionDetails - The database connection details with attachment information
45
+ * @returns Object containing database environment variables and tunnel configuration
46
+ */
47
+ export declare function getPsqlConfigs(connectionDetails: ConnectionDetailsWithAttachment): {
26
48
  dbEnv: NodeJS.ProcessEnv;
27
49
  dbTunnelConfig: createTunnel.Config;
28
50
  };
29
- export declare function sshTunnel(db: ConnectionDetails, dbTunnelConfig: TunnelConfig, timeout?: number): Promise<void | import("net").Server | null>;
30
- export declare function tunnelConfig(db: ConnectionDetails): TunnelConfig;
51
+ export type PsqlConfigs = ReturnType<typeof getPsqlConfigs>;
52
+ /**
53
+ * Establishes an SSH tunnel to the database using the provided configuration.
54
+ *
55
+ * @param connectionDetails - The database connection details with attachment information
56
+ * @param dbTunnelConfig - The tunnel configuration object
57
+ * @param timeout - The timeout in milliseconds (default: 10000)
58
+ * @param createSSHTunnel - The function to create the SSH tunnel (default: promisified createTunnel.default)
59
+ * @returns Promise that resolves to the tunnel server or null if no bastion key is provided
60
+ * @throws Error if unable to establish the tunnel
61
+ */
62
+ export declare function sshTunnel(connectionDetails: ConnectionDetailsWithAttachment, dbTunnelConfig: TunnelConfig, timeout?: number, createSSHTunnel?: (arg1: createTunnel.Config | undefined) => Promise<Server>): Promise<Server | void>;