@hyperfixation/cli 0.1.2 → 0.1.3

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.
@@ -7,7 +7,8 @@ import type { FetchLike } from "../providers/http.js";
7
7
  * A step refused to act, or a command it ran failed.
8
8
  *
9
9
  * Carries no provider body and no environment: the only thing a step is allowed to say about a
10
- * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half.
10
+ * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half — it quotes the
11
+ * provider's own explanation, with every credential and every value it was sent blanked out.
11
12
  */
12
13
  export declare class StepFailed extends Error {
13
14
  constructor(message: string);
@@ -9,7 +9,8 @@ import { fetchTemplate } from "../template-source.js";
9
9
  * A step refused to act, or a command it ran failed.
10
10
  *
11
11
  * Carries no provider body and no environment: the only thing a step is allowed to say about a
12
- * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half.
12
+ * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half — it quotes the
13
+ * provider's own explanation, with every credential and every value it was sent blanked out.
13
14
  */
14
15
  export class StepFailed extends Error {
15
16
  constructor(message) {
@@ -6,6 +6,16 @@ import { StepFailed, type CloudStepContext } from "./context.js";
6
6
  export declare const COOLIFY_ENVIRONMENT = "production";
7
7
  /** Where the build pack looks for the compose file, relative to the repository root. */
8
8
  export declare const COMPOSE_LOCATION = "/docker-compose.prod.yml";
9
+ /**
10
+ * The compose service the app's domain is attached to — the one the template publishes 3000 from.
11
+ *
12
+ * A `dockercompose` application cannot take `domains` at all (Coolify 4.3.21: 422 `The domains
13
+ * field cannot be used for dockercompose applications`), and `docker_compose_domains` names a
14
+ * service, so one of them has to be named here. A template that renamed `web` would deploy an app
15
+ * Coolify's proxy routes nothing to, which is why this is a constant with a test behind it rather
16
+ * than a literal in the payload.
17
+ */
18
+ export declare const COMPOSE_DOMAIN_SERVICE = "web";
9
19
  /** The compose file the drift assertion reads, under the app's directory. */
10
20
  export declare const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
11
21
  /**
@@ -9,6 +9,16 @@ import { appFqdn, StepFailed } from "./context.js";
9
9
  export const COOLIFY_ENVIRONMENT = "production";
10
10
  /** Where the build pack looks for the compose file, relative to the repository root. */
11
11
  export const COMPOSE_LOCATION = "/docker-compose.prod.yml";
12
+ /**
13
+ * The compose service the app's domain is attached to — the one the template publishes 3000 from.
14
+ *
15
+ * A `dockercompose` application cannot take `domains` at all (Coolify 4.3.21: 422 `The domains
16
+ * field cannot be used for dockercompose applications`), and `docker_compose_domains` names a
17
+ * service, so one of them has to be named here. A template that renamed `web` would deploy an app
18
+ * Coolify's proxy routes nothing to, which is why this is a constant with a test behind it rather
19
+ * than a literal in the payload.
20
+ */
21
+ export const COMPOSE_DOMAIN_SERVICE = "web";
12
22
  /** The compose file the drift assertion reads, under the app's directory. */
13
23
  export const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
14
24
  /**
@@ -255,7 +265,7 @@ async function findOrCreateApplication(context, coolify, projectUuid, environmen
255
265
  docker_compose_location: COMPOSE_LOCATION,
256
266
  connect_to_docker_network: true,
257
267
  name,
258
- domains: `https://${fqdn}`,
268
+ docker_compose_domains: [{ name: COMPOSE_DOMAIN_SERVICE, domain: `https://${fqdn}` }],
259
269
  // The `deploy` step is what deploys, once the environment is set and the database migrated.
260
270
  instant_deploy: false,
261
271
  });
package/dist/doctor.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface DoctorOptions {
49
49
  * whole point of this command is one screen that says whether anything needs attention.
50
50
  *
51
51
  * Nothing here prints a secret. The read token authorizes the status request and never appears
52
- * in a finding; a provider's response body is dropped for the same reason (`ProviderError`).
52
+ * in a finding, and a provider's refusal reaches a finding redacted (`ProviderError`).
53
53
  */
54
54
  export declare function doctor(options?: DoctorOptions): Promise<DoctorResult>;
55
55
  /** The report as printed: a blank line and a header per app, then its findings. */
package/dist/doctor.js CHANGED
@@ -20,7 +20,7 @@ export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
20
20
  * whole point of this command is one screen that says whether anything needs attention.
21
21
  *
22
22
  * Nothing here prints a secret. The read token authorizes the status request and never appears
23
- * in a finding; a provider's response body is dropped for the same reason (`ProviderError`).
23
+ * in a finding, and a provider's refusal reaches a finding redacted (`ProviderError`).
24
24
  */
25
25
  export async function doctor(options = {}) {
26
26
  const env = options.env ?? process.env;
@@ -7,6 +7,7 @@ export class CloudflareClient {
7
7
  provider: "cloudflare",
8
8
  baseUrl: options.url ?? CLOUDFLARE_API_URL,
9
9
  headers: { authorization: `Bearer ${options.token}` },
10
+ secrets: [options.token],
10
11
  fetch: options.fetch,
11
12
  });
12
13
  }
@@ -29,14 +29,28 @@ export interface CoolifyApplicationRequest {
29
29
  build_pack: "nixpacks" | "railpack" | "static" | "dockerfile" | "dockercompose";
30
30
  name?: string;
31
31
  description?: string;
32
- /** Comma-separated; the app's `https://<name>.<HF_BASE_DOMAIN>`. */
32
+ /**
33
+ * Comma-separated. Rejected outright for a `dockercompose` build pack — Coolify 4.3.21 answers
34
+ * 422 `The domains field cannot be used for dockercompose applications` — so
35
+ * `docker_compose_domains` is what `hf new` sends and this is for the other four packs.
36
+ */
33
37
  domains?: string;
38
+ /** One entry per compose service that gets a domain; `hf new` sends exactly one. */
39
+ docker_compose_domains?: readonly CoolifyComposeDomain[];
34
40
  ports_exposes?: string;
35
41
  docker_compose_location?: string;
36
42
  connect_to_docker_network?: boolean;
37
43
  instant_deploy?: boolean;
38
44
  is_auto_deploy_enabled?: boolean;
39
45
  }
46
+ /** A domain on one service of a compose application, which is how a `dockercompose` app gets one. */
47
+ export interface CoolifyComposeDomain {
48
+ /** The service name as `docker-compose.prod.yml` spells it. */
49
+ name: string;
50
+ /** Comma-separated, same as `domains`. */
51
+ domain: string;
52
+ redirect?: "www" | "non-www" | "both";
53
+ }
40
54
  export interface CoolifyApplication {
41
55
  uuid: string;
42
56
  }
@@ -6,6 +6,7 @@ export class CoolifyClient {
6
6
  provider: "coolify",
7
7
  baseUrl: `${options.url.replace(/\/+$/, "")}/api/v1`,
8
8
  headers: { authorization: `Bearer ${options.token}` },
9
+ secrets: [options.token],
9
10
  fetch: options.fetch,
10
11
  });
11
12
  }
@@ -42,6 +43,9 @@ export class CoolifyClient {
42
43
  method: "PATCH",
43
44
  path: `/applications/${segment(appUuid)}/envs/bulk`,
44
45
  body: { data },
46
+ // The app's whole environment goes out in this one call, and a 422 names the variables it
47
+ // rejected by quoting them.
48
+ secrets: data.map((variable) => variable.value),
45
49
  });
46
50
  }
47
51
  async deploy(uuid, options = {}) {
@@ -11,6 +11,7 @@ export class GithubClient {
11
11
  accept: "application/vnd.github+json",
12
12
  "x-github-api-version": "2022-11-28",
13
13
  },
14
+ secrets: [options.token],
14
15
  fetch: options.fetch,
15
16
  });
16
17
  }
@@ -7,15 +7,23 @@ export interface ProviderRequest {
7
7
  path: string;
8
8
  query?: Record<string, string | number | boolean | undefined>;
9
9
  body?: unknown;
10
+ /**
11
+ * Values this request carries that a provider may echo back at it — the app's environment, in
12
+ * the one call that sends it. Blanked out of the error message; see `explain`.
13
+ */
14
+ secrets?: readonly string[];
10
15
  }
16
+ /** How much of a rejected request's explanation reaches the message before it is cut. */
17
+ export declare const EXPLANATION_LIMIT = 500;
11
18
  /**
12
19
  * A provider answered with a status outside 2xx.
13
20
  *
14
- * The response body is on `body` and deliberately **not** in `message`: a Coolify 422 echoes
15
- * the fields it rejected, and those fields are the app's whole environment — its database URL,
16
- * its status tokens, its Langfuse secret key. `hf` prints `error.message`, so a body that
17
- * quotes a secret would land in a terminal and a scrollback. Nor is the query string included,
18
- * for the same reason.
21
+ * `message` carries the response's own `message` and `errors` and nothing else of it: those two are
22
+ * where every provider here puts the reason, and the rest of a body is free to quote what was sent.
23
+ * What reaches the message is redacted first — every credential the client was built with, every
24
+ * value the request declared, and anything shaped like a password — because `hf` prints
25
+ * `error.message` and a terminal keeps a scrollback. The raw body is on `body` for a caller that
26
+ * wants it; the request's own headers and query string are in neither.
19
27
  */
20
28
  export declare class ProviderError extends Error {
21
29
  readonly provider: string;
@@ -23,7 +31,7 @@ export declare class ProviderError extends Error {
23
31
  readonly method: HttpMethod;
24
32
  readonly path: string;
25
33
  readonly body: string;
26
- constructor(provider: string, request: ProviderRequest, status: number, body: string);
34
+ constructor(provider: string, request: ProviderRequest, status: number, body: string, explanation?: string);
27
35
  }
28
36
  export interface TransportOptions {
29
37
  /** Names the provider in errors. */
@@ -32,8 +40,17 @@ export interface TransportOptions {
32
40
  baseUrl: string;
33
41
  /** Sent on every request — the authorization header, and whatever else the API insists on. */
34
42
  headers: Record<string, string>;
43
+ /** The credentials this client was built with; blanked out of every error message. */
44
+ secrets?: readonly string[];
35
45
  fetch?: FetchLike;
36
46
  }
47
+ /**
48
+ * Why the provider refused, out of its `message` and `errors` and redacted.
49
+ *
50
+ * `undefined` when the body is not JSON or carries neither key: a provider that explained nothing
51
+ * leaves the status to speak, rather than a page of HTML in a terminal.
52
+ */
53
+ export declare function explain(body: string, secrets: readonly string[]): string | undefined;
37
54
  export type Transport = <Result>(request: ProviderRequest) => Promise<Result>;
38
55
  /** Builds the `request` function the clients in this directory are written against. */
39
56
  export declare function createTransport(options: TransportOptions): Transport;
@@ -1,11 +1,15 @@
1
+ import { redactPasswords } from "../database.js";
2
+ /** How much of a rejected request's explanation reaches the message before it is cut. */
3
+ export const EXPLANATION_LIMIT = 500;
1
4
  /**
2
5
  * A provider answered with a status outside 2xx.
3
6
  *
4
- * The response body is on `body` and deliberately **not** in `message`: a Coolify 422 echoes
5
- * the fields it rejected, and those fields are the app's whole environment — its database URL,
6
- * its status tokens, its Langfuse secret key. `hf` prints `error.message`, so a body that
7
- * quotes a secret would land in a terminal and a scrollback. Nor is the query string included,
8
- * for the same reason.
7
+ * `message` carries the response's own `message` and `errors` and nothing else of it: those two are
8
+ * where every provider here puts the reason, and the rest of a body is free to quote what was sent.
9
+ * What reaches the message is redacted first — every credential the client was built with, every
10
+ * value the request declared, and anything shaped like a password — because `hf` prints
11
+ * `error.message` and a terminal keeps a scrollback. The raw body is on `body` for a caller that
12
+ * wants it; the request's own headers and query string are in neither.
9
13
  */
10
14
  export class ProviderError extends Error {
11
15
  provider;
@@ -13,8 +17,9 @@ export class ProviderError extends Error {
13
17
  method;
14
18
  path;
15
19
  body;
16
- constructor(provider, request, status, body) {
17
- super(`${provider} ${request.method} ${request.path} failed: HTTP ${status}`);
20
+ constructor(provider, request, status, body, explanation) {
21
+ super(`${provider} ${request.method} ${request.path} failed: HTTP ${status}` +
22
+ (explanation === undefined ? "" : `: ${explanation}`));
18
23
  this.name = "ProviderError";
19
24
  this.provider = provider;
20
25
  this.status = status;
@@ -23,6 +28,38 @@ export class ProviderError extends Error {
23
28
  this.body = body;
24
29
  }
25
30
  }
31
+ /**
32
+ * Why the provider refused, out of its `message` and `errors` and redacted.
33
+ *
34
+ * `undefined` when the body is not JSON or carries neither key: a provider that explained nothing
35
+ * leaves the status to speak, rather than a page of HTML in a terminal.
36
+ */
37
+ export function explain(body, secrets) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(body);
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ if (parsed === null || typeof parsed !== "object")
46
+ return undefined;
47
+ const { message, errors } = parsed;
48
+ if (message === undefined && errors === undefined)
49
+ return undefined;
50
+ let text = JSON.stringify({
51
+ ...(message === undefined ? {} : { message }),
52
+ ...(errors === undefined ? {} : { errors }),
53
+ });
54
+ for (const secret of secrets) {
55
+ // Short values are skipped: a two-character secret would blank half the explanation with it.
56
+ if (secret.length < 4)
57
+ continue;
58
+ text = text.split(JSON.stringify(secret).slice(1, -1)).join("***");
59
+ }
60
+ text = redactPasswords(text);
61
+ return text.length > EXPLANATION_LIMIT ? `${text.slice(0, EXPLANATION_LIMIT)}…` : text;
62
+ }
26
63
  /** Builds the `request` function the clients in this directory are written against. */
27
64
  export function createTransport(options) {
28
65
  // Looked up per request, not captured: a test's mock server replaces `globalThis.fetch` after
@@ -45,7 +82,8 @@ export function createTransport(options) {
45
82
  });
46
83
  const text = await response.text();
47
84
  if (!response.ok) {
48
- throw new ProviderError(options.provider, request, response.status, text);
85
+ const secrets = [...(options.secrets ?? []), ...(request.secrets ?? [])];
86
+ throw new ProviderError(options.provider, request, response.status, text, explain(text, secrets));
49
87
  }
50
88
  return (text === "" ? undefined : JSON.parse(text));
51
89
  };
@@ -8,6 +8,8 @@ export class LangfuseClient {
8
8
  headers: {
9
9
  authorization: `Basic ${Buffer.from(options.orgKey, "utf8").toString("base64")}`,
10
10
  },
11
+ // Both halves of the pair: an error message must not quote either one back.
12
+ secrets: options.orgKey.split(":"),
11
13
  fetch: options.fetch,
12
14
  });
13
15
  }
@@ -7,6 +7,7 @@ export class SentryClient {
7
7
  provider: "sentry",
8
8
  baseUrl: options.url ?? SENTRY_URL,
9
9
  headers: { authorization: `Bearer ${options.token}` },
10
+ secrets: [options.token],
10
11
  fetch: options.fetch,
11
12
  });
12
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "description": "The hf binary and its Turborepo generator templates",
6
6
  "repository": {
@@ -29,15 +29,15 @@
29
29
  "!dist/test-support/**"
30
30
  ],
31
31
  "dependencies": {
32
- "@hyperfixation/auth": "0.1.2",
33
- "@hyperfixation/core": "0.1.2",
34
- "@hyperfixation/db": "0.1.2",
32
+ "@hyperfixation/auth": "0.1.3",
33
+ "@hyperfixation/core": "0.1.3",
34
+ "@hyperfixation/db": "0.1.3",
35
35
  "giget": "3.3.1",
36
36
  "pg": "^8.23.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@hyperfixation/eslint-config": "0.1.2",
40
- "@hyperfixation/testing": "0.1.2",
39
+ "@hyperfixation/eslint-config": "0.1.3",
40
+ "@hyperfixation/testing": "0.1.3",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",