@hyperfixation/cli 0.1.1 → 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.
- package/dist/checklist.js +1 -1
- package/dist/cloud-steps/context.d.ts +2 -1
- package/dist/cloud-steps/context.js +2 -1
- package/dist/cloud-steps/coolify.d.ts +23 -3
- package/dist/cloud-steps/coolify.js +34 -8
- package/dist/cloud-steps/langfuse.d.ts +5 -0
- package/dist/cloud-steps/langfuse.js +36 -0
- package/dist/cloud-steps/repo.js +38 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +37 -0
- package/dist/database.d.ts +41 -11
- package/dist/database.js +100 -16
- package/dist/doctor.d.ts +4 -3
- package/dist/doctor.js +80 -22
- package/dist/new-cloud.d.ts +11 -2
- package/dist/new-cloud.js +30 -21
- package/dist/providers/cloudflare.js +1 -0
- package/dist/providers/coolify.d.ts +15 -1
- package/dist/providers/coolify.js +4 -0
- package/dist/providers/github.js +1 -0
- package/dist/providers/http.d.ts +23 -6
- package/dist/providers/http.js +46 -8
- package/dist/providers/langfuse.js +2 -0
- package/dist/providers/sentry.js +1 -0
- package/dist/provision-database.js +2 -2
- package/dist/restore-check.js +16 -11
- package/dist/runner.d.ts +10 -3
- package/dist/runner.js +32 -10
- package/package.json +6 -6
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
|
|
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}.`);
|
|
@@ -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
|
/**
|
|
@@ -24,6 +34,14 @@ export declare const CONTAINER_PROVIDED_ENV: readonly ["HF_PROCESS", "HF_BUILD_S
|
|
|
24
34
|
* checklist is what says it out loud.
|
|
25
35
|
*/
|
|
26
36
|
export declare const OPTIONAL_PROVIDER_ENV: readonly ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
37
|
+
/**
|
|
38
|
+
* The three `hf new` omits when the `langfuse` step provisioned no keys.
|
|
39
|
+
*
|
|
40
|
+
* All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
|
|
41
|
+
* worker — registers the span processor only when none of them is empty, so a base URL on its own
|
|
42
|
+
* configures nothing and only reads as though it did.
|
|
43
|
+
*/
|
|
44
|
+
export declare const OPTIONAL_LANGFUSE_ENV: readonly ["LANGFUSE_BASE_URL", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"];
|
|
27
45
|
/**
|
|
28
46
|
* The app's environment and what `docker-compose.prod.yml` interpolates have diverged.
|
|
29
47
|
*
|
|
@@ -49,12 +67,14 @@ export declare function neededEnvNames(dir: string): Promise<string[]>;
|
|
|
49
67
|
/**
|
|
50
68
|
* Refuses to PATCH an environment that is not the one the compose file needs.
|
|
51
69
|
*
|
|
52
|
-
* A provider key the operator has not configured
|
|
53
|
-
*
|
|
70
|
+
* A provider key the operator has not configured, and the three Langfuse variables when the step
|
|
71
|
+
* provisioned no keys, are deliberately absent rather than missing: each is excused here and
|
|
72
|
+
* reported in the checklist instead.
|
|
54
73
|
*/
|
|
55
74
|
export declare function assertEnvsMatchCompose(dir: string, sent: readonly string[]): Promise<void>;
|
|
56
75
|
/**
|
|
57
|
-
* The twelve variables the deployed app runs on,
|
|
76
|
+
* The twelve variables the deployed app runs on, less any of the five optional ones — the two
|
|
77
|
+
* provider keys and the three Langfuse variables — that nothing provisioned.
|
|
58
78
|
*
|
|
59
79
|
* Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
|
|
60
80
|
* that no provider hands back, so a run that did not persist it would lock every existing session
|
|
@@ -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
|
/**
|
|
@@ -27,6 +37,18 @@ export const CONTAINER_PROVIDED_ENV = ["HF_PROCESS", "HF_BUILD_SHA"];
|
|
|
27
37
|
* checklist is what says it out loud.
|
|
28
38
|
*/
|
|
29
39
|
export const OPTIONAL_PROVIDER_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
40
|
+
/**
|
|
41
|
+
* The three `hf new` omits when the `langfuse` step provisioned no keys.
|
|
42
|
+
*
|
|
43
|
+
* All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
|
|
44
|
+
* worker — registers the span processor only when none of them is empty, so a base URL on its own
|
|
45
|
+
* configures nothing and only reads as though it did.
|
|
46
|
+
*/
|
|
47
|
+
export const OPTIONAL_LANGFUSE_ENV = [
|
|
48
|
+
"LANGFUSE_BASE_URL",
|
|
49
|
+
"LANGFUSE_PUBLIC_KEY",
|
|
50
|
+
"LANGFUSE_SECRET_KEY",
|
|
51
|
+
];
|
|
30
52
|
/** Which operator key carries each of them, in `REQUIRED_ENV` order. */
|
|
31
53
|
const PROVIDER_ENV_SOURCE = [
|
|
32
54
|
["ANTHROPIC_API_KEY", "HF_ANTHROPIC_API_KEY"],
|
|
@@ -88,20 +110,22 @@ export async function neededEnvNames(dir) {
|
|
|
88
110
|
/**
|
|
89
111
|
* Refuses to PATCH an environment that is not the one the compose file needs.
|
|
90
112
|
*
|
|
91
|
-
* A provider key the operator has not configured
|
|
92
|
-
*
|
|
113
|
+
* A provider key the operator has not configured, and the three Langfuse variables when the step
|
|
114
|
+
* provisioned no keys, are deliberately absent rather than missing: each is excused here and
|
|
115
|
+
* reported in the checklist instead.
|
|
93
116
|
*/
|
|
94
117
|
export async function assertEnvsMatchCompose(dir, sent) {
|
|
95
118
|
const needed = await neededEnvNames(dir);
|
|
96
119
|
const sentNames = new Set(sent);
|
|
97
|
-
const optional = new Set(OPTIONAL_PROVIDER_ENV);
|
|
120
|
+
const optional = new Set([...OPTIONAL_PROVIDER_ENV, ...OPTIONAL_LANGFUSE_ENV]);
|
|
98
121
|
const missing = needed.filter((name) => !sentNames.has(name) && !optional.has(name));
|
|
99
122
|
const extra = sent.filter((name) => !needed.includes(name));
|
|
100
123
|
if (missing.length > 0 || extra.length > 0)
|
|
101
124
|
throw new EnvDrift(missing, extra);
|
|
102
125
|
}
|
|
103
126
|
/**
|
|
104
|
-
* The twelve variables the deployed app runs on,
|
|
127
|
+
* The twelve variables the deployed app runs on, less any of the five optional ones — the two
|
|
128
|
+
* provider keys and the three Langfuse variables — that nothing provisioned.
|
|
105
129
|
*
|
|
106
130
|
* Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
|
|
107
131
|
* that no provider hands back, so a run that did not persist it would lock every existing session
|
|
@@ -136,10 +160,12 @@ export async function buildAppEnvs(context) {
|
|
|
136
160
|
{ key: "SMTP_URL", value: config.HF_SMTP_URL },
|
|
137
161
|
{ key: "EMAIL_FROM", value: config.HF_EMAIL_FROM },
|
|
138
162
|
{ 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
163
|
];
|
|
164
|
+
// Omitted outright when the langfuse step reused nothing and created nothing: an empty trio
|
|
165
|
+
// would deploy the same telemetry — none — while reading as configured in Coolify's UI.
|
|
166
|
+
if (langfuse.publicKey !== undefined && langfuse.secretKey !== undefined) {
|
|
167
|
+
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 });
|
|
168
|
+
}
|
|
143
169
|
for (const [key, value] of providerKeys(context.config))
|
|
144
170
|
envs.push({ key, value });
|
|
145
171
|
return envs;
|
|
@@ -239,7 +265,7 @@ async function findOrCreateApplication(context, coolify, projectUuid, environmen
|
|
|
239
265
|
docker_compose_location: COMPOSE_LOCATION,
|
|
240
266
|
connect_to_docker_network: true,
|
|
241
267
|
name,
|
|
242
|
-
|
|
268
|
+
docker_compose_domains: [{ name: COMPOSE_DOMAIN_SERVICE, domain: `https://${fqdn}` }],
|
|
243
269
|
// The `deploy` step is what deploys, once the environment is set and the database migrated.
|
|
244
270
|
instant_deploy: false,
|
|
245
271
|
});
|
|
@@ -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
|
+
}
|
package/dist/cloud-steps/repo.js
CHANGED
|
@@ -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
|
|
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
|
|
100
|
-
|
|
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
|
*
|
package/dist/database.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* container, so `CREATE DATABASE`, the extensions and a password
|
|
19
|
-
* is no address for a client library to dial and `adminUrl` is
|
|
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
|
-
/**
|
|
59
|
+
/** The port Postgres listens on, wherever it is reached. */
|
|
51
60
|
remotePort?: number;
|
|
52
|
-
/**
|
|
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
|
|
80
|
+
* Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
|
|
58
81
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
29
|
+
* Docker's network→IP map for one container, as whitespace-separated `network=ip` pairs.
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
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(
|
|
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 (
|
|
90
|
+
catch (failure) {
|
|
43
91
|
await tunnel?.close();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
121
|
+
throw new DatabaseTransportError("tunnel", `no Postgres container on the box under any name tried (${containers.join(", ")}): ` +
|
|
122
|
+
problems.join("; "));
|
|
50
123
|
}
|
|
51
|
-
/** The
|
|
52
|
-
|
|
53
|
-
|
|
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);
|