@hyperfixation/cli 0.1.2 → 0.1.4

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
  /**
@@ -223,12 +233,11 @@ async function findOrCreateProject(context, coolify) {
223
233
  async function productionEnvironment(context, coolify, projectUuid) {
224
234
  const environments = await coolify.listEnvironments(projectUuid);
225
235
  const production = environments.find((environment) => environment.name === COOLIFY_ENVIRONMENT);
226
- if (production === undefined) {
227
- throw new StepFailed(`the Coolify project ${context.names.given} has no ${COOLIFY_ENVIRONMENT} environment ` +
228
- `(it has ${environments.map((environment) => environment.name).join(", ") || "none"}), ` +
229
- "and the API has no endpoint that creates one: add it in Coolify and re-run hf new");
230
- }
231
- return production.uuid;
236
+ if (production !== undefined)
237
+ return production.uuid;
238
+ const created = await coolify.createEnvironment(projectUuid, { name: COOLIFY_ENVIRONMENT });
239
+ context.io.out(`${context.names.given}: created the ${COOLIFY_ENVIRONMENT} environment in the Coolify project`);
240
+ return created.uuid;
232
241
  }
233
242
  async function findOrCreateApplication(context, coolify, projectUuid, environmentUuid) {
234
243
  const name = context.names.given;
@@ -255,7 +264,7 @@ async function findOrCreateApplication(context, coolify, projectUuid, environmen
255
264
  docker_compose_location: COMPOSE_LOCATION,
256
265
  connect_to_docker_network: true,
257
266
  name,
258
- domains: `https://${fqdn}`,
267
+ docker_compose_domains: [{ name: COMPOSE_DOMAIN_SERVICE, domain: `https://${fqdn}` }],
259
268
  // The `deploy` step is what deploys, once the environment is set and the database migrated.
260
269
  instant_deploy: false,
261
270
  });
@@ -37,19 +37,33 @@ export const templateStep = {
37
37
  await substituteTree(fetched, names);
38
38
  // The marker is what `assertTemplateSource` looks for: an app is never a template twice.
39
39
  await rm(path.join(fetched, TEMPLATE_MARKER));
40
+ // Recorded before the rename, not after: a crash between the two must leave a directory this
41
+ // run's state vouches for, or the rerun would refuse the app it just made.
42
+ await context.state.patch({ templateStartedAt: new Date(context.now()).toISOString() });
40
43
  await rename(fetched, dir);
41
44
  context.io.out(`${names.given}: template fetched into ${dir}`);
42
45
  },
43
46
  };
44
47
  /**
45
- * A directory already at the target: this app on a run whose state was lost, or something else.
48
+ * A directory already at the target: this app on a run that crashed after the rename, or
49
+ * something else.
46
50
  *
47
- * "This app" means a substituted template — its `package.json` carries the underscored app name
48
- * and the marker is gone. Anything else is the local flow's rule, refused rather than written
49
- * into.
51
+ * Adopted only on a genuine resume — the state cache records that this app's template step began
52
+ * (`templateStartedAt`) or finished. With no such record the directory is not ours however much it
53
+ * looks like it: an earlier `hf new --local` leaves a substituted scaffold with the right
54
+ * `package.json` name, and adopting it once pushed a stale template to a new repo.
55
+ *
56
+ * On a resume, "this app" means a substituted template — its `package.json` carries the
57
+ * underscored app name and the marker is gone. Anything else is the local flow's rule, refused
58
+ * rather than written into.
50
59
  */
51
60
  async function adoptOrRefuse(context) {
52
- const { dir, names } = context;
61
+ const { dir, names, state } = context;
62
+ const resuming = state.state.templateStartedAt !== undefined || state.isDone("template");
63
+ if (!resuming) {
64
+ throw new TemplateError(`${dir} already exists and hf has no record of creating it, so it will not adopt it on a ` +
65
+ `first run. Move it away (or delete it) and rerun; the template is fetched fresh.`);
66
+ }
53
67
  const substituted = !(await exists(path.join(dir, TEMPLATE_MARKER))) && (await packageName(dir)) === names.appName;
54
68
  if (!substituted) {
55
69
  throw new TemplateError(`${dir} already exists; hf new will not write into it`);
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
  }
@@ -118,6 +132,11 @@ export declare class CoolifyClient {
118
132
  listProjects(): Promise<CoolifyProject[]>;
119
133
  getProject(uuid: string): Promise<CoolifyProject>;
120
134
  listEnvironments(projectUuid: string): Promise<CoolifyEnvironment[]>;
135
+ createEnvironment(projectUuid: string, body: {
136
+ name: string;
137
+ }): Promise<{
138
+ uuid: string;
139
+ }>;
121
140
  createPrivateGithubAppApplication(body: CoolifyApplicationRequest): Promise<CoolifyApplication>;
122
141
  /**
123
142
  * Every application, so a rerun can find the one it created last time by name.
@@ -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
  }
@@ -25,6 +26,13 @@ export class CoolifyClient {
25
26
  path: `/projects/${segment(projectUuid)}/environments`,
26
27
  });
27
28
  }
29
+ async createEnvironment(projectUuid, body) {
30
+ return await this.request({
31
+ method: "POST",
32
+ path: `/projects/${segment(projectUuid)}/environments`,
33
+ body,
34
+ });
35
+ }
28
36
  async createPrivateGithubAppApplication(body) {
29
37
  return await this.request({ method: "POST", path: "/applications/private-github-app", body });
30
38
  }
@@ -42,6 +50,9 @@ export class CoolifyClient {
42
50
  method: "PATCH",
43
51
  path: `/applications/${segment(appUuid)}/envs/bulk`,
44
52
  body: { data },
53
+ // The app's whole environment goes out in this one call, and a 422 names the variables it
54
+ // rejected by quoting them.
55
+ secrets: data.map((variable) => variable.value),
45
56
  });
46
57
  }
47
58
  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/dist/state.d.ts CHANGED
@@ -50,6 +50,15 @@ export interface StatusTokenState {
50
50
  */
51
51
  export interface AppState {
52
52
  steps: Partial<Record<StepName, StepRecord>>;
53
+ /**
54
+ * ISO 8601, written just before the `template` step renames the substituted fetch into place.
55
+ *
56
+ * The proof that a directory at the app's path is this run's own: `steps.template` is only
57
+ * recorded after the rename, so a crash in between would otherwise leave a directory no state
58
+ * vouches for. Without it the step refuses to adopt a directory — a stale scaffold from an
59
+ * earlier `hf new --local` looks exactly like a lost run.
60
+ */
61
+ templateStartedAt?: string;
53
62
  /** `owner/name` of the app's GitHub repository. */
54
63
  repo?: string;
55
64
  coolify?: CoolifyState;
package/dist/state.js CHANGED
@@ -149,6 +149,7 @@ function parseAppState(contents, file) {
149
149
  state.steps = parseSteps(value, file);
150
150
  break;
151
151
  case "repo":
152
+ case "templateStartedAt":
152
153
  case "sentryDsn":
153
154
  case "betterAuthSecret":
154
155
  case "lastRestoreCheckAt":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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.4",
33
+ "@hyperfixation/core": "0.1.4",
34
+ "@hyperfixation/db": "0.1.4",
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.4",
40
+ "@hyperfixation/testing": "0.1.4",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",