@hyperfixation/cli 0.1.0 → 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/app.d.ts +15 -2
- package/dist/app.js +4 -2
- package/dist/backup-source.d.ts +47 -0
- package/dist/backup-source.js +107 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +1 -1
- package/dist/checklist.d.ts +25 -0
- package/dist/checklist.js +32 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +95 -2
- package/dist/cloud-steps/backup.d.ts +17 -0
- package/dist/cloud-steps/backup.js +40 -0
- package/dist/cloud-steps/context.d.ts +120 -0
- package/dist/cloud-steps/context.js +88 -0
- package/dist/cloud-steps/coolify.d.ts +84 -0
- package/dist/cloud-steps/coolify.js +316 -0
- package/dist/cloud-steps/database.d.ts +12 -0
- package/dist/cloud-steps/database.js +25 -0
- package/dist/cloud-steps/deploy.d.ts +18 -0
- package/dist/cloud-steps/deploy.js +110 -0
- package/dist/cloud-steps/dns.d.ts +11 -0
- package/dist/cloud-steps/dns.js +53 -0
- package/dist/cloud-steps/index.d.ts +21 -0
- package/dist/cloud-steps/index.js +30 -0
- package/dist/cloud-steps/install.d.ts +12 -0
- package/dist/cloud-steps/install.js +53 -0
- package/dist/cloud-steps/langfuse.d.ts +17 -0
- package/dist/cloud-steps/langfuse.js +71 -0
- package/dist/cloud-steps/repo.d.ts +20 -0
- package/dist/cloud-steps/repo.js +198 -0
- package/dist/cloud-steps/sentry.d.ts +13 -0
- package/dist/cloud-steps/sentry.js +55 -0
- package/dist/cloud-steps/template.d.ts +22 -0
- package/dist/cloud-steps/template.js +68 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +192 -0
- package/dist/database.d.ts +95 -0
- package/dist/database.js +226 -0
- package/dist/doctor.d.ts +72 -0
- package/dist/doctor.js +368 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +26 -2
- package/dist/new-cloud.d.ts +135 -0
- package/dist/new-cloud.js +219 -0
- package/dist/new.d.ts +2 -0
- package/dist/new.js +2 -1
- package/dist/providers/cloudflare.d.ts +49 -0
- package/dist/providers/cloudflare.js +27 -0
- package/dist/providers/coolify.d.ts +148 -0
- package/dist/providers/coolify.js +87 -0
- package/dist/providers/github.d.ts +117 -0
- package/dist/providers/github.js +98 -0
- package/dist/providers/http.d.ts +41 -0
- package/dist/providers/http.js +56 -0
- package/dist/providers/langfuse.d.ts +41 -0
- package/dist/providers/langfuse.js +29 -0
- package/dist/providers/sentry.d.ts +31 -0
- package/dist/providers/sentry.js +27 -0
- package/dist/provision-database.d.ts +42 -0
- package/dist/provision-database.js +107 -0
- package/dist/restore-check.d.ts +91 -0
- package/dist/restore-check.js +262 -0
- package/dist/runner.d.ts +72 -0
- package/dist/runner.js +221 -0
- package/dist/secret-file.d.ts +30 -0
- package/dist/secret-file.js +69 -0
- package/dist/state.d.ts +124 -0
- package/dist/state.js +217 -0
- package/dist/status-token.d.ts +2 -0
- package/dist/status-token.js +1 -1
- package/dist/template-source.d.ts +23 -0
- package/dist/template-source.js +23 -0
- package/package.json +10 -7
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { ProviderError } from "../providers/http.js";
|
|
3
|
+
import { SentryClient } from "../providers/sentry.js";
|
|
4
|
+
import { StepFailed } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* The app's Sentry project, and the DSN the deployment reports to.
|
|
7
|
+
*
|
|
8
|
+
* The keys endpoint is both the lookup and the answer: a 200 means the project is there and hands
|
|
9
|
+
* back its DSN in the same request, so a cold run against an existing project neither creates a
|
|
10
|
+
* second one nor needs a list of every project the token can see. Only a 404 creates.
|
|
11
|
+
*
|
|
12
|
+
* The DSN reaches the app through the Coolify env PATCH; it is a credential, so it is recorded in
|
|
13
|
+
* the state and never printed.
|
|
14
|
+
*/
|
|
15
|
+
export const sentryStep = {
|
|
16
|
+
name: "sentry",
|
|
17
|
+
run: async (context) => {
|
|
18
|
+
const { names } = context;
|
|
19
|
+
const required = requireOperatorConfig(context.config, ["HF_SENTRY_TOKEN", "HF_SENTRY_ORG"], {
|
|
20
|
+
env: context.env,
|
|
21
|
+
});
|
|
22
|
+
const org = required.HF_SENTRY_ORG;
|
|
23
|
+
const sentry = new SentryClient({ token: required.HF_SENTRY_TOKEN, fetch: context.fetch });
|
|
24
|
+
let keys = await listKeys(sentry, org, names.appName);
|
|
25
|
+
if (keys === undefined) {
|
|
26
|
+
await sentry.createProject(org, {
|
|
27
|
+
name: names.appName,
|
|
28
|
+
slug: names.appName,
|
|
29
|
+
platform: "node",
|
|
30
|
+
});
|
|
31
|
+
keys = await sentry.listProjectKeys(org, names.appName);
|
|
32
|
+
context.io.out(`${names.given}: created the Sentry project ${org}/${names.appName}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
context.io.out(`${names.given}: adopting the Sentry project ${org}/${names.appName}`);
|
|
36
|
+
}
|
|
37
|
+
const dsn = keys[0]?.dsn.public;
|
|
38
|
+
if (dsn === undefined) {
|
|
39
|
+
throw new StepFailed(`the Sentry project ${org}/${names.appName} has no client key: create one in Sentry and ` +
|
|
40
|
+
"re-run hf new");
|
|
41
|
+
}
|
|
42
|
+
await context.state.patch({ sentryDsn: dsn });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
/** The project's keys, or `undefined` when Sentry says there is no such project. */
|
|
46
|
+
async function listKeys(sentry, org, project) {
|
|
47
|
+
try {
|
|
48
|
+
return await sentry.listProjectKeys(org, project);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof ProviderError && error.status === 404)
|
|
52
|
+
return undefined;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where the fetch lands before it becomes the app.
|
|
5
|
+
*
|
|
6
|
+
* Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
|
|
7
|
+
* copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
|
|
8
|
+
*/
|
|
9
|
+
export declare function templateTempDir(dir: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The app's files: giget's fetch of the template, substituted, renamed into place.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is ever written to the target directory except by that rename, so a crash — mid-fetch,
|
|
14
|
+
* mid-substitution — leaves the target absent and the next run free to start over rather than an
|
|
15
|
+
* app-shaped directory the operator has to judge. The leftover temp directory is what that next
|
|
16
|
+
* run removes first.
|
|
17
|
+
*
|
|
18
|
+
* No `.env` is written, unlike `hf new --local`: in the cloud every value lives in Coolify's
|
|
19
|
+
* environment, and a `.env` in the app directory would only be a second copy of the app's secrets
|
|
20
|
+
* on the laptop that ran `hf new`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const templateStep: Step<CloudStepContext>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { substituteTree, TEMPLATE_MARKER, TemplateError } from "../new.js";
|
|
4
|
+
import { exists } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the fetch lands before it becomes the app.
|
|
7
|
+
*
|
|
8
|
+
* Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
|
|
9
|
+
* copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
|
|
10
|
+
*/
|
|
11
|
+
export function templateTempDir(dir) {
|
|
12
|
+
return path.join(path.dirname(dir), `.${path.basename(dir)}.hf-new`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The app's files: giget's fetch of the template, substituted, renamed into place.
|
|
16
|
+
*
|
|
17
|
+
* Nothing is ever written to the target directory except by that rename, so a crash — mid-fetch,
|
|
18
|
+
* mid-substitution — leaves the target absent and the next run free to start over rather than an
|
|
19
|
+
* app-shaped directory the operator has to judge. The leftover temp directory is what that next
|
|
20
|
+
* run removes first.
|
|
21
|
+
*
|
|
22
|
+
* No `.env` is written, unlike `hf new --local`: in the cloud every value lives in Coolify's
|
|
23
|
+
* environment, and a `.env` in the app directory would only be a second copy of the app's secrets
|
|
24
|
+
* on the laptop that ran `hf new`.
|
|
25
|
+
*/
|
|
26
|
+
export const templateStep = {
|
|
27
|
+
name: "template",
|
|
28
|
+
run: async (context) => {
|
|
29
|
+
const { dir, names } = context;
|
|
30
|
+
if (await exists(dir)) {
|
|
31
|
+
await adoptOrRefuse(context);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const temp = templateTempDir(dir);
|
|
35
|
+
await rm(temp, { recursive: true, force: true });
|
|
36
|
+
const fetched = await context.fetchTemplate(context.from, temp);
|
|
37
|
+
await substituteTree(fetched, names);
|
|
38
|
+
// The marker is what `assertTemplateSource` looks for: an app is never a template twice.
|
|
39
|
+
await rm(path.join(fetched, TEMPLATE_MARKER));
|
|
40
|
+
await rename(fetched, dir);
|
|
41
|
+
context.io.out(`${names.given}: template fetched into ${dir}`);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A directory already at the target: this app on a run whose state was lost, or something else.
|
|
46
|
+
*
|
|
47
|
+
* "This app" means a substituted template — its `package.json` carries the underscored app name
|
|
48
|
+
* and the marker is gone. Anything else is the local flow's rule, refused rather than written
|
|
49
|
+
* into.
|
|
50
|
+
*/
|
|
51
|
+
async function adoptOrRefuse(context) {
|
|
52
|
+
const { dir, names } = context;
|
|
53
|
+
const substituted = !(await exists(path.join(dir, TEMPLATE_MARKER))) && (await packageName(dir)) === names.appName;
|
|
54
|
+
if (!substituted) {
|
|
55
|
+
throw new TemplateError(`${dir} already exists; hf new will not write into it`);
|
|
56
|
+
}
|
|
57
|
+
context.io.out(`${names.given}: adopting the app directory already at ${dir}`);
|
|
58
|
+
}
|
|
59
|
+
async function packageName(dir) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8"));
|
|
62
|
+
const name = parsed.name;
|
|
63
|
+
return typeof name === "string" ? name : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every key the operator's config may carry, spelled exactly as the environment variable that
|
|
3
|
+
* overrides it. One flat list of names, no nesting: these are pasted in from account pages, and
|
|
4
|
+
* a shape is one more thing to get wrong.
|
|
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_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
|
+
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
8
|
+
/** What the operator has configured. Every key is optional until a command asks for it. */
|
|
9
|
+
export type OperatorConfig = Partial<Record<ConfigKey, string>>;
|
|
10
|
+
/** `~/.config/hf`, or `$XDG_CONFIG_HOME/hf`. Holds `config.json` and `state/`. */
|
|
11
|
+
export declare function configHome(env?: NodeJS.ProcessEnv): string;
|
|
12
|
+
export declare function configFile(env?: NodeJS.ProcessEnv): string;
|
|
13
|
+
/** The config file exists but is not a flat JSON object of known keys to strings. */
|
|
14
|
+
export declare class ConfigFileInvalid extends Error {
|
|
15
|
+
readonly file: string;
|
|
16
|
+
constructor(file: string, problem: string);
|
|
17
|
+
}
|
|
18
|
+
/** One command needed keys the operator has not set. Names every one of them, values never. */
|
|
19
|
+
export declare class MissingConfig extends Error {
|
|
20
|
+
readonly names: readonly ConfigKey[];
|
|
21
|
+
constructor(names: readonly ConfigKey[], file: string);
|
|
22
|
+
}
|
|
23
|
+
export interface LoadOperatorConfigOptions {
|
|
24
|
+
/** The config file to read. Defaults to `configFile()`. */
|
|
25
|
+
file?: string;
|
|
26
|
+
/** The environment that overrides it. Defaults to `process.env`. */
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reads `~/.config/hf/config.json` and lays the environment over it.
|
|
31
|
+
*
|
|
32
|
+
* The environment wins, the same precedence an app's `.env` has under `process.env`, so a
|
|
33
|
+
* one-off `HF_COOLIFY_URL=… hf new` does not mean editing a file. A missing file is not an
|
|
34
|
+
* error here — `requireOperatorConfig` is what reports what a command actually needs, all at
|
|
35
|
+
* once, rather than one failed request at a time.
|
|
36
|
+
*
|
|
37
|
+
* The file must be mode 0600; see `readSecretFile`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function loadOperatorConfig(options?: LoadOperatorConfigOptions): Promise<OperatorConfig>;
|
|
40
|
+
/**
|
|
41
|
+
* Narrows a loaded config to the keys a command needs, or names **every** missing one.
|
|
42
|
+
*
|
|
43
|
+
* All at once on purpose: provisioning fails at the first request that needs a key it has not
|
|
44
|
+
* got, and an operator who fixes one key per run pays for a partly-provisioned app each time.
|
|
45
|
+
*/
|
|
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[];
|
|
59
|
+
/**
|
|
60
|
+
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
61
|
+
*
|
|
62
|
+
* A config value is a string — one flat list of names is the whole contract — so the split lives
|
|
63
|
+
* here rather than in the file format, and an unset key is an empty list: nothing to assert.
|
|
64
|
+
*/
|
|
65
|
+
export declare function githubAppSlugs(config: OperatorConfig): readonly string[];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readSecretFile } from "./secret-file.js";
|
|
4
|
+
/**
|
|
5
|
+
* Every key the operator's config may carry, spelled exactly as the environment variable that
|
|
6
|
+
* overrides it. One flat list of names, no nesting: these are pasted in from account pages, and
|
|
7
|
+
* a shape is one more thing to get wrong.
|
|
8
|
+
*/
|
|
9
|
+
export const CONFIG_KEYS = [
|
|
10
|
+
"HF_COOLIFY_URL",
|
|
11
|
+
"HF_COOLIFY_TOKEN",
|
|
12
|
+
"HF_COOLIFY_SERVER_UUID",
|
|
13
|
+
"HF_COOLIFY_GITHUB_APP_UUID",
|
|
14
|
+
"HF_COOLIFY_POSTGRES_UUID",
|
|
15
|
+
// The Postgres container's hostname on the docker network, as the app's containers see it.
|
|
16
|
+
// Configurable because Coolify's API document reports no such field: its own compose generator
|
|
17
|
+
// names the container after the database's uuid, so `HF_COOLIFY_POSTGRES_UUID` is the default a
|
|
18
|
+
// caller falls back to, and this is how a box that disagrees is told to us rather than guessed.
|
|
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",
|
|
29
|
+
"HF_SSH_HOST",
|
|
30
|
+
"HF_CLOUDFLARE_TOKEN",
|
|
31
|
+
"HF_CLOUDFLARE_ZONE_ID",
|
|
32
|
+
"HF_BASE_DOMAIN",
|
|
33
|
+
"HF_GITHUB_TOKEN",
|
|
34
|
+
"HF_GITHUB_OWNER",
|
|
35
|
+
// Comma-separated `app_slug`s — Coolify's GitHub App and the bump bot's — every one of which
|
|
36
|
+
// has to be installed on a new app's repository; `githubAppSlugs` is what splits it.
|
|
37
|
+
"HF_GITHUB_APP_SLUGS",
|
|
38
|
+
"HF_SENTRY_TOKEN",
|
|
39
|
+
"HF_SENTRY_ORG",
|
|
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.
|
|
46
|
+
"HF_LANGFUSE_ORG_KEY",
|
|
47
|
+
"HF_LANGFUSE_PUBLIC_KEY",
|
|
48
|
+
"HF_LANGFUSE_SECRET_KEY",
|
|
49
|
+
"HF_BOX_IP",
|
|
50
|
+
"HF_SMTP_URL",
|
|
51
|
+
"HF_EMAIL_FROM",
|
|
52
|
+
// The two model-provider keys, and the only optional ones here. An app deployed without
|
|
53
|
+
// either serves fixture drafts (`/api/status` reports `llm.mode`), so `hf new` omits the
|
|
54
|
+
// variable altogether rather than sending an empty one and printing a checklist line — an
|
|
55
|
+
// empty value in Coolify's UI reads as configured.
|
|
56
|
+
"HF_ANTHROPIC_API_KEY",
|
|
57
|
+
"HF_OPENAI_API_KEY",
|
|
58
|
+
];
|
|
59
|
+
const KEYS = new Set(CONFIG_KEYS);
|
|
60
|
+
/** `~/.config/hf`, or `$XDG_CONFIG_HOME/hf`. Holds `config.json` and `state/`. */
|
|
61
|
+
export function configHome(env = process.env) {
|
|
62
|
+
const base = env.XDG_CONFIG_HOME;
|
|
63
|
+
return path.join(base !== undefined && base !== "" ? base : path.join(homedir(), ".config"), "hf");
|
|
64
|
+
}
|
|
65
|
+
export function configFile(env = process.env) {
|
|
66
|
+
return path.join(configHome(env), "config.json");
|
|
67
|
+
}
|
|
68
|
+
/** The config file exists but is not a flat JSON object of known keys to strings. */
|
|
69
|
+
export class ConfigFileInvalid extends Error {
|
|
70
|
+
file;
|
|
71
|
+
constructor(file, problem) {
|
|
72
|
+
// The file holds tokens: the problem is described by key name, never by value, and the
|
|
73
|
+
// JSON parser's own message is dropped because it quotes the text it choked on.
|
|
74
|
+
super(`${file} is not usable hf config: ${problem}`);
|
|
75
|
+
this.name = "ConfigFileInvalid";
|
|
76
|
+
this.file = file;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** One command needed keys the operator has not set. Names every one of them, values never. */
|
|
80
|
+
export class MissingConfig extends Error {
|
|
81
|
+
names;
|
|
82
|
+
constructor(names, file) {
|
|
83
|
+
super(`${names.join(", ")} unset: set ${names.length === 1 ? "it" : "them"} in ${file} ` +
|
|
84
|
+
`or in the environment`);
|
|
85
|
+
this.name = "MissingConfig";
|
|
86
|
+
this.names = names;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reads `~/.config/hf/config.json` and lays the environment over it.
|
|
91
|
+
*
|
|
92
|
+
* The environment wins, the same precedence an app's `.env` has under `process.env`, so a
|
|
93
|
+
* one-off `HF_COOLIFY_URL=… hf new` does not mean editing a file. A missing file is not an
|
|
94
|
+
* error here — `requireOperatorConfig` is what reports what a command actually needs, all at
|
|
95
|
+
* once, rather than one failed request at a time.
|
|
96
|
+
*
|
|
97
|
+
* The file must be mode 0600; see `readSecretFile`.
|
|
98
|
+
*/
|
|
99
|
+
export async function loadOperatorConfig(options = {}) {
|
|
100
|
+
const env = options.env ?? process.env;
|
|
101
|
+
const file = options.file ?? configFile(env);
|
|
102
|
+
const config = {};
|
|
103
|
+
const contents = await readSecretFile(file);
|
|
104
|
+
if (contents !== undefined) {
|
|
105
|
+
for (const [key, value] of Object.entries(parseConfig(contents, file))) {
|
|
106
|
+
config[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const key of CONFIG_KEYS) {
|
|
110
|
+
const override = env[key];
|
|
111
|
+
if (override !== undefined && override !== "")
|
|
112
|
+
config[key] = override;
|
|
113
|
+
}
|
|
114
|
+
return config;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Narrows a loaded config to the keys a command needs, or names **every** missing one.
|
|
118
|
+
*
|
|
119
|
+
* All at once on purpose: provisioning fails at the first request that needs a key it has not
|
|
120
|
+
* got, and an operator who fixes one key per run pays for a partly-provisioned app each time.
|
|
121
|
+
*/
|
|
122
|
+
export function requireOperatorConfig(config, keys, options = {}) {
|
|
123
|
+
const missing = [];
|
|
124
|
+
const required = {};
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
const value = config[key];
|
|
127
|
+
if (value === undefined || value === "")
|
|
128
|
+
missing.push(key);
|
|
129
|
+
else
|
|
130
|
+
required[key] = value;
|
|
131
|
+
}
|
|
132
|
+
if (missing.length > 0) {
|
|
133
|
+
throw new MissingConfig(missing, options.file ?? configFile(options.env));
|
|
134
|
+
}
|
|
135
|
+
return required;
|
|
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
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
160
|
+
*
|
|
161
|
+
* A config value is a string — one flat list of names is the whole contract — so the split lives
|
|
162
|
+
* here rather than in the file format, and an unset key is an empty list: nothing to assert.
|
|
163
|
+
*/
|
|
164
|
+
export function githubAppSlugs(config) {
|
|
165
|
+
return (config.HF_GITHUB_APP_SLUGS ?? "")
|
|
166
|
+
.split(",")
|
|
167
|
+
.map((slug) => slug.trim())
|
|
168
|
+
.filter((slug) => slug !== "");
|
|
169
|
+
}
|
|
170
|
+
function parseConfig(contents, file) {
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(contents);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new ConfigFileInvalid(file, "it is not valid JSON");
|
|
177
|
+
}
|
|
178
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
179
|
+
throw new ConfigFileInvalid(file, "the top level is not an object");
|
|
180
|
+
}
|
|
181
|
+
const config = {};
|
|
182
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
183
|
+
if (!KEYS.has(key)) {
|
|
184
|
+
throw new ConfigFileInvalid(file, `${JSON.stringify(key)} is not an hf config key`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof value !== "string") {
|
|
187
|
+
throw new ConfigFileInvalid(file, `${key} is not a string`);
|
|
188
|
+
}
|
|
189
|
+
config[key] = value;
|
|
190
|
+
}
|
|
191
|
+
return config;
|
|
192
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type Runner } from "./runner.js";
|
|
2
|
+
/** Rows as strings, the one shape both transports can produce without inventing types. */
|
|
3
|
+
export interface QueryResult {
|
|
4
|
+
rows: string[][];
|
|
5
|
+
}
|
|
6
|
+
export interface QueryOptions {
|
|
7
|
+
/** Which database on the cluster to run against; defaults to the admin database. */
|
|
8
|
+
database?: string;
|
|
9
|
+
}
|
|
10
|
+
export type DatabaseTransport = "tunnel" | "docker-exec";
|
|
11
|
+
/**
|
|
12
|
+
* One way of reaching the box's Postgres cluster as an admin.
|
|
13
|
+
*
|
|
14
|
+
* `tunnel` is the default, and the only transport that can carry the whole of E2: it hands out
|
|
15
|
+
* a libpq URL, which is what `provisionRoles()` — a `pg` client, in `@hyperfixation/db` — takes.
|
|
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`.
|
|
21
|
+
*/
|
|
22
|
+
export interface Database {
|
|
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
|
+
};
|
|
32
|
+
/** A libpq URL onto `databaseName`, or `undefined` when the transport has no address. */
|
|
33
|
+
adminUrl(databaseName?: string): string | undefined;
|
|
34
|
+
query(sql: string, options?: QueryOptions): Promise<QueryResult>;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export declare class DatabaseTransportError extends Error {
|
|
38
|
+
readonly transport: DatabaseTransport;
|
|
39
|
+
constructor(transport: DatabaseTransport, message: string, options?: {
|
|
40
|
+
cause?: unknown;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Blanks anything that looks like a password before it reaches a message.
|
|
45
|
+
*
|
|
46
|
+
* `psql` echoes the failing statement, and the failing statement is sometimes an `ALTER ROLE
|
|
47
|
+
* … PASSWORD`; a connection string carries one in its authority. Neither may reach a terminal
|
|
48
|
+
* or a scrollback, so every transport error goes through here on the way out.
|
|
49
|
+
*/
|
|
50
|
+
export declare function redactPasswords(text: string): string;
|
|
51
|
+
export interface AdminCredentials {
|
|
52
|
+
user: string;
|
|
53
|
+
password?: string;
|
|
54
|
+
/** The database to connect to for cluster-wide statements. Defaults to `postgres`. */
|
|
55
|
+
database?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface OpenDatabaseOptions {
|
|
58
|
+
admin: AdminCredentials;
|
|
59
|
+
/** The port Postgres listens on, wherever it is reached. */
|
|
60
|
+
remotePort?: number;
|
|
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
|
+
*/
|
|
74
|
+
container?: string;
|
|
75
|
+
/** Last resort when no address carries a query: `psql` inside the container. Default false. */
|
|
76
|
+
dockerExec?: boolean;
|
|
77
|
+
}
|
|
78
|
+
export declare const DEFAULT_POSTGRES_PORT = 5432;
|
|
79
|
+
/**
|
|
80
|
+
* Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
|
|
81
|
+
*
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
export declare function openDatabase(runner: Runner, options: OpenDatabaseOptions): Promise<Database>;
|
|
94
|
+
/** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
|
|
95
|
+
export declare function openDatabaseUrl(adminUrl: string): Database;
|