@hyperfixation/cli 0.1.0 → 0.1.1
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 +74 -0
- package/dist/cloud-steps/coolify.js +300 -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 +12 -0
- package/dist/cloud-steps/langfuse.js +35 -0
- package/dist/cloud-steps/repo.d.ts +20 -0
- package/dist/cloud-steps/repo.js +163 -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 +53 -0
- package/dist/config.js +155 -0
- package/dist/database.d.ts +65 -0
- package/dist/database.js +142 -0
- package/dist/doctor.d.ts +71 -0
- package/dist/doctor.js +310 -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 +126 -0
- package/dist/new-cloud.js +210 -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 +257 -0
- package/dist/runner.d.ts +65 -0
- package/dist/runner.js +199 -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,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,53 @@
|
|
|
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_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"];
|
|
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
|
+
/**
|
|
48
|
+
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
49
|
+
*
|
|
50
|
+
* A config value is a string — one flat list of names is the whole contract — so the split lives
|
|
51
|
+
* here rather than in the file format, and an unset key is an empty list: nothing to assert.
|
|
52
|
+
*/
|
|
53
|
+
export declare function githubAppSlugs(config: OperatorConfig): readonly string[];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
"HF_SSH_HOST",
|
|
21
|
+
"HF_CLOUDFLARE_TOKEN",
|
|
22
|
+
"HF_CLOUDFLARE_ZONE_ID",
|
|
23
|
+
"HF_BASE_DOMAIN",
|
|
24
|
+
"HF_GITHUB_TOKEN",
|
|
25
|
+
"HF_GITHUB_OWNER",
|
|
26
|
+
// Comma-separated `app_slug`s — Coolify's GitHub App and the bump bot's — every one of which
|
|
27
|
+
// has to be installed on a new app's repository; `githubAppSlugs` is what splits it.
|
|
28
|
+
"HF_GITHUB_APP_SLUGS",
|
|
29
|
+
"HF_SENTRY_TOKEN",
|
|
30
|
+
"HF_SENTRY_ORG",
|
|
31
|
+
"HF_LANGFUSE_URL",
|
|
32
|
+
"HF_LANGFUSE_ORG_KEY",
|
|
33
|
+
"HF_BOX_IP",
|
|
34
|
+
"HF_SMTP_URL",
|
|
35
|
+
"HF_EMAIL_FROM",
|
|
36
|
+
// The two model-provider keys, and the only optional ones here. An app deployed without
|
|
37
|
+
// either serves fixture drafts (`/api/status` reports `llm.mode`), so `hf new` omits the
|
|
38
|
+
// variable altogether rather than sending an empty one and printing a checklist line — an
|
|
39
|
+
// empty value in Coolify's UI reads as configured.
|
|
40
|
+
"HF_ANTHROPIC_API_KEY",
|
|
41
|
+
"HF_OPENAI_API_KEY",
|
|
42
|
+
];
|
|
43
|
+
const KEYS = new Set(CONFIG_KEYS);
|
|
44
|
+
/** `~/.config/hf`, or `$XDG_CONFIG_HOME/hf`. Holds `config.json` and `state/`. */
|
|
45
|
+
export function configHome(env = process.env) {
|
|
46
|
+
const base = env.XDG_CONFIG_HOME;
|
|
47
|
+
return path.join(base !== undefined && base !== "" ? base : path.join(homedir(), ".config"), "hf");
|
|
48
|
+
}
|
|
49
|
+
export function configFile(env = process.env) {
|
|
50
|
+
return path.join(configHome(env), "config.json");
|
|
51
|
+
}
|
|
52
|
+
/** The config file exists but is not a flat JSON object of known keys to strings. */
|
|
53
|
+
export class ConfigFileInvalid extends Error {
|
|
54
|
+
file;
|
|
55
|
+
constructor(file, problem) {
|
|
56
|
+
// The file holds tokens: the problem is described by key name, never by value, and the
|
|
57
|
+
// JSON parser's own message is dropped because it quotes the text it choked on.
|
|
58
|
+
super(`${file} is not usable hf config: ${problem}`);
|
|
59
|
+
this.name = "ConfigFileInvalid";
|
|
60
|
+
this.file = file;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** One command needed keys the operator has not set. Names every one of them, values never. */
|
|
64
|
+
export class MissingConfig extends Error {
|
|
65
|
+
names;
|
|
66
|
+
constructor(names, file) {
|
|
67
|
+
super(`${names.join(", ")} unset: set ${names.length === 1 ? "it" : "them"} in ${file} ` +
|
|
68
|
+
`or in the environment`);
|
|
69
|
+
this.name = "MissingConfig";
|
|
70
|
+
this.names = names;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads `~/.config/hf/config.json` and lays the environment over it.
|
|
75
|
+
*
|
|
76
|
+
* The environment wins, the same precedence an app's `.env` has under `process.env`, so a
|
|
77
|
+
* one-off `HF_COOLIFY_URL=… hf new` does not mean editing a file. A missing file is not an
|
|
78
|
+
* error here — `requireOperatorConfig` is what reports what a command actually needs, all at
|
|
79
|
+
* once, rather than one failed request at a time.
|
|
80
|
+
*
|
|
81
|
+
* The file must be mode 0600; see `readSecretFile`.
|
|
82
|
+
*/
|
|
83
|
+
export async function loadOperatorConfig(options = {}) {
|
|
84
|
+
const env = options.env ?? process.env;
|
|
85
|
+
const file = options.file ?? configFile(env);
|
|
86
|
+
const config = {};
|
|
87
|
+
const contents = await readSecretFile(file);
|
|
88
|
+
if (contents !== undefined) {
|
|
89
|
+
for (const [key, value] of Object.entries(parseConfig(contents, file))) {
|
|
90
|
+
config[key] = value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const key of CONFIG_KEYS) {
|
|
94
|
+
const override = env[key];
|
|
95
|
+
if (override !== undefined && override !== "")
|
|
96
|
+
config[key] = override;
|
|
97
|
+
}
|
|
98
|
+
return config;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Narrows a loaded config to the keys a command needs, or names **every** missing one.
|
|
102
|
+
*
|
|
103
|
+
* All at once on purpose: provisioning fails at the first request that needs a key it has not
|
|
104
|
+
* got, and an operator who fixes one key per run pays for a partly-provisioned app each time.
|
|
105
|
+
*/
|
|
106
|
+
export function requireOperatorConfig(config, keys, options = {}) {
|
|
107
|
+
const missing = [];
|
|
108
|
+
const required = {};
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
const value = config[key];
|
|
111
|
+
if (value === undefined || value === "")
|
|
112
|
+
missing.push(key);
|
|
113
|
+
else
|
|
114
|
+
required[key] = value;
|
|
115
|
+
}
|
|
116
|
+
if (missing.length > 0) {
|
|
117
|
+
throw new MissingConfig(missing, options.file ?? configFile(options.env));
|
|
118
|
+
}
|
|
119
|
+
return required;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
123
|
+
*
|
|
124
|
+
* A config value is a string — one flat list of names is the whole contract — so the split lives
|
|
125
|
+
* here rather than in the file format, and an unset key is an empty list: nothing to assert.
|
|
126
|
+
*/
|
|
127
|
+
export function githubAppSlugs(config) {
|
|
128
|
+
return (config.HF_GITHUB_APP_SLUGS ?? "")
|
|
129
|
+
.split(",")
|
|
130
|
+
.map((slug) => slug.trim())
|
|
131
|
+
.filter((slug) => slug !== "");
|
|
132
|
+
}
|
|
133
|
+
function parseConfig(contents, file) {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(contents);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new ConfigFileInvalid(file, "it is not valid JSON");
|
|
140
|
+
}
|
|
141
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
142
|
+
throw new ConfigFileInvalid(file, "the top level is not an object");
|
|
143
|
+
}
|
|
144
|
+
const config = {};
|
|
145
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
146
|
+
if (!KEYS.has(key)) {
|
|
147
|
+
throw new ConfigFileInvalid(file, `${JSON.stringify(key)} is not an hf config key`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof value !== "string") {
|
|
150
|
+
throw new ConfigFileInvalid(file, `${key} is not a string`);
|
|
151
|
+
}
|
|
152
|
+
config[key] = value;
|
|
153
|
+
}
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
* `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`.
|
|
20
|
+
*/
|
|
21
|
+
export interface Database {
|
|
22
|
+
readonly kind: DatabaseTransport;
|
|
23
|
+
/** A libpq URL onto `databaseName`, or `undefined` when the transport has no address. */
|
|
24
|
+
adminUrl(databaseName?: string): string | undefined;
|
|
25
|
+
query(sql: string, options?: QueryOptions): Promise<QueryResult>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export declare class DatabaseTransportError extends Error {
|
|
29
|
+
readonly transport: DatabaseTransport;
|
|
30
|
+
constructor(transport: DatabaseTransport, message: string, options?: {
|
|
31
|
+
cause?: unknown;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Blanks anything that looks like a password before it reaches a message.
|
|
36
|
+
*
|
|
37
|
+
* `psql` echoes the failing statement, and the failing statement is sometimes an `ALTER ROLE
|
|
38
|
+
* … PASSWORD`; a connection string carries one in its authority. Neither may reach a terminal
|
|
39
|
+
* or a scrollback, so every transport error goes through here on the way out.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactPasswords(text: string): string;
|
|
42
|
+
export interface AdminCredentials {
|
|
43
|
+
user: string;
|
|
44
|
+
password?: string;
|
|
45
|
+
/** The database to connect to for cluster-wide statements. Defaults to `postgres`. */
|
|
46
|
+
database?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface OpenDatabaseOptions {
|
|
49
|
+
admin: AdminCredentials;
|
|
50
|
+
/** Where Postgres listens on the box's loopback. */
|
|
51
|
+
remotePort?: number;
|
|
52
|
+
/** The Coolify Postgres container, for the `docker-exec` fallback. Omit to have none. */
|
|
53
|
+
container?: string;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_POSTGRES_PORT = 5432;
|
|
56
|
+
/**
|
|
57
|
+
* Opens the cluster over `runner`, preferring the tunnel and falling back to `docker exec`.
|
|
58
|
+
*
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
export declare function openDatabase(runner: Runner, options: OpenDatabaseOptions): Promise<Database>;
|
|
64
|
+
/** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
|
|
65
|
+
export declare function openDatabaseUrl(adminUrl: string): Database;
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Client } from "pg";
|
|
2
|
+
export class DatabaseTransportError extends Error {
|
|
3
|
+
transport;
|
|
4
|
+
constructor(transport, message, options) {
|
|
5
|
+
super(`${transport}: ${message}`, options);
|
|
6
|
+
this.name = "DatabaseTransportError";
|
|
7
|
+
this.transport = transport;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Blanks anything that looks like a password before it reaches a message.
|
|
12
|
+
*
|
|
13
|
+
* `psql` echoes the failing statement, and the failing statement is sometimes an `ALTER ROLE
|
|
14
|
+
* … PASSWORD`; a connection string carries one in its authority. Neither may reach a terminal
|
|
15
|
+
* or a scrollback, so every transport error goes through here on the way out.
|
|
16
|
+
*/
|
|
17
|
+
export function redactPasswords(text) {
|
|
18
|
+
return text
|
|
19
|
+
.replace(/(PASSWORD\s+)'(?:[^']|'')*'/gi, "$1'***'")
|
|
20
|
+
.replace(/(:\/\/[^:@/\s]+):[^@/\s]+@/g, "$1:***@");
|
|
21
|
+
}
|
|
22
|
+
/** Separates the columns of a `psql -A` row; no SQL value this provisions can contain it. */
|
|
23
|
+
const FIELD_SEPARATOR = "";
|
|
24
|
+
export const DEFAULT_POSTGRES_PORT = 5432;
|
|
25
|
+
const DEFAULT_ADMIN_DATABASE = "postgres";
|
|
26
|
+
/**
|
|
27
|
+
* Opens the cluster over `runner`, preferring the tunnel and falling back to `docker exec`.
|
|
28
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
export async function openDatabase(runner, options) {
|
|
34
|
+
const remotePort = options.remotePort ?? DEFAULT_POSTGRES_PORT;
|
|
35
|
+
let tunnel;
|
|
36
|
+
try {
|
|
37
|
+
tunnel = await runner.tunnel(remotePort);
|
|
38
|
+
const database = tunnelDatabase(adminUrlOf(options.admin, tunnel.localPort), tunnel);
|
|
39
|
+
await database.query("SELECT 1");
|
|
40
|
+
return database;
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
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 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return dockerExecDatabase(runner, options.container, options.admin);
|
|
50
|
+
}
|
|
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);
|
|
54
|
+
}
|
|
55
|
+
function tunnelDatabase(adminUrl, tunnel) {
|
|
56
|
+
const clients = new Map();
|
|
57
|
+
const clientFor = async (databaseName) => {
|
|
58
|
+
const url = withDatabase(adminUrl, databaseName);
|
|
59
|
+
const existing = clients.get(url);
|
|
60
|
+
if (existing !== undefined)
|
|
61
|
+
return existing;
|
|
62
|
+
const client = new Client({ connectionString: url });
|
|
63
|
+
await client.connect();
|
|
64
|
+
clients.set(url, client);
|
|
65
|
+
return client;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
kind: "tunnel",
|
|
69
|
+
adminUrl: (databaseName) => withDatabase(adminUrl, databaseName),
|
|
70
|
+
query: async (sql, queryOptions) => {
|
|
71
|
+
const client = await clientFor(queryOptions?.database);
|
|
72
|
+
try {
|
|
73
|
+
const result = await client.query({ text: sql, rowMode: "array" });
|
|
74
|
+
const rows = result.rows ?? [];
|
|
75
|
+
return { rows: rows.map((row) => row.map(String)) };
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
throw new DatabaseTransportError("tunnel", redactPasswords(cause.message), {
|
|
79
|
+
cause,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
close: async () => {
|
|
84
|
+
for (const client of clients.values())
|
|
85
|
+
await client.end();
|
|
86
|
+
clients.clear();
|
|
87
|
+
await tunnel?.close();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function dockerExecDatabase(runner, container, admin) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "docker-exec",
|
|
94
|
+
adminUrl: () => undefined,
|
|
95
|
+
query: async (sql, queryOptions) => {
|
|
96
|
+
// `-f -`: the statement goes down stdin, so it never appears in the box's process list
|
|
97
|
+
// and never has to survive a second round of shell quoting.
|
|
98
|
+
const result = await runner.exec([
|
|
99
|
+
"docker",
|
|
100
|
+
"exec",
|
|
101
|
+
"-i",
|
|
102
|
+
container,
|
|
103
|
+
"psql",
|
|
104
|
+
"-v",
|
|
105
|
+
"ON_ERROR_STOP=1",
|
|
106
|
+
"-qtAF",
|
|
107
|
+
FIELD_SEPARATOR,
|
|
108
|
+
"-U",
|
|
109
|
+
admin.user,
|
|
110
|
+
"-d",
|
|
111
|
+
queryOptions?.database ?? admin.database ?? DEFAULT_ADMIN_DATABASE,
|
|
112
|
+
"-f",
|
|
113
|
+
"-",
|
|
114
|
+
], { input: sql });
|
|
115
|
+
if (result.code !== 0) {
|
|
116
|
+
throw new DatabaseTransportError("docker-exec", `psql exited ${String(result.code)}: ${redactPasswords(result.stderr.trim())}`);
|
|
117
|
+
}
|
|
118
|
+
const rows = result.stdout
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter((line) => line !== "")
|
|
121
|
+
.map((line) => line.split(FIELD_SEPARATOR));
|
|
122
|
+
return { rows };
|
|
123
|
+
},
|
|
124
|
+
close: async () => undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function adminUrlOf(admin, localPort) {
|
|
128
|
+
const url = new URL("postgresql://127.0.0.1");
|
|
129
|
+
url.port = String(localPort);
|
|
130
|
+
url.username = encodeURIComponent(admin.user);
|
|
131
|
+
if (admin.password !== undefined)
|
|
132
|
+
url.password = encodeURIComponent(admin.password);
|
|
133
|
+
url.pathname = `/${encodeURIComponent(admin.database ?? DEFAULT_ADMIN_DATABASE)}`;
|
|
134
|
+
return url.toString();
|
|
135
|
+
}
|
|
136
|
+
function withDatabase(connectionString, databaseName) {
|
|
137
|
+
if (databaseName === undefined)
|
|
138
|
+
return connectionString;
|
|
139
|
+
const url = new URL(connectionString);
|
|
140
|
+
url.pathname = `/${encodeURIComponent(databaseName)}`;
|
|
141
|
+
return url.toString();
|
|
142
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type OperatorConfig } from "./config.js";
|
|
2
|
+
import type { FetchLike } from "./providers/http.js";
|
|
3
|
+
import { type Runner } from "./runner.js";
|
|
4
|
+
/** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
|
|
5
|
+
export declare const RESTORE_CHECK_MAX_AGE_DAYS = 7;
|
|
6
|
+
/** The branch prefix Phase 4's core bumps open their pull requests on. */
|
|
7
|
+
export declare const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
|
|
8
|
+
export type Severity = "ok" | "warn" | "fail";
|
|
9
|
+
export interface DoctorFinding {
|
|
10
|
+
/** The app as the state cache names it. */
|
|
11
|
+
app: string;
|
|
12
|
+
/** `state`, `status`, `runs`, `version`, `budget`, `E006`, `restore-check`, `core-bump`. */
|
|
13
|
+
check: string;
|
|
14
|
+
severity: Severity;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
export interface DoctorResult {
|
|
18
|
+
findings: readonly DoctorFinding[];
|
|
19
|
+
/** No warning and no failure; `hf doctor` exits 0 exactly when this is true. */
|
|
20
|
+
ok: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The names E006 is read against: `SET ROLE <applicationRole>` in `<databaseName>`. */
|
|
23
|
+
export interface PrivilegeTarget {
|
|
24
|
+
app: string;
|
|
25
|
+
databaseName: string;
|
|
26
|
+
applicationRole: string;
|
|
27
|
+
}
|
|
28
|
+
/** E006 for one app: resolves when both privileges are there, throws naming what is not. */
|
|
29
|
+
export type PrivilegeCheck = (target: PrivilegeTarget) => Promise<void>;
|
|
30
|
+
export interface DoctorOptions {
|
|
31
|
+
/** One app; otherwise every app the state cache knows about. */
|
|
32
|
+
name?: string;
|
|
33
|
+
/** Defaults to `loadOperatorConfig()`. */
|
|
34
|
+
config?: OperatorConfig;
|
|
35
|
+
/** Where the per-app state files are. Defaults to `stateDir()`. */
|
|
36
|
+
stateDir?: string;
|
|
37
|
+
fetch?: FetchLike;
|
|
38
|
+
/** The clock the restore-check age is measured against. */
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
/** How E006 is read. Defaults to the tunnel to `HF_SSH_HOST` as `postgres`. */
|
|
41
|
+
privileges?: PrivilegeCheck;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `hf doctor` — what is wrong with the deployed apps, one line per finding.
|
|
46
|
+
*
|
|
47
|
+
* Every check is reported rather than thrown: an app whose status endpoint is unreachable is
|
|
48
|
+
* also an app whose E006 and whose bump PRs the operator still wants to know about, and the
|
|
49
|
+
* whole point of this command is one screen that says whether anything needs attention.
|
|
50
|
+
*
|
|
51
|
+
* Nothing here prints a secret. The read token authorizes the status request and never appears
|
|
52
|
+
* in a finding; a provider's response body is dropped for the same reason (`ProviderError`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function doctor(options?: DoctorOptions): Promise<DoctorResult>;
|
|
55
|
+
/** The report as printed: a blank line and a header per app, then its findings. */
|
|
56
|
+
export declare function doctorLines(result: DoctorResult): string[];
|
|
57
|
+
/**
|
|
58
|
+
* E006 as `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
|
|
59
|
+
*
|
|
60
|
+
* As the app role rather than as an admin because that is the only role whose answer matters —
|
|
61
|
+
* a superuser's privileges are both true whatever the migrator granted.
|
|
62
|
+
*/
|
|
63
|
+
export declare function tunnelPrivilegeCheck(runner: Runner, options?: {
|
|
64
|
+
container?: string;
|
|
65
|
+
}): PrivilegeCheck;
|
|
66
|
+
/**
|
|
67
|
+
* `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
|
|
68
|
+
* database — after `SET ROLE`. The check itself is not restated here: a second copy of the
|
|
69
|
+
* privilege query is a second thing to keep in step with the grants the migrator makes.
|
|
70
|
+
*/
|
|
71
|
+
export declare function checkAppRolePrivileges(adminUrl: string, role: string): Promise<void>;
|