@hyperfixation/cli 0.1.1 → 0.1.2

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/dist/checklist.js CHANGED
@@ -13,7 +13,7 @@ export function checklistLines(input) {
13
13
  lines.push(` - ${parts[0]}`, ...parts.slice(1).map((part) => ` ${part}`), "");
14
14
  };
15
15
  if (input.providerKeysSent.length === 0) {
16
- add("The app is serving FIXTURE drafts: no ANTHROPIC_API_KEY or OPENAI_API_KEY was configured,", "so llm.run returns canned text and /api/status reports llm.mode=fixtures. Paste a key", "into Coolify's environment for this application and redeploy.");
16
+ add("The app is serving FIXTURE drafts: no ANTHROPIC_API_KEY or OPENAI_API_KEY was configured,", "so llm.run returns canned text. /api/status reports llm.mode=fixtures once the app's", "worker has reported the mode — until then, and on a core older than 0.1.1, it says", "unknown. Paste a key into Coolify's environment for this application and redeploy.");
17
17
  }
18
18
  add("Third-party keys go into Coolify's environment for this application — and every new name", "also has to be added to REQUIRED_ENV (src/env.ts), .env.example and all three", "environment: blocks of docker-compose.prod.yml. A name Coolify carries that compose does", "not interpolate never reaches a container, and hf new refuses the next run until they agree.");
19
19
  add(`Metabase reads through ${roleNames(names.appName).readonly}:`, `postgres://${roleNames(names.appName).readonly}:<password>@${input.dbHost}:5432/${names.databaseName}`, `the password is database.readonlyPassword in ${input.stateFile}.`);
@@ -24,6 +24,14 @@ export declare const CONTAINER_PROVIDED_ENV: readonly ["HF_PROCESS", "HF_BUILD_S
24
24
  * checklist is what says it out loud.
25
25
  */
26
26
  export declare const OPTIONAL_PROVIDER_ENV: readonly ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
27
+ /**
28
+ * The three `hf new` omits when the `langfuse` step provisioned no keys.
29
+ *
30
+ * All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
31
+ * worker — registers the span processor only when none of them is empty, so a base URL on its own
32
+ * configures nothing and only reads as though it did.
33
+ */
34
+ export declare const OPTIONAL_LANGFUSE_ENV: readonly ["LANGFUSE_BASE_URL", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"];
27
35
  /**
28
36
  * The app's environment and what `docker-compose.prod.yml` interpolates have diverged.
29
37
  *
@@ -49,12 +57,14 @@ export declare function neededEnvNames(dir: string): Promise<string[]>;
49
57
  /**
50
58
  * Refuses to PATCH an environment that is not the one the compose file needs.
51
59
  *
52
- * A provider key the operator has not configured is deliberately absent rather than missing, so it
53
- * is excused here and reported in the checklist instead.
60
+ * A provider key the operator has not configured, and the three Langfuse variables when the step
61
+ * provisioned no keys, are deliberately absent rather than missing: each is excused here and
62
+ * reported in the checklist instead.
54
63
  */
55
64
  export declare function assertEnvsMatchCompose(dir: string, sent: readonly string[]): Promise<void>;
56
65
  /**
57
- * The twelve variables the deployed app runs on, or ten when no provider key is configured.
66
+ * The twelve variables the deployed app runs on, less any of the five optional ones — the two
67
+ * provider keys and the three Langfuse variables — that nothing provisioned.
58
68
  *
59
69
  * Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
60
70
  * that no provider hands back, so a run that did not persist it would lock every existing session
@@ -27,6 +27,18 @@ export const CONTAINER_PROVIDED_ENV = ["HF_PROCESS", "HF_BUILD_SHA"];
27
27
  * checklist is what says it out loud.
28
28
  */
29
29
  export const OPTIONAL_PROVIDER_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
30
+ /**
31
+ * The three `hf new` omits when the `langfuse` step provisioned no keys.
32
+ *
33
+ * All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
34
+ * worker — registers the span processor only when none of them is empty, so a base URL on its own
35
+ * configures nothing and only reads as though it did.
36
+ */
37
+ export const OPTIONAL_LANGFUSE_ENV = [
38
+ "LANGFUSE_BASE_URL",
39
+ "LANGFUSE_PUBLIC_KEY",
40
+ "LANGFUSE_SECRET_KEY",
41
+ ];
30
42
  /** Which operator key carries each of them, in `REQUIRED_ENV` order. */
31
43
  const PROVIDER_ENV_SOURCE = [
32
44
  ["ANTHROPIC_API_KEY", "HF_ANTHROPIC_API_KEY"],
@@ -88,20 +100,22 @@ export async function neededEnvNames(dir) {
88
100
  /**
89
101
  * Refuses to PATCH an environment that is not the one the compose file needs.
90
102
  *
91
- * A provider key the operator has not configured is deliberately absent rather than missing, so it
92
- * is excused here and reported in the checklist instead.
103
+ * A provider key the operator has not configured, and the three Langfuse variables when the step
104
+ * provisioned no keys, are deliberately absent rather than missing: each is excused here and
105
+ * reported in the checklist instead.
93
106
  */
94
107
  export async function assertEnvsMatchCompose(dir, sent) {
95
108
  const needed = await neededEnvNames(dir);
96
109
  const sentNames = new Set(sent);
97
- const optional = new Set(OPTIONAL_PROVIDER_ENV);
110
+ const optional = new Set([...OPTIONAL_PROVIDER_ENV, ...OPTIONAL_LANGFUSE_ENV]);
98
111
  const missing = needed.filter((name) => !sentNames.has(name) && !optional.has(name));
99
112
  const extra = sent.filter((name) => !needed.includes(name));
100
113
  if (missing.length > 0 || extra.length > 0)
101
114
  throw new EnvDrift(missing, extra);
102
115
  }
103
116
  /**
104
- * The twelve variables the deployed app runs on, or ten when no provider key is configured.
117
+ * The twelve variables the deployed app runs on, less any of the five optional ones — the two
118
+ * provider keys and the three Langfuse variables — that nothing provisioned.
105
119
  *
106
120
  * Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
107
121
  * that no provider hands back, so a run that did not persist it would lock every existing session
@@ -136,10 +150,12 @@ export async function buildAppEnvs(context) {
136
150
  { key: "SMTP_URL", value: config.HF_SMTP_URL },
137
151
  { key: "EMAIL_FROM", value: config.HF_EMAIL_FROM },
138
152
  { key: "SENTRY_DSN", value: context.state.state.sentryDsn ?? "" },
139
- { key: "LANGFUSE_BASE_URL", value: config.HF_LANGFUSE_URL },
140
- { key: "LANGFUSE_PUBLIC_KEY", value: langfuse.publicKey ?? "" },
141
- { key: "LANGFUSE_SECRET_KEY", value: langfuse.secretKey ?? "" },
142
153
  ];
154
+ // Omitted outright when the langfuse step reused nothing and created nothing: an empty trio
155
+ // would deploy the same telemetry — none — while reading as configured in Coolify's UI.
156
+ if (langfuse.publicKey !== undefined && langfuse.secretKey !== undefined) {
157
+ envs.push({ key: "LANGFUSE_BASE_URL", value: config.HF_LANGFUSE_URL }, { key: "LANGFUSE_PUBLIC_KEY", value: langfuse.publicKey }, { key: "LANGFUSE_SECRET_KEY", value: langfuse.secretKey });
158
+ }
143
159
  for (const [key, value] of providerKeys(context.config))
144
160
  envs.push({ key, value });
145
161
  return envs;
@@ -8,5 +8,10 @@ import type { CloudStepContext } from "./context.js";
8
8
  * once, at creation, and the only copy is the state file this run may not have — so a new key is
9
9
  * created and the old ones keep working, which costs an unused key and never an app that cannot
10
10
  * authenticate.
11
+ *
12
+ * Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
13
+ * the step falls back to the project key pair the operator configured, and without that to a
14
+ * warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
15
+ * the operator can decide otherwise.
11
16
  */
12
17
  export declare const langfuseStep: Step<CloudStepContext>;
@@ -2,6 +2,12 @@ import { requireOperatorConfig } from "../config.js";
2
2
  import { LangfuseClient } from "../providers/langfuse.js";
3
3
  /** Langfuse keeps data indefinitely at 0, and any other value needs a paid entitlement. */
4
4
  const RETENTION_DAYS = 0;
5
+ /** Said in the warning and again in the closing checklist, so neither run nor log has to be read. */
6
+ const NOT_CONFIGURED = "Langfuse tracing is not configured: neither HF_LANGFUSE_ORG_KEY nor an " +
7
+ "HF_LANGFUSE_PUBLIC_KEY/HF_LANGFUSE_SECRET_KEY pair is set, so LANGFUSE_BASE_URL, " +
8
+ "LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY were not sent to Coolify and the app records no " +
9
+ "traces. To add them later, take a project's key pair from Langfuse → project settings → API " +
10
+ "keys, set all three in Coolify's environment for this application, and redeploy.";
5
11
  /**
6
12
  * The app's Langfuse project and a key pair for it.
7
13
  *
@@ -10,11 +16,20 @@ const RETENTION_DAYS = 0;
10
16
  * once, at creation, and the only copy is the state file this run may not have — so a new key is
11
17
  * created and the old ones keep working, which costs an unused key and never an app that cannot
12
18
  * authenticate.
19
+ *
20
+ * Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
21
+ * the step falls back to the project key pair the operator configured, and without that to a
22
+ * warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
23
+ * the operator can decide otherwise.
13
24
  */
14
25
  export const langfuseStep = {
15
26
  name: "langfuse",
16
27
  run: async (context) => {
17
28
  const { names } = context;
29
+ if ((context.config.HF_LANGFUSE_ORG_KEY ?? "") === "") {
30
+ await reuseOrSkip(context);
31
+ return;
32
+ }
18
33
  const required = requireOperatorConfig(context.config, ["HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY"], { env: context.env });
19
34
  const langfuse = new LangfuseClient({
20
35
  url: required.HF_LANGFUSE_URL,
@@ -33,3 +48,24 @@ export const langfuseStep = {
33
48
  });
34
49
  },
35
50
  };
51
+ /**
52
+ * The two paths without an org key: the operator's own project key pair, or nothing.
53
+ *
54
+ * No request either way — a project-scoped pair cannot list or create projects, so there is
55
+ * nothing to ask Langfuse that would not fail.
56
+ */
57
+ async function reuseOrSkip(context) {
58
+ const { names, config } = context;
59
+ const publicKey = config.HF_LANGFUSE_PUBLIC_KEY ?? "";
60
+ const secretKey = config.HF_LANGFUSE_SECRET_KEY ?? "";
61
+ if (publicKey === "" || secretKey === "") {
62
+ context.io.out(`WARNING: ${names.given}: ${NOT_CONFIGURED}`);
63
+ context.checklist.push(NOT_CONFIGURED);
64
+ return;
65
+ }
66
+ // The public key names the project without being a secret, which is the only identifier this
67
+ // path has: nothing here may ask Langfuse what the project is called.
68
+ context.io.out(`${names.given}: reusing the configured Langfuse project keys (${publicKey}) — every app ` +
69
+ "configured with them traces into that one project");
70
+ await context.state.patch({ langfuse: { publicKey, secretKey } });
71
+ }
@@ -4,6 +4,14 @@ import { ProviderError } from "../providers/http.js";
4
4
  import { gitHead, mustRun, short, StepFailed } from "./context.js";
5
5
  /** One page of installations, and of an installation's repositories. */
6
6
  const PER_PAGE = 100;
7
+ /**
8
+ * What GitHub answers a token that may not list installations.
9
+ *
10
+ * `GET /user/installations` is documented as a GitHub App user-to-server endpoint, so every
11
+ * classic PAT, fine-grained PAT and OAuth token — which is what `HF_GITHUB_TOKEN` is — is refused:
12
+ * 403 for a `gh` OAuth token, and 401/404 for the other ways a token can be told no.
13
+ */
14
+ const CANNOT_LIST = new Set([401, 403, 404]);
7
15
  /**
8
16
  * The token reaches `git` through the child's environment alone.
9
17
  *
@@ -85,7 +93,7 @@ export const repoStep = {
85
93
  env: gitAuthEnv(required.HF_GITHUB_TOKEN),
86
94
  });
87
95
  }
88
- await assertAppsInstalled(github, githubAppSlugs(context.config), fullName);
96
+ await checkAppsInstalled(context, github, githubAppSlugs(context.config), fullName);
89
97
  await context.state.patch({ repo: fullName });
90
98
  },
91
99
  };
@@ -95,9 +103,25 @@ export const repoStep = {
95
103
  * Coolify cannot deploy from a repository its GitHub App cannot see, and that failure otherwise
96
104
  * surfaces as a deployment that clones nothing — so it is asserted here, by name, with the URL
97
105
  * that fixes it.
106
+ *
107
+ * Unless the token may not ask at all, which is the usual case: then this degrades to a warning
108
+ * and a checklist line, because the repository has already been created and pushed and there is no
109
+ * second way to read a personal account's installations (organizations have
110
+ * `GET /orgs/{org}/installations`; personal accounts have nothing).
98
111
  */
99
- async function assertAppsInstalled(github, slugs, fullName) {
100
- const installations = await allInstallations(github);
112
+ async function checkAppsInstalled(context, github, slugs, fullName) {
113
+ let installations;
114
+ try {
115
+ installations = await allInstallations(github);
116
+ }
117
+ catch (error) {
118
+ if (!(error instanceof ProviderError) || !CANNOT_LIST.has(error.status))
119
+ throw error;
120
+ const note = unverifiedNote(slugs, fullName, error.status);
121
+ context.io.out(`WARNING: ${context.names.given}: ${note}`);
122
+ context.checklist.push(note);
123
+ return;
124
+ }
101
125
  for (const slug of slugs) {
102
126
  const installation = installations.find((candidate) => candidate.app_slug === slug);
103
127
  if (installation === undefined) {
@@ -110,6 +134,17 @@ async function assertAppsInstalled(github, slugs, fullName) {
110
134
  }
111
135
  }
112
136
  }
137
+ /** The one thing left to the operator when the installations could not be listed. */
138
+ function unverifiedNote(slugs, fullName, status) {
139
+ const apps = slugs
140
+ .map((slug) => `${slug} (https://github.com/apps/${slug}/installations/new)`)
141
+ .join(", ");
142
+ return (`the GitHub App installations on ${fullName} could not be verified with this token — ` +
143
+ `listing them needs a GitHub App user-to-server token and GitHub answered HTTP ` +
144
+ `${String(status)}. Check by hand that each of these is installed on the repository, or on ` +
145
+ `All repositories: ${apps}. Coolify's first deploy clones an empty repository if its app ` +
146
+ `cannot see this one.`);
147
+ }
113
148
  async function allInstallations(github) {
114
149
  const found = [];
115
150
  for (let page = 1;; page += 1) {
package/dist/config.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * overrides it. One flat list of names, no nesting: these are pasted in from account pages, and
4
4
  * a shape is one more thing to get wrong.
5
5
  */
6
- export declare const CONFIG_KEYS: readonly ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_COOLIFY_SERVER_UUID", "HF_COOLIFY_GITHUB_APP_UUID", "HF_COOLIFY_POSTGRES_UUID", "HF_DB_HOST_INTERNAL", "HF_SSH_HOST", "HF_CLOUDFLARE_TOKEN", "HF_CLOUDFLARE_ZONE_ID", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS", "HF_SENTRY_TOKEN", "HF_SENTRY_ORG", "HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY", "HF_BOX_IP", "HF_SMTP_URL", "HF_EMAIL_FROM", "HF_ANTHROPIC_API_KEY", "HF_OPENAI_API_KEY"];
6
+ export declare const CONFIG_KEYS: readonly ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_COOLIFY_SERVER_UUID", "HF_COOLIFY_GITHUB_APP_UUID", "HF_COOLIFY_POSTGRES_UUID", "HF_DB_HOST_INTERNAL", "HF_DB_CONTAINER", "HF_PG_ADMIN_USER", "HF_SSH_HOST", "HF_CLOUDFLARE_TOKEN", "HF_CLOUDFLARE_ZONE_ID", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS", "HF_SENTRY_TOKEN", "HF_SENTRY_ORG", "HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY", "HF_LANGFUSE_PUBLIC_KEY", "HF_LANGFUSE_SECRET_KEY", "HF_BOX_IP", "HF_SMTP_URL", "HF_EMAIL_FROM", "HF_ANTHROPIC_API_KEY", "HF_OPENAI_API_KEY"];
7
7
  export type ConfigKey = (typeof CONFIG_KEYS)[number];
8
8
  /** What the operator has configured. Every key is optional until a command asks for it. */
9
9
  export type OperatorConfig = Partial<Record<ConfigKey, string>>;
@@ -44,6 +44,18 @@ export declare function loadOperatorConfig(options?: LoadOperatorConfigOptions):
44
44
  * got, and an operator who fixes one key per run pays for a partly-provisioned app each time.
45
45
  */
46
46
  export declare function requireOperatorConfig<Key extends ConfigKey>(config: OperatorConfig, keys: readonly Key[], options?: LoadOperatorConfigOptions): Record<Key, string>;
47
+ /** What `HF_PG_ADMIN_USER` defaults to: the role a stock Postgres image creates. */
48
+ export declare const DEFAULT_PG_ADMIN_USER = "postgres";
49
+ /** The cluster superuser to log in as — Coolify's `POSTGRES_USER`, which need not be `postgres`. */
50
+ export declare function pgAdminUser(config: OperatorConfig): string;
51
+ /**
52
+ * What Coolify may have called the Postgres container on the box, in the order to try them.
53
+ *
54
+ * A standalone Postgres resource runs in a container named for the bare uuid; a database attached
55
+ * to a service gets `postgresql-<uuid>`. Both are asked about rather than one being guessed at,
56
+ * and `HF_DB_CONTAINER` replaces the pair outright. Empty when neither key is set.
57
+ */
58
+ export declare function postgresContainers(config: OperatorConfig): readonly string[];
47
59
  /**
48
60
  * `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
49
61
  *
package/dist/config.js CHANGED
@@ -17,6 +17,15 @@ export const CONFIG_KEYS = [
17
17
  // names the container after the database's uuid, so `HF_COOLIFY_POSTGRES_UUID` is the default a
18
18
  // caller falls back to, and this is how a box that disagrees is told to us rather than guessed.
19
19
  "HF_DB_HOST_INTERNAL",
20
+ // The Postgres container's name on the box, which is what `docker inspect` is asked about when
21
+ // the box's loopback has no 5432 listener. Coolify names it for the bare uuid or
22
+ // `postgresql-<uuid>` depending on how the database was created, so `postgresContainers()`
23
+ // derives both from `HF_COOLIFY_POSTGRES_UUID` and this replaces the pair.
24
+ "HF_DB_CONTAINER",
25
+ // The cluster superuser to log in as. Coolify creates the cluster with its own `POSTGRES_USER`,
26
+ // and on the X1 box that role is not `postgres` — `psql -U postgres` there fails with
27
+ // `role "postgres" does not exist`. The password stays in `PGPASSWORD`, never in this file.
28
+ "HF_PG_ADMIN_USER",
20
29
  "HF_SSH_HOST",
21
30
  "HF_CLOUDFLARE_TOKEN",
22
31
  "HF_CLOUDFLARE_ZONE_ID",
@@ -29,7 +38,14 @@ export const CONFIG_KEYS = [
29
38
  "HF_SENTRY_TOKEN",
30
39
  "HF_SENTRY_ORG",
31
40
  "HF_LANGFUSE_URL",
41
+ // Optional, and all three are: an organization-scoped key pair creates the app its own project,
42
+ // but it is a paid-plan feature, so a Hobby account instead names an existing project's key pair
43
+ // here and every app it provisions traces into that one project. With none of them set the
44
+ // langfuse step records nothing and the three LANGFUSE_* variables are omitted rather than sent
45
+ // empty — an empty value in Coolify's UI reads as configured.
32
46
  "HF_LANGFUSE_ORG_KEY",
47
+ "HF_LANGFUSE_PUBLIC_KEY",
48
+ "HF_LANGFUSE_SECRET_KEY",
33
49
  "HF_BOX_IP",
34
50
  "HF_SMTP_URL",
35
51
  "HF_EMAIL_FROM",
@@ -118,6 +134,27 @@ export function requireOperatorConfig(config, keys, options = {}) {
118
134
  }
119
135
  return required;
120
136
  }
137
+ /** What `HF_PG_ADMIN_USER` defaults to: the role a stock Postgres image creates. */
138
+ export const DEFAULT_PG_ADMIN_USER = "postgres";
139
+ /** The cluster superuser to log in as — Coolify's `POSTGRES_USER`, which need not be `postgres`. */
140
+ export function pgAdminUser(config) {
141
+ const user = config.HF_PG_ADMIN_USER;
142
+ return user === undefined || user === "" ? DEFAULT_PG_ADMIN_USER : user;
143
+ }
144
+ /**
145
+ * What Coolify may have called the Postgres container on the box, in the order to try them.
146
+ *
147
+ * A standalone Postgres resource runs in a container named for the bare uuid; a database attached
148
+ * to a service gets `postgresql-<uuid>`. Both are asked about rather than one being guessed at,
149
+ * and `HF_DB_CONTAINER` replaces the pair outright. Empty when neither key is set.
150
+ */
151
+ export function postgresContainers(config) {
152
+ const override = config.HF_DB_CONTAINER;
153
+ if (override !== undefined && override !== "")
154
+ return [override];
155
+ const uuid = config.HF_COOLIFY_POSTGRES_UUID;
156
+ return uuid === undefined || uuid === "" ? [] : [uuid, `postgresql-${uuid}`];
157
+ }
121
158
  /**
122
159
  * `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
123
160
  *
@@ -1,4 +1,4 @@
1
- import type { Runner } from "./runner.js";
1
+ import { type Runner } from "./runner.js";
2
2
  /** Rows as strings, the one shape both transports can produce without inventing types. */
3
3
  export interface QueryResult {
4
4
  rows: string[][];
@@ -13,13 +13,22 @@ export type DatabaseTransport = "tunnel" | "docker-exec";
13
13
  *
14
14
  * `tunnel` is the default, and the only transport that can carry the whole of E2: it hands out
15
15
  * a libpq URL, which is what `provisionRoles()` — a `pg` client, in `@hyperfixation/db` — takes.
16
- * `docker-exec` exists because Phase 0 never confirmed that the Coolify Postgres container
17
- * publishes 5432 on the box's loopback; it runs the same SQL through `psql` inside the
18
- * container, so `CREATE DATABASE`, the extensions and a password rotation all work, but there
19
- * is no address for a client library to dial and `adminUrl` is `undefined`.
16
+ * Its far end is the box's loopback where the port is published and the container's own address
17
+ * on the docker network where it is not. `docker-exec` is the last resort: it runs the same SQL
18
+ * through `psql` inside the container, so `CREATE DATABASE`, the extensions and a password
19
+ * rotation all work, but there is no address for a client library to dial and `adminUrl` is
20
+ * `undefined`.
20
21
  */
21
22
  export interface Database {
22
23
  readonly kind: DatabaseTransport;
24
+ /**
25
+ * Where the **box** reaches this cluster, for anything that runs there rather than here —
26
+ * `pg_restore`, in E7. `undefined` when the transport has no address at all.
27
+ */
28
+ readonly boxAddress?: {
29
+ host: string;
30
+ port: number;
31
+ };
23
32
  /** A libpq URL onto `databaseName`, or `undefined` when the transport has no address. */
24
33
  adminUrl(databaseName?: string): string | undefined;
25
34
  query(sql: string, options?: QueryOptions): Promise<QueryResult>;
@@ -47,18 +56,39 @@ export interface AdminCredentials {
47
56
  }
48
57
  export interface OpenDatabaseOptions {
49
58
  admin: AdminCredentials;
50
- /** Where Postgres listens on the box's loopback. */
59
+ /** The port Postgres listens on, wherever it is reached. */
51
60
  remotePort?: number;
52
- /** The Coolify Postgres container, for the `docker-exec` fallback. Omit to have none. */
61
+ /**
62
+ * What the Coolify Postgres container may be called, in the order to try them; Coolify's own
63
+ * naming depends on how the database was created. The address of the first one that exists is
64
+ * what the tunnel forwards to when the box's loopback has no listener, and `dockerExec` runs
65
+ * `psql` inside it. Omit to have neither, and the loopback is then the only route.
66
+ */
67
+ containers?: readonly string[];
68
+ /**
69
+ * One container, appended to `containers`.
70
+ *
71
+ * @deprecated Coolify's naming depends on how the database was created, so a caller that knows
72
+ * only the uuid has two names to try; pass `containers`. Removed in 0.2.0.
73
+ */
53
74
  container?: string;
75
+ /** Last resort when no address carries a query: `psql` inside the container. Default false. */
76
+ dockerExec?: boolean;
54
77
  }
55
78
  export declare const DEFAULT_POSTGRES_PORT = 5432;
56
79
  /**
57
- * Opens the cluster over `runner`, preferring the tunnel and falling back to `docker exec`.
80
+ * Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
58
81
  *
59
- * The probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward accepts locally
60
- * and only then discovers that nothing is listening on the far side, so a forward to an
61
- * unpublished port looks healthy until the first query.
82
+ * Coolify publishes nothing for its Postgres — `docker inspect` reports `{"5432/tcp": null}`, so
83
+ * the box's `127.0.0.1:5432` is not a listener and forwarding to it can never work. The
84
+ * container's address on the `coolify` network is the route in: the box host routes to it, and
85
+ * `ssh -L localPort:<containerIP>:5432` makes the box the hop. Publishing the port would bind
86
+ * every interface, which is not a trade worth making for a forward that already works.
87
+ *
88
+ * The loopback is still tried first and costs one `ssh` when it fails, because some setups do
89
+ * publish it. Each probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward
90
+ * accepts locally and only then discovers that nothing is listening on the far side, so a forward
91
+ * to an unpublished port looks healthy until the first query.
62
92
  */
63
93
  export declare function openDatabase(runner: Runner, options: OpenDatabaseOptions): Promise<Database>;
64
94
  /** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
package/dist/database.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { isIPv4 } from "node:net";
1
2
  import { Client } from "pg";
3
+ import { shellQuote, TUNNEL_LOOPBACK } from "./runner.js";
2
4
  export class DatabaseTransportError extends Error {
3
5
  transport;
4
6
  constructor(transport, message, options) {
@@ -24,35 +26,116 @@ const FIELD_SEPARATOR = "";
24
26
  export const DEFAULT_POSTGRES_PORT = 5432;
25
27
  const DEFAULT_ADMIN_DATABASE = "postgres";
26
28
  /**
27
- * Opens the cluster over `runner`, preferring the tunnel and falling back to `docker exec`.
29
+ * Docker's network→IP map for one container, as whitespace-separated `network=ip` pairs.
28
30
  *
29
- * The probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward accepts locally
30
- * and only then discovers that nothing is listening on the far side, so a forward to an
31
- * unpublished port looks healthy until the first query.
31
+ * A Go template rather than `--format json` and a parse: the output is one flat line, so nothing
32
+ * about it depends on which docker version the box has.
33
+ */
34
+ const DOCKER_NETWORKS_FORMAT = "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}";
35
+ /** The network Coolify attaches its services to, and the one the box host can route to. */
36
+ const COOLIFY_NETWORK = "coolify";
37
+ /**
38
+ * Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
39
+ *
40
+ * Coolify publishes nothing for its Postgres — `docker inspect` reports `{"5432/tcp": null}`, so
41
+ * the box's `127.0.0.1:5432` is not a listener and forwarding to it can never work. The
42
+ * container's address on the `coolify` network is the route in: the box host routes to it, and
43
+ * `ssh -L localPort:<containerIP>:5432` makes the box the hop. Publishing the port would bind
44
+ * every interface, which is not a trade worth making for a forward that already works.
45
+ *
46
+ * The loopback is still tried first and costs one `ssh` when it fails, because some setups do
47
+ * publish it. Each probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward
48
+ * accepts locally and only then discovers that nothing is listening on the far side, so a forward
49
+ * to an unpublished port looks healthy until the first query.
32
50
  */
33
51
  export async function openDatabase(runner, options) {
34
52
  const remotePort = options.remotePort ?? DEFAULT_POSTGRES_PORT;
53
+ const loopback = await tryTunnel(runner, options.admin, remotePort, TUNNEL_LOOPBACK);
54
+ if ("database" in loopback)
55
+ return loopback.database;
56
+ const candidates = [
57
+ ...(options.containers ?? []),
58
+ ...(options.container === undefined ? [] : [options.container]),
59
+ ];
60
+ if (candidates.length === 0) {
61
+ throw new DatabaseTransportError("tunnel", `could not reach Postgres on ${TUNNEL_LOOPBACK}:${String(remotePort)} on the box, and no ` +
62
+ "container was named to discover an address on the docker network", { cause: loopback.failure });
63
+ }
64
+ const found = await containerAddress(runner, candidates);
65
+ const direct = await tryTunnel(runner, options.admin, remotePort, found.address);
66
+ if ("database" in direct)
67
+ return direct.database;
68
+ if (options.dockerExec !== true) {
69
+ throw new DatabaseTransportError("tunnel", `could not reach Postgres on ${TUNNEL_LOOPBACK}:${String(remotePort)} on the box, nor on ` +
70
+ `${found.address}:${String(remotePort)}, which is where ${found.container} answers on ` +
71
+ "the docker network", { cause: direct.failure });
72
+ }
73
+ return dockerExecDatabase(runner, found.container, options.admin);
74
+ }
75
+ /** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
76
+ export function openDatabaseUrl(adminUrl) {
77
+ return tunnelDatabase(adminUrl, undefined, undefined);
78
+ }
79
+ async function tryTunnel(runner, admin, remotePort, remoteHost) {
35
80
  let tunnel;
36
81
  try {
37
- tunnel = await runner.tunnel(remotePort);
38
- const database = tunnelDatabase(adminUrlOf(options.admin, tunnel.localPort), tunnel);
82
+ tunnel = await runner.tunnel(remotePort, remoteHost);
83
+ const database = tunnelDatabase(adminUrlOf(admin, tunnel.localPort), tunnel, {
84
+ host: remoteHost,
85
+ port: remotePort,
86
+ });
39
87
  await database.query("SELECT 1");
40
- return database;
88
+ return { database };
41
89
  }
42
- catch (cause) {
90
+ catch (failure) {
43
91
  await tunnel?.close();
44
- if (options.container === undefined) {
45
- throw new DatabaseTransportError("tunnel", `could not reach Postgres on 127.0.0.1:${String(remotePort)} on the box, and no ` +
46
- "container was named to fall back to", { cause });
92
+ return { failure };
93
+ }
94
+ }
95
+ /**
96
+ * The first of `containers` that exists, and its own address, asked of the box.
97
+ *
98
+ * The `coolify` network by name, because a Coolify service also sits on a per-service network
99
+ * that only its own stack is on; the first address is the fallback for a box that names its
100
+ * network something else. Every candidate that failed is reported, because which name a database
101
+ * got is a fact about how it was created and the operator is the one who knows it.
102
+ */
103
+ async function containerAddress(runner, containers) {
104
+ const problems = [];
105
+ for (const container of containers) {
106
+ const command = ["docker", "inspect", "-f", DOCKER_NETWORKS_FORMAT, container];
107
+ const result = await runner.exec(command);
108
+ if (result.code !== 0) {
109
+ problems.push(`${container}: ${shellQuote(command)} exited ${String(result.code)}: ` +
110
+ result.stderr.trim());
111
+ continue;
112
+ }
113
+ const address = coolifyAddress(result.stdout);
114
+ if (address === undefined) {
115
+ problems.push(`${container}: no IPv4 address on any docker network, ${shellQuote(command)} printed ` +
116
+ JSON.stringify(result.stdout.trim()));
117
+ continue;
47
118
  }
119
+ return { container, address };
48
120
  }
49
- return dockerExecDatabase(runner, options.container, options.admin);
121
+ throw new DatabaseTransportError("tunnel", `no Postgres container on the box under any name tried (${containers.join(", ")}): ` +
122
+ problems.join("; "));
50
123
  }
51
- /** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
52
- export function openDatabaseUrl(adminUrl) {
53
- return tunnelDatabase(adminUrl, undefined);
124
+ /** The `coolify` network's address in `docker inspect`'s output, else the first one there is. */
125
+ function coolifyAddress(stdout) {
126
+ const networks = stdout
127
+ .split(/\s+/)
128
+ .filter((pair) => pair.includes("="))
129
+ .map((pair) => ({
130
+ network: pair.slice(0, pair.indexOf("=")),
131
+ address: pair.slice(pair.indexOf("=") + 1),
132
+ }))
133
+ // An IPv4 literal and nothing else: this goes into an `ssh -L` field, and a container with no
134
+ // address on a network reports the key with an empty value.
135
+ .filter((entry) => isIPv4(entry.address));
136
+ return (networks.find((entry) => entry.network === COOLIFY_NETWORK) ?? networks[0])?.address;
54
137
  }
55
- function tunnelDatabase(adminUrl, tunnel) {
138
+ function tunnelDatabase(adminUrl, tunnel, boxAddress) {
56
139
  const clients = new Map();
57
140
  const clientFor = async (databaseName) => {
58
141
  const url = withDatabase(adminUrl, databaseName);
@@ -66,6 +149,7 @@ function tunnelDatabase(adminUrl, tunnel) {
66
149
  };
67
150
  return {
68
151
  kind: "tunnel",
152
+ boxAddress,
69
153
  adminUrl: (databaseName) => withDatabase(adminUrl, databaseName),
70
154
  query: async (sql, queryOptions) => {
71
155
  const client = await clientFor(queryOptions?.database);
package/dist/doctor.d.ts CHANGED
@@ -55,13 +55,14 @@ 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. */
56
56
  export declare function doctorLines(result: DoctorResult): string[];
57
57
  /**
58
- * E006 as `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
58
+ * E006 as the cluster admin with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
59
59
  *
60
60
  * As the app role rather than as an admin because that is the only role whose answer matters —
61
61
  * a superuser's privileges are both true whatever the migrator granted.
62
62
  */
63
63
  export declare function tunnelPrivilegeCheck(runner: Runner, options?: {
64
- container?: string;
64
+ containers?: readonly string[];
65
+ adminUser?: string;
65
66
  }): PrivilegeCheck;
66
67
  /**
67
68
  * `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
package/dist/doctor.js CHANGED
@@ -2,7 +2,7 @@ import { access, readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { checkE006, quoteIdent } from "@hyperfixation/db";
4
4
  import { Client } from "pg";
5
- import { loadOperatorConfig, requireOperatorConfig, } from "./config.js";
5
+ import { DEFAULT_PG_ADMIN_USER, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
6
6
  import { openDatabase } from "./database.js";
7
7
  import { deriveNames } from "./names.js";
8
8
  import { GithubClient } from "./providers/github.js";
@@ -12,8 +12,6 @@ import { openAppState, stateDir } from "./state.js";
12
12
  export const RESTORE_CHECK_MAX_AGE_DAYS = 7;
13
13
  /** The branch prefix Phase 4's core bumps open their pull requests on. */
14
14
  export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
15
- /** The cluster role `hf doctor` reads privileges as, before `SET ROLE`. */
16
- const CLUSTER_ADMIN_USER = "postgres";
17
15
  /**
18
16
  * `hf doctor` — what is wrong with the deployed apps, one line per finding.
19
17
  *
@@ -63,21 +61,22 @@ export function doctorLines(result) {
63
61
  return lines;
64
62
  }
65
63
  /**
66
- * E006 as `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
64
+ * E006 as the cluster admin with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
67
65
  *
68
66
  * As the app role rather than as an admin because that is the only role whose answer matters —
69
67
  * a superuser's privileges are both true whatever the migrator granted.
70
68
  */
71
69
  export function tunnelPrivilegeCheck(runner, options = {}) {
70
+ const adminUser = options.adminUser ?? DEFAULT_PG_ADMIN_USER;
72
71
  return async (target) => {
73
72
  const db = await openDatabase(runner, {
74
- admin: { user: CLUSTER_ADMIN_USER },
75
- container: options.container,
73
+ admin: { user: adminUser },
74
+ containers: options.containers,
76
75
  });
77
76
  try {
78
77
  const adminUrl = db.adminUrl(target.databaseName);
79
78
  if (adminUrl === undefined) {
80
- throw new Error(`E006 cannot be read over the ${db.kind} transport: ${CLUSTER_ADMIN_USER} has to be ` +
79
+ throw new Error(`E006 cannot be read over the ${db.kind} transport: ${adminUser} has to be ` +
81
80
  "a session a pg client holds open, so that SET ROLE outlives the statement");
82
81
  }
83
82
  await checkAppRolePrivileges(adminUrl, target.applicationRole);
@@ -105,7 +104,10 @@ export async function checkAppRolePrivileges(adminUrl, role) {
105
104
  }
106
105
  function defaultPrivilegeCheck(config, env) {
107
106
  const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
108
- return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST }));
107
+ return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST }), {
108
+ containers: postgresContainers(config),
109
+ adminUser: pgAdminUser(config),
110
+ });
109
111
  }
110
112
  async function doctorApp(context, name) {
111
113
  const findings = [];
@@ -141,7 +143,7 @@ async function doctorApp(context, name) {
141
143
  }
142
144
  const report = await statusFindings(context, name, state, add);
143
145
  versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
144
- if (report !== undefined) {
146
+ if (report?.budget !== undefined) {
145
147
  budgetFinding(report.budget.current, "current", add);
146
148
  budgetFinding(report.budget.previous, "previous", add);
147
149
  }
@@ -161,23 +163,75 @@ async function statusFindings(context, name, state, add) {
161
163
  }
162
164
  let report;
163
165
  try {
164
- report = await getStatus(context.fetch, url, token);
166
+ report = readStatus(await getStatus(context.fetch, url, token));
165
167
  }
166
168
  catch (error) {
167
169
  add("status", "fail", `GET ${url}: ${flatten(error.message)}`);
168
170
  return undefined;
169
171
  }
170
- add("status", report.health === "ok" ? "ok" : "warn", `health ${report.health}, ${String(report.anomalies)} anomaly/anomalies, core ` +
171
- report.coreVersion);
172
- add("runs", "ok", `${String(report.runs.running)} run(s) running`);
173
- // Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` is an app whose
174
- // worker has not reported yet neither is a finding, but a canned draft an operator takes
175
- // for a real one is.
176
- if (report.llm.mode === "fixtures") {
172
+ const anomalies = report.anomalies === undefined ? UNKNOWN : String(report.anomalies);
173
+ add("status",
174
+ // Only a health the app actually reported can be a warning: a field it did not answer with
175
+ // says nothing about the deployment, and a WARN the operator cannot act on is noise.
176
+ report.health === undefined || report.health === "ok" ? "ok" : "warn", `health ${report.health ?? UNKNOWN}, ${anomalies} anomaly/anomalies, core ` +
177
+ (report.coreVersion ?? UNKNOWN));
178
+ if (report.runsRunning !== undefined) {
179
+ add("runs", "ok", `${String(report.runsRunning)} run(s) running`);
180
+ }
181
+ // Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` — as is a core too
182
+ // old to have the field at all — is an app that has not said; neither is a finding, but a
183
+ // canned draft an operator takes for a real one is.
184
+ if (report.llmMode === "fixtures") {
177
185
  add("llm", "warn", "app is serving fixture drafts — no provider key set");
178
186
  }
179
187
  return report;
180
188
  }
189
+ const UNKNOWN = "unknown";
190
+ function readStatus(payload) {
191
+ const report = record(payload);
192
+ const budget = record(report.budget);
193
+ return {
194
+ health: text(report.health),
195
+ anomalies: numeric(report.anomalies),
196
+ coreVersion: text(report.coreVersion),
197
+ applicationVersion: report.applicationVersion === null ? null : text(report.applicationVersion),
198
+ runsRunning: numeric(record(report.runs).running),
199
+ llmMode: text(record(report.llm).mode),
200
+ budget: isRecord(report.budget)
201
+ ? { current: readPeriod(budget.current), previous: readPeriod(budget.previous) }
202
+ : undefined,
203
+ };
204
+ }
205
+ /** `null` for a period the app has no row for, which is also how a malformed one reads. */
206
+ function readPeriod(value) {
207
+ if (!isRecord(value))
208
+ return null;
209
+ return {
210
+ period: text(value.period),
211
+ budgetUsd: text(value.budgetUsd),
212
+ spentUsd: text(value.spentUsd),
213
+ driftUsd: text(value.driftUsd),
214
+ };
215
+ }
216
+ function isRecord(value) {
217
+ return typeof value === "object" && value !== null;
218
+ }
219
+ function record(value) {
220
+ return isRecord(value) ? value : {};
221
+ }
222
+ function text(value) {
223
+ return typeof value === "string" ? value : undefined;
224
+ }
225
+ function numeric(value) {
226
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
227
+ }
228
+ /** A money column as a number, or `undefined` when the app did not report a usable one. */
229
+ function amount(value) {
230
+ if (value === undefined)
231
+ return undefined;
232
+ const parsed = Number(value);
233
+ return Number.isFinite(parsed) ? parsed : undefined;
234
+ }
181
235
  function versionFinding(deployed, mainSha, mainShaProblem, add) {
182
236
  if (mainShaProblem !== undefined) {
183
237
  add("version", "fail", mainShaProblem);
@@ -198,9 +252,13 @@ function budgetFinding(period, which, add) {
198
252
  add("budget", "ok", `no ${which} period row yet`);
199
253
  return;
200
254
  }
201
- const over = Number(period.spentUsd) > Number(period.budgetUsd);
202
- const drifting = Number(period.driftUsd) !== 0;
203
- const spend = `${period.period} spent $${period.spentUsd} of $${period.budgetUsd}`;
255
+ const spent = amount(period.spentUsd);
256
+ const budget = amount(period.budgetUsd);
257
+ const drift = amount(period.driftUsd);
258
+ const over = spent !== undefined && budget !== undefined && spent > budget;
259
+ const drifting = drift !== undefined && drift !== 0;
260
+ const spend = `${period.period ?? UNKNOWN} spent $${period.spentUsd ?? UNKNOWN} ` +
261
+ `of $${period.budgetUsd ?? UNKNOWN}`;
204
262
  if (over || drifting) {
205
263
  add("budget", "warn", `${spend}${over ? " — over budget" : ""}${drifting ? ` — drift $${period.driftUsd}` : ""}`);
206
264
  return;
@@ -283,7 +341,7 @@ async function getStatus(fetchImpl, url, token) {
283
341
  });
284
342
  if (!response.ok)
285
343
  throw new Error(`HTTP ${String(response.status)}`);
286
- return (await response.json());
344
+ return await response.json();
287
345
  }
288
346
  async function stateNames(dir) {
289
347
  const entries = await readdir(dir).catch(() => []);
@@ -75,13 +75,22 @@ export declare function invalidateStaleSecretSteps(state: AppStateStore): Promis
75
75
  * Errors propagate untouched: the caller prints them, and the state file is the resume point.
76
76
  */
77
77
  export declare function runSteps<Context extends CloudContext>(steps: readonly Step<Context>[], context: Context): Promise<RunStepsResult>;
78
+ /**
79
+ * The keys a cloud `hf new` runs without: three have a default derived from another key, the two
80
+ * provider keys are what the checklist warns about when they are unset, and the three Langfuse
81
+ * keys are three ways of configuring one step — an org key, a project key pair, or neither, which
82
+ * the step degrades to a warning and a checklist line.
83
+ */
84
+ export declare const OPTIONAL_CLOUD_CONFIG: readonly ConfigKey[];
78
85
  /**
79
86
  * Every operator config key a cloud `hf new` needs, checked before the first step.
80
87
  *
81
88
  * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
82
89
  * and an operator who learns about them one failed step at a time pays for a half-provisioned app
83
- * each time. The optional keys are deliberately absent `HF_DB_HOST_INTERNAL` has a default, and
84
- * the two provider keys are what the checklist warns about when they are unset.
90
+ * each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
91
+ * what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
92
+ * ten steps between them read every key there is, so the required set is the complement of the
93
+ * optional one, and a key added for a step is required the moment it is named.
85
94
  */
86
95
  export declare const REQUIRED_CLOUD_CONFIG: readonly ConfigKey[];
87
96
  export interface NewAppCloudOptions {
package/dist/new-cloud.js CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { checklistLines } from "./checklist.js";
3
3
  import { providerKeys } from "./cloud-steps/coolify.js";
4
4
  import { cloudCommands, CLOUD_STEPS, defaultTemplateFetch, spawnStepExec, } from "./cloud-steps/index.js";
5
- import { loadOperatorConfig, requireOperatorConfig, } from "./config.js";
5
+ import { CONFIG_KEYS, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
6
6
  import { openDatabase } from "./database.js";
7
7
  import { deriveNames } from "./names.js";
8
8
  import { createSshRunner } from "./runner.js";
@@ -106,28 +106,33 @@ export async function runSteps(steps, context) {
106
106
  assertRotationApplied(context);
107
107
  return { ran, skipped, invalidated };
108
108
  }
109
+ /**
110
+ * The keys a cloud `hf new` runs without: three have a default derived from another key, the two
111
+ * provider keys are what the checklist warns about when they are unset, and the three Langfuse
112
+ * keys are three ways of configuring one step — an org key, a project key pair, or neither, which
113
+ * the step degrades to a warning and a checklist line.
114
+ */
115
+ export const OPTIONAL_CLOUD_CONFIG = [
116
+ "HF_DB_HOST_INTERNAL",
117
+ "HF_DB_CONTAINER",
118
+ "HF_PG_ADMIN_USER",
119
+ "HF_ANTHROPIC_API_KEY",
120
+ "HF_OPENAI_API_KEY",
121
+ "HF_LANGFUSE_ORG_KEY",
122
+ "HF_LANGFUSE_PUBLIC_KEY",
123
+ "HF_LANGFUSE_SECRET_KEY",
124
+ ];
109
125
  /**
110
126
  * Every operator config key a cloud `hf new` needs, checked before the first step.
111
127
  *
112
128
  * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
113
129
  * and an operator who learns about them one failed step at a time pays for a half-provisioned app
114
- * each time. The optional keys are deliberately absent `HF_DB_HOST_INTERNAL` has a default, and
115
- * the two provider keys are what the checklist warns about when they are unset.
130
+ * each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
131
+ * what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
132
+ * ten steps between them read every key there is, so the required set is the complement of the
133
+ * optional one, and a key added for a step is required the moment it is named.
116
134
  */
117
- export const REQUIRED_CLOUD_CONFIG = [
118
- "HF_COOLIFY_URL",
119
- "HF_COOLIFY_TOKEN",
120
- "HF_COOLIFY_SERVER_UUID",
121
- "HF_COOLIFY_GITHUB_APP_UUID",
122
- "HF_COOLIFY_POSTGRES_UUID",
123
- "HF_SSH_HOST",
124
- "HF_BASE_DOMAIN",
125
- "HF_SMTP_URL",
126
- "HF_EMAIL_FROM",
127
- "HF_LANGFUSE_URL",
128
- ];
129
- /** The cluster role `hf new` provisions the app's database and roles as. */
130
- const CLUSTER_ADMIN_USER = "postgres";
135
+ export const REQUIRED_CLOUD_CONFIG = CONFIG_KEYS.filter((key) => !OPTIONAL_CLOUD_CONFIG.includes(key));
131
136
  /**
132
137
  * `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
133
138
  *
@@ -144,7 +149,7 @@ export async function newAppCloud(options) {
144
149
  const runner = options.runner ?? createSshRunner({ host: required.HF_SSH_HOST });
145
150
  // `PGPASSWORD` is libpq's own name for it, and the same place `hf restore-check` reads it:
146
151
  // Coolify's cluster password is not an hf config key, because nothing of ours should hold it.
147
- const clusterAdmin = options.clusterAdmin ?? { user: CLUSTER_ADMIN_USER, password: env.PGPASSWORD };
152
+ const clusterAdmin = options.clusterAdmin ?? { user: pgAdminUser(config), password: env.PGPASSWORD };
148
153
  let database;
149
154
  const hadWriteToken = state.state.statusTokens?.write !== undefined;
150
155
  const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
@@ -164,9 +169,13 @@ export async function newAppCloud(options) {
164
169
  email: options.email,
165
170
  budgetUsd: options.budgetUsd,
166
171
  database: async () => {
167
- // No `container`: the `docker exec psql` transport has no address, and every use of the
168
- // cluster here `provisionRoles`, the migrator, the tokensis a pg client.
169
- database ??= await openDatabase(runner, { admin: clusterAdmin });
172
+ // The container is named so the tunnel can discover its address, but no `dockerExec`: that
173
+ // transport has no address, and every use of the cluster here `provisionRoles`, the
174
+ // migrator, the tokens is a pg client.
175
+ database ??= await openDatabase(runner, {
176
+ admin: clusterAdmin,
177
+ containers: postgresContainers(config),
178
+ });
170
179
  return database;
171
180
  },
172
181
  commands: options.commands ?? cloudCommands,
@@ -44,8 +44,8 @@ export async function provisionDatabase(target, options) {
44
44
  const adminUrl = db.adminUrl();
45
45
  if (adminUrl === undefined) {
46
46
  throw new ProvisionDatabaseError(`roles cannot be provisioned over the ${db.kind} transport: provisionRoles() is a pg ` +
47
- "client and needs an address. Publish the Coolify Postgres port on the box's loopback " +
48
- "so the tunnel works.");
47
+ "client and needs an address. Name the Postgres container HF_DB_CONTAINER, or " +
48
+ "HF_COOLIFY_POSTGRES_UUID — so the tunnel can discover one.");
49
49
  }
50
50
  try {
51
51
  const createdDatabase = await createDatabaseIfAbsent(db, names.databaseName);
@@ -1,6 +1,6 @@
1
1
  import { quoteIdent } from "@hyperfixation/db";
2
2
  import { createLocalDirectoryBackupSource, createS3BackupSource, } from "./backup-source.js";
3
- import { loadOperatorConfig, requireOperatorConfig } from "./config.js";
3
+ import { loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
4
4
  import { openDatabase, openDatabaseUrl, redactPasswords, DEFAULT_POSTGRES_PORT, } from "./database.js";
5
5
  import { deriveNames } from "./names.js";
6
6
  import { REQUIRED_EXTENSIONS } from "./provision-database.js";
@@ -12,8 +12,6 @@ export const SCRATCH_SUFFIX = "_restore_check";
12
12
  const MAX_IDENTIFIER_BYTES = 63;
13
13
  /** Older than this and the dump gets a warning line; it never changes the exit code. */
14
14
  export const STALE_DUMP_HOURS = 36;
15
- /** The box's Coolify Postgres superuser — the role the whole check runs as. */
16
- const CLUSTER_ADMIN_USER = "postgres";
17
15
  const CLUSTER_ADMIN_DATABASE = "postgres";
18
16
  export class RestoreCheckError extends Error {
19
17
  constructor(message) {
@@ -48,7 +46,8 @@ export async function restoreCheck(options) {
48
46
  const clusterUrl = db.adminUrl();
49
47
  if (clusterUrl === undefined) {
50
48
  throw new RestoreCheckError(`a restore cannot be run over the ${db.kind} transport: pg_restore needs an address. ` +
51
- "Publish the Coolify Postgres port on the box's loopback so the tunnel works.");
49
+ "Name the Postgres container HF_DB_CONTAINER, or HF_COOLIFY_POSTGRES_UUID so the " +
50
+ "tunnel can discover one.");
52
51
  }
53
52
  const restoreTarget = urlOnto(options.restoreAdminUrl ?? clusterUrl, scratchDatabase);
54
53
  const now = options.now ?? new Date();
@@ -219,8 +218,8 @@ export async function restoreCheckApp(options) {
219
218
  const config = await loadOperatorConfig({ env });
220
219
  const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
221
220
  const runner = createSshRunner({ host: HF_SSH_HOST });
222
- const admin = { user: CLUSTER_ADMIN_USER, password: env.PGPASSWORD };
223
- const db = await openDatabase(runner, { admin });
221
+ const admin = { user: pgAdminUser(config), password: env.PGPASSWORD };
222
+ const db = await openDatabase(runner, { admin, containers: postgresContainers(config) });
224
223
  try {
225
224
  return await restoreCheck({
226
225
  app: options.app,
@@ -230,17 +229,23 @@ export async function restoreCheckApp(options) {
230
229
  : createLocalDirectoryBackupSource({ runner, directory: options.backupDir }),
231
230
  runner,
232
231
  database: db,
233
- restoreAdminUrl: boxAdminUrl(admin),
232
+ restoreAdminUrl: boxAdminUrl(admin, db.boxAddress),
234
233
  });
235
234
  }
236
235
  finally {
237
236
  await db.close();
238
237
  }
239
238
  }
240
- /** The cluster as the box itself sees it, where `pg_restore` runs. */
241
- function boxAdminUrl(admin) {
242
- const url = new URL("postgresql://127.0.0.1");
243
- url.port = String(DEFAULT_POSTGRES_PORT);
239
+ /**
240
+ * The cluster as the box itself sees it, where `pg_restore` runs.
241
+ *
242
+ * `address` is whatever the tunnel settled on: with 5432 unpublished the box's loopback is no more
243
+ * a listener for `pg_restore` than for the forward, and the container's address on the docker
244
+ * network is what both have to dial.
245
+ */
246
+ function boxAdminUrl(admin, address) {
247
+ const url = new URL(`postgresql://${address?.host ?? "127.0.0.1"}`);
248
+ url.port = String(address?.port ?? DEFAULT_POSTGRES_PORT);
244
249
  url.username = encodeURIComponent(admin.user);
245
250
  if (admin.password !== undefined)
246
251
  url.password = encodeURIComponent(admin.password);
package/dist/runner.d.ts CHANGED
@@ -22,8 +22,13 @@ export interface Tunnel {
22
22
  */
23
23
  export interface Runner {
24
24
  exec(command: readonly string[], options?: ExecOptions): Promise<ExecResult>;
25
- /** Forwards a local port to `127.0.0.1:<remotePort>` on the far side. */
26
- tunnel(remotePort: number): Promise<Tunnel>;
25
+ /**
26
+ * Forwards a local port to `<remoteHost>:<remotePort>` as the far side sees it.
27
+ *
28
+ * `remoteHost` defaults to the far side's own loopback; it is an address on a network the far
29
+ * side can route to, which is how a container that publishes nothing is still reachable.
30
+ */
31
+ tunnel(remotePort: number, remoteHost?: string): Promise<Tunnel>;
27
32
  }
28
33
  export declare class RunnerError extends Error {
29
34
  constructor(message: string, options?: {
@@ -32,7 +37,9 @@ export declare class RunnerError extends Error {
32
37
  }
33
38
  /** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
34
39
  export declare function sshExecArgv(host: string, command: readonly string[]): string[];
35
- export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number): string[];
40
+ export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number, remoteHost?: string): string[];
41
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
42
+ export declare const TUNNEL_LOOPBACK = "127.0.0.1";
36
43
  /** Single-quotes one word for a POSIX remote shell. */
37
44
  export declare function shellQuote(command: readonly string[]): string;
38
45
  export interface SshRunnerOptions {
package/dist/runner.js CHANGED
@@ -37,17 +37,20 @@ export function sshExecArgv(host, command) {
37
37
  // re-quoted for that shell; nothing else in this file ever builds a shell word.
38
38
  return ["-T", ...SSH_OPTIONS, host, shellQuote(command)];
39
39
  }
40
- export function sshTunnelArgv(host, localPort, remotePort) {
40
+ export function sshTunnelArgv(host, localPort, remotePort, remoteHost = TUNNEL_LOOPBACK) {
41
41
  assertHost(host);
42
+ assertTunnelHost(remoteHost);
42
43
  return [
43
44
  "-N",
44
45
  "-T",
45
46
  ...SSH_OPTIONS,
46
47
  "-L",
47
- `${String(localPort)}:127.0.0.1:${String(remotePort)}`,
48
+ `${String(localPort)}:${remoteHost}:${String(remotePort)}`,
48
49
  host,
49
50
  ];
50
51
  }
52
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
53
+ export const TUNNEL_LOOPBACK = "127.0.0.1";
51
54
  /** Single-quotes one word for a POSIX remote shell. */
52
55
  export function shellQuote(command) {
53
56
  return command.map((word) => `'${word.replaceAll("'", `'\\''`)}'`).join(" ");
@@ -59,9 +62,9 @@ export function createSshRunner(options) {
59
62
  assertHost(options.host);
60
63
  return {
61
64
  exec: async (command, execOptions) => await spawnCollecting(ssh, sshExecArgv(options.host, command), execOptions),
62
- tunnel: async (remotePort) => {
65
+ tunnel: async (remotePort, remoteHost = TUNNEL_LOOPBACK) => {
63
66
  const localPort = await freeLocalPort();
64
- const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort), {
67
+ const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort, remoteHost), {
65
68
  stdio: ["ignore", "ignore", "pipe"],
66
69
  });
67
70
  let stderr = "";
@@ -75,7 +78,7 @@ export function createSshRunner(options) {
75
78
  catch (cause) {
76
79
  child.kill("SIGTERM");
77
80
  await exited;
78
- throw new RunnerError(`ssh -L ${String(localPort)}:127.0.0.1:${String(remotePort)} never became ready` +
81
+ throw new RunnerError(`ssh -L ${String(localPort)}:${remoteHost}:${String(remotePort)} never became ready` +
79
82
  (stderr === "" ? "" : `: ${stderr.trim()}`), { cause });
80
83
  }
81
84
  return {
@@ -131,14 +134,20 @@ async function spawnCollecting(bin, args, options = {}) {
131
134
  child.stderr?.on("data", (chunk) => {
132
135
  stderr += chunk.toString("utf8");
133
136
  });
134
- // A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
135
- // EPIPE; the exit code already says what happened, so it must not surface as an unhandled error.
136
- child.stdin?.on("error", () => { });
137
- child.stdin?.end(options.input ?? "");
138
- const code = await new Promise((resolve, reject) => {
137
+ const closed = new Promise((resolve, reject) => {
138
+ // A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
139
+ // EPIPE; the exit code already says what happened. Any other stdin error is a real failure.
140
+ child.stdin?.on("error", (error) => {
141
+ if (error.code === "EPIPE")
142
+ return;
143
+ child.kill();
144
+ reject(error);
145
+ });
139
146
  child.once("error", reject);
140
147
  child.once("close", (exitCode) => resolve(exitCode));
141
148
  });
149
+ child.stdin?.end(options.input ?? "");
150
+ const code = await closed;
142
151
  return { code, stdout, stderr };
143
152
  }
144
153
  /**
@@ -192,6 +201,19 @@ async function canConnect(port) {
192
201
  socket.once("timeout", () => done(false));
193
202
  });
194
203
  }
204
+ /**
205
+ * The far-side end of a `-L` forward, which is a bare address or hostname and nothing else.
206
+ *
207
+ * It arrives from `docker inspect` on the box rather than from the operator, and `-L` takes its
208
+ * three fields colon-separated, so a value carrying a colon or a space would silently become a
209
+ * different forward than the one asked for.
210
+ */
211
+ function assertTunnelHost(remoteHost) {
212
+ if (!TUNNEL_HOST.test(remoteHost)) {
213
+ throw new RunnerError(`a tunnel's remote host must match ${TUNNEL_HOST.source}, got ${JSON.stringify(remoteHost)}`);
214
+ }
215
+ }
216
+ const TUNNEL_HOST = /^[A-Za-z0-9._-]+$/;
195
217
  function assertHost(host) {
196
218
  if (!SSH_HOST.test(host)) {
197
219
  throw new RunnerError(`HF_SSH_HOST must match ${SSH_HOST.source}, got ${JSON.stringify(host)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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.1",
33
- "@hyperfixation/core": "0.1.1",
34
- "@hyperfixation/db": "0.1.1",
32
+ "@hyperfixation/auth": "0.1.2",
33
+ "@hyperfixation/core": "0.1.2",
34
+ "@hyperfixation/db": "0.1.2",
35
35
  "giget": "3.3.1",
36
36
  "pg": "^8.23.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@hyperfixation/eslint-config": "0.1.1",
40
- "@hyperfixation/testing": "0.1.1",
39
+ "@hyperfixation/eslint-config": "0.1.2",
40
+ "@hyperfixation/testing": "0.1.2",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",