@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.
Files changed (75) hide show
  1. package/dist/app.d.ts +15 -2
  2. package/dist/app.js +4 -2
  3. package/dist/backup-source.d.ts +47 -0
  4. package/dist/backup-source.js +107 -0
  5. package/dist/bootstrap.d.ts +2 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/checklist.d.ts +25 -0
  8. package/dist/checklist.js +32 -0
  9. package/dist/cli.d.ts +2 -2
  10. package/dist/cli.js +95 -2
  11. package/dist/cloud-steps/backup.d.ts +17 -0
  12. package/dist/cloud-steps/backup.js +40 -0
  13. package/dist/cloud-steps/context.d.ts +120 -0
  14. package/dist/cloud-steps/context.js +88 -0
  15. package/dist/cloud-steps/coolify.d.ts +84 -0
  16. package/dist/cloud-steps/coolify.js +316 -0
  17. package/dist/cloud-steps/database.d.ts +12 -0
  18. package/dist/cloud-steps/database.js +25 -0
  19. package/dist/cloud-steps/deploy.d.ts +18 -0
  20. package/dist/cloud-steps/deploy.js +110 -0
  21. package/dist/cloud-steps/dns.d.ts +11 -0
  22. package/dist/cloud-steps/dns.js +53 -0
  23. package/dist/cloud-steps/index.d.ts +21 -0
  24. package/dist/cloud-steps/index.js +30 -0
  25. package/dist/cloud-steps/install.d.ts +12 -0
  26. package/dist/cloud-steps/install.js +53 -0
  27. package/dist/cloud-steps/langfuse.d.ts +17 -0
  28. package/dist/cloud-steps/langfuse.js +71 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +198 -0
  31. package/dist/cloud-steps/sentry.d.ts +13 -0
  32. package/dist/cloud-steps/sentry.js +55 -0
  33. package/dist/cloud-steps/template.d.ts +22 -0
  34. package/dist/cloud-steps/template.js +68 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +192 -0
  37. package/dist/database.d.ts +95 -0
  38. package/dist/database.js +226 -0
  39. package/dist/doctor.d.ts +72 -0
  40. package/dist/doctor.js +368 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/migrate.d.ts +11 -0
  44. package/dist/migrate.js +26 -2
  45. package/dist/new-cloud.d.ts +135 -0
  46. package/dist/new-cloud.js +219 -0
  47. package/dist/new.d.ts +2 -0
  48. package/dist/new.js +2 -1
  49. package/dist/providers/cloudflare.d.ts +49 -0
  50. package/dist/providers/cloudflare.js +27 -0
  51. package/dist/providers/coolify.d.ts +148 -0
  52. package/dist/providers/coolify.js +87 -0
  53. package/dist/providers/github.d.ts +117 -0
  54. package/dist/providers/github.js +98 -0
  55. package/dist/providers/http.d.ts +41 -0
  56. package/dist/providers/http.js +56 -0
  57. package/dist/providers/langfuse.d.ts +41 -0
  58. package/dist/providers/langfuse.js +29 -0
  59. package/dist/providers/sentry.d.ts +31 -0
  60. package/dist/providers/sentry.js +27 -0
  61. package/dist/provision-database.d.ts +42 -0
  62. package/dist/provision-database.js +107 -0
  63. package/dist/restore-check.d.ts +91 -0
  64. package/dist/restore-check.js +262 -0
  65. package/dist/runner.d.ts +72 -0
  66. package/dist/runner.js +221 -0
  67. package/dist/secret-file.d.ts +30 -0
  68. package/dist/secret-file.js +69 -0
  69. package/dist/state.d.ts +124 -0
  70. package/dist/state.js +217 -0
  71. package/dist/status-token.d.ts +2 -0
  72. package/dist/status-token.js +1 -1
  73. package/dist/template-source.d.ts +23 -0
  74. package/dist/template-source.js +23 -0
  75. package/package.json +10 -7
@@ -0,0 +1,226 @@
1
+ import { isIPv4 } from "node:net";
2
+ import { Client } from "pg";
3
+ import { shellQuote, TUNNEL_LOOPBACK } from "./runner.js";
4
+ export class DatabaseTransportError extends Error {
5
+ transport;
6
+ constructor(transport, message, options) {
7
+ super(`${transport}: ${message}`, options);
8
+ this.name = "DatabaseTransportError";
9
+ this.transport = transport;
10
+ }
11
+ }
12
+ /**
13
+ * Blanks anything that looks like a password before it reaches a message.
14
+ *
15
+ * `psql` echoes the failing statement, and the failing statement is sometimes an `ALTER ROLE
16
+ * … PASSWORD`; a connection string carries one in its authority. Neither may reach a terminal
17
+ * or a scrollback, so every transport error goes through here on the way out.
18
+ */
19
+ export function redactPasswords(text) {
20
+ return text
21
+ .replace(/(PASSWORD\s+)'(?:[^']|'')*'/gi, "$1'***'")
22
+ .replace(/(:\/\/[^:@/\s]+):[^@/\s]+@/g, "$1:***@");
23
+ }
24
+ /** Separates the columns of a `psql -A` row; no SQL value this provisions can contain it. */
25
+ const FIELD_SEPARATOR = "";
26
+ export const DEFAULT_POSTGRES_PORT = 5432;
27
+ const DEFAULT_ADMIN_DATABASE = "postgres";
28
+ /**
29
+ * Docker's network→IP map for one container, as whitespace-separated `network=ip` pairs.
30
+ *
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.
50
+ */
51
+ export async function openDatabase(runner, options) {
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) {
80
+ let tunnel;
81
+ try {
82
+ tunnel = await runner.tunnel(remotePort, remoteHost);
83
+ const database = tunnelDatabase(adminUrlOf(admin, tunnel.localPort), tunnel, {
84
+ host: remoteHost,
85
+ port: remotePort,
86
+ });
87
+ await database.query("SELECT 1");
88
+ return { database };
89
+ }
90
+ catch (failure) {
91
+ await tunnel?.close();
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;
118
+ }
119
+ return { container, address };
120
+ }
121
+ throw new DatabaseTransportError("tunnel", `no Postgres container on the box under any name tried (${containers.join(", ")}): ` +
122
+ problems.join("; "));
123
+ }
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;
137
+ }
138
+ function tunnelDatabase(adminUrl, tunnel, boxAddress) {
139
+ const clients = new Map();
140
+ const clientFor = async (databaseName) => {
141
+ const url = withDatabase(adminUrl, databaseName);
142
+ const existing = clients.get(url);
143
+ if (existing !== undefined)
144
+ return existing;
145
+ const client = new Client({ connectionString: url });
146
+ await client.connect();
147
+ clients.set(url, client);
148
+ return client;
149
+ };
150
+ return {
151
+ kind: "tunnel",
152
+ boxAddress,
153
+ adminUrl: (databaseName) => withDatabase(adminUrl, databaseName),
154
+ query: async (sql, queryOptions) => {
155
+ const client = await clientFor(queryOptions?.database);
156
+ try {
157
+ const result = await client.query({ text: sql, rowMode: "array" });
158
+ const rows = result.rows ?? [];
159
+ return { rows: rows.map((row) => row.map(String)) };
160
+ }
161
+ catch (cause) {
162
+ throw new DatabaseTransportError("tunnel", redactPasswords(cause.message), {
163
+ cause,
164
+ });
165
+ }
166
+ },
167
+ close: async () => {
168
+ for (const client of clients.values())
169
+ await client.end();
170
+ clients.clear();
171
+ await tunnel?.close();
172
+ },
173
+ };
174
+ }
175
+ function dockerExecDatabase(runner, container, admin) {
176
+ return {
177
+ kind: "docker-exec",
178
+ adminUrl: () => undefined,
179
+ query: async (sql, queryOptions) => {
180
+ // `-f -`: the statement goes down stdin, so it never appears in the box's process list
181
+ // and never has to survive a second round of shell quoting.
182
+ const result = await runner.exec([
183
+ "docker",
184
+ "exec",
185
+ "-i",
186
+ container,
187
+ "psql",
188
+ "-v",
189
+ "ON_ERROR_STOP=1",
190
+ "-qtAF",
191
+ FIELD_SEPARATOR,
192
+ "-U",
193
+ admin.user,
194
+ "-d",
195
+ queryOptions?.database ?? admin.database ?? DEFAULT_ADMIN_DATABASE,
196
+ "-f",
197
+ "-",
198
+ ], { input: sql });
199
+ if (result.code !== 0) {
200
+ throw new DatabaseTransportError("docker-exec", `psql exited ${String(result.code)}: ${redactPasswords(result.stderr.trim())}`);
201
+ }
202
+ const rows = result.stdout
203
+ .split("\n")
204
+ .filter((line) => line !== "")
205
+ .map((line) => line.split(FIELD_SEPARATOR));
206
+ return { rows };
207
+ },
208
+ close: async () => undefined,
209
+ };
210
+ }
211
+ function adminUrlOf(admin, localPort) {
212
+ const url = new URL("postgresql://127.0.0.1");
213
+ url.port = String(localPort);
214
+ url.username = encodeURIComponent(admin.user);
215
+ if (admin.password !== undefined)
216
+ url.password = encodeURIComponent(admin.password);
217
+ url.pathname = `/${encodeURIComponent(admin.database ?? DEFAULT_ADMIN_DATABASE)}`;
218
+ return url.toString();
219
+ }
220
+ function withDatabase(connectionString, databaseName) {
221
+ if (databaseName === undefined)
222
+ return connectionString;
223
+ const url = new URL(connectionString);
224
+ url.pathname = `/${encodeURIComponent(databaseName)}`;
225
+ return url.toString();
226
+ }
@@ -0,0 +1,72 @@
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 the cluster admin 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
+ containers?: readonly string[];
65
+ adminUser?: string;
66
+ }): PrivilegeCheck;
67
+ /**
68
+ * `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
69
+ * database — after `SET ROLE`. The check itself is not restated here: a second copy of the
70
+ * privilege query is a second thing to keep in step with the grants the migrator makes.
71
+ */
72
+ export declare function checkAppRolePrivileges(adminUrl: string, role: string): Promise<void>;
package/dist/doctor.js ADDED
@@ -0,0 +1,368 @@
1
+ import { access, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { checkE006, quoteIdent } from "@hyperfixation/db";
4
+ import { Client } from "pg";
5
+ import { DEFAULT_PG_ADMIN_USER, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
6
+ import { openDatabase } from "./database.js";
7
+ import { deriveNames } from "./names.js";
8
+ import { GithubClient } from "./providers/github.js";
9
+ import { createSshRunner } from "./runner.js";
10
+ import { openAppState, stateDir } from "./state.js";
11
+ /** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
12
+ export const RESTORE_CHECK_MAX_AGE_DAYS = 7;
13
+ /** The branch prefix Phase 4's core bumps open their pull requests on. */
14
+ export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
15
+ /**
16
+ * `hf doctor` — what is wrong with the deployed apps, one line per finding.
17
+ *
18
+ * Every check is reported rather than thrown: an app whose status endpoint is unreachable is
19
+ * also an app whose E006 and whose bump PRs the operator still wants to know about, and the
20
+ * whole point of this command is one screen that says whether anything needs attention.
21
+ *
22
+ * Nothing here prints a secret. The read token authorizes the status request and never appears
23
+ * in a finding; a provider's response body is dropped for the same reason (`ProviderError`).
24
+ */
25
+ export async function doctor(options = {}) {
26
+ const env = options.env ?? process.env;
27
+ const config = options.config ?? (await loadOperatorConfig({ env }));
28
+ const required = requireOperatorConfig(config, ["HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
29
+ const dir = options.stateDir ?? stateDir(env);
30
+ const context = {
31
+ dir,
32
+ env,
33
+ baseDomain: required.HF_BASE_DOMAIN,
34
+ github: new GithubClient({ token: required.HF_GITHUB_TOKEN, fetch: options.fetch }),
35
+ fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
36
+ now: options.now ?? (() => new Date()),
37
+ privileges: options.privileges ?? defaultPrivilegeCheck(config, env),
38
+ };
39
+ const names = options.name === undefined ? await stateNames(dir) : [options.name];
40
+ const findings = [];
41
+ for (const name of names)
42
+ findings.push(...(await doctorApp(context, name)));
43
+ return { findings, ok: findings.every((finding) => finding.severity === "ok") };
44
+ }
45
+ const MARKER = { ok: "OK ", warn: "WARN", fail: "FAIL" };
46
+ /** The report as printed: a blank line and a header per app, then its findings. */
47
+ export function doctorLines(result) {
48
+ const lines = [];
49
+ let app;
50
+ for (const finding of result.findings) {
51
+ if (finding.app !== app) {
52
+ if (app !== undefined)
53
+ lines.push("");
54
+ lines.push(finding.app);
55
+ app = finding.app;
56
+ }
57
+ lines.push(` ${MARKER[finding.severity]} ${finding.check}: ${finding.message}`);
58
+ }
59
+ if (lines.length === 0)
60
+ lines.push("no apps in the state cache: hf new has provisioned none");
61
+ return lines;
62
+ }
63
+ /**
64
+ * E006 as the cluster admin with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
65
+ *
66
+ * As the app role rather than as an admin because that is the only role whose answer matters —
67
+ * a superuser's privileges are both true whatever the migrator granted.
68
+ */
69
+ export function tunnelPrivilegeCheck(runner, options = {}) {
70
+ const adminUser = options.adminUser ?? DEFAULT_PG_ADMIN_USER;
71
+ return async (target) => {
72
+ const db = await openDatabase(runner, {
73
+ admin: { user: adminUser },
74
+ containers: options.containers,
75
+ });
76
+ try {
77
+ const adminUrl = db.adminUrl(target.databaseName);
78
+ if (adminUrl === undefined) {
79
+ throw new Error(`E006 cannot be read over the ${db.kind} transport: ${adminUser} has to be ` +
80
+ "a session a pg client holds open, so that SET ROLE outlives the statement");
81
+ }
82
+ await checkAppRolePrivileges(adminUrl, target.applicationRole);
83
+ }
84
+ finally {
85
+ await db.close();
86
+ }
87
+ };
88
+ }
89
+ /**
90
+ * `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
91
+ * database — after `SET ROLE`. The check itself is not restated here: a second copy of the
92
+ * privilege query is a second thing to keep in step with the grants the migrator makes.
93
+ */
94
+ export async function checkAppRolePrivileges(adminUrl, role) {
95
+ const client = new Client({ connectionString: adminUrl });
96
+ await client.connect();
97
+ try {
98
+ await client.query(`SET ROLE ${quoteIdent(role)}`);
99
+ await checkE006(client);
100
+ }
101
+ finally {
102
+ await client.end();
103
+ }
104
+ }
105
+ function defaultPrivilegeCheck(config, env) {
106
+ const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
107
+ return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST }), {
108
+ containers: postgresContainers(config),
109
+ adminUser: pgAdminUser(config),
110
+ });
111
+ }
112
+ async function doctorApp(context, name) {
113
+ const findings = [];
114
+ const add = (check, severity, message) => {
115
+ findings.push({ app: name, check, severity, message });
116
+ };
117
+ const file = path.join(context.dir, `${name}.json`);
118
+ if (!(await exists(file))) {
119
+ add("state", "fail", `no state file at ${file}: hf new has not provisioned ${name}`);
120
+ return findings;
121
+ }
122
+ let state;
123
+ try {
124
+ state = (await openAppState(name, { dir: context.dir, env: context.env })).state;
125
+ }
126
+ catch (error) {
127
+ add("state", "fail", flatten(error.message));
128
+ return findings;
129
+ }
130
+ const repo = parseRepo(state.repo);
131
+ let mainSha;
132
+ let mainShaProblem;
133
+ if (repo === undefined) {
134
+ mainShaProblem = "no owner/name repo recorded in state; main's sha cannot be read";
135
+ }
136
+ else {
137
+ try {
138
+ mainSha = (await context.github.getReference(repo.owner, repo.repo, "heads/main")).object.sha;
139
+ }
140
+ catch (error) {
141
+ mainShaProblem = `${state.repo ?? "?"}: ${flatten(error.message)}`;
142
+ }
143
+ }
144
+ const report = await statusFindings(context, name, state, add);
145
+ versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
146
+ if (report?.budget !== undefined) {
147
+ budgetFinding(report.budget.current, "current", add);
148
+ budgetFinding(report.budget.previous, "previous", add);
149
+ }
150
+ await privilegeFindings(context, name, add);
151
+ restoreCheckFindings(context, state, add);
152
+ if (repo !== undefined)
153
+ await bumpFindings(context, repo, add);
154
+ return findings;
155
+ }
156
+ /** The health and run lines; `undefined` when the app did not answer, which is its own line. */
157
+ async function statusFindings(context, name, state, add) {
158
+ const url = `https://${name}.${context.baseDomain}/api/status`;
159
+ const token = state.statusTokens?.read;
160
+ if (token === undefined) {
161
+ add("status", "fail", `no read status token in state; hf status-token has not run for ${name}`);
162
+ return undefined;
163
+ }
164
+ let report;
165
+ try {
166
+ report = readStatus(await getStatus(context.fetch, url, token));
167
+ }
168
+ catch (error) {
169
+ add("status", "fail", `GET ${url}: ${flatten(error.message)}`);
170
+ return undefined;
171
+ }
172
+ const anomalies = report.anomalies === undefined ? UNKNOWN : String(report.anomalies);
173
+ add("status",
174
+ // Only a health the app actually reported can be a warning: a field it did not answer with
175
+ // says nothing about the deployment, and a WARN the operator cannot act on is noise.
176
+ report.health === undefined || report.health === "ok" ? "ok" : "warn", `health ${report.health ?? UNKNOWN}, ${anomalies} anomaly/anomalies, core ` +
177
+ (report.coreVersion ?? UNKNOWN));
178
+ if (report.runsRunning !== undefined) {
179
+ add("runs", "ok", `${String(report.runsRunning)} run(s) running`);
180
+ }
181
+ // Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` — as is a core too
182
+ // old to have the field at all — is an app that has not said; neither is a finding, but a
183
+ // canned draft an operator takes for a real one is.
184
+ if (report.llmMode === "fixtures") {
185
+ add("llm", "warn", "app is serving fixture drafts — no provider key set");
186
+ }
187
+ return report;
188
+ }
189
+ const UNKNOWN = "unknown";
190
+ function readStatus(payload) {
191
+ const report = record(payload);
192
+ const budget = record(report.budget);
193
+ return {
194
+ health: text(report.health),
195
+ anomalies: numeric(report.anomalies),
196
+ coreVersion: text(report.coreVersion),
197
+ applicationVersion: report.applicationVersion === null ? null : text(report.applicationVersion),
198
+ runsRunning: numeric(record(report.runs).running),
199
+ llmMode: text(record(report.llm).mode),
200
+ budget: isRecord(report.budget)
201
+ ? { current: readPeriod(budget.current), previous: readPeriod(budget.previous) }
202
+ : undefined,
203
+ };
204
+ }
205
+ /** `null` for a period the app has no row for, which is also how a malformed one reads. */
206
+ function readPeriod(value) {
207
+ if (!isRecord(value))
208
+ return null;
209
+ return {
210
+ period: text(value.period),
211
+ budgetUsd: text(value.budgetUsd),
212
+ spentUsd: text(value.spentUsd),
213
+ driftUsd: text(value.driftUsd),
214
+ };
215
+ }
216
+ function isRecord(value) {
217
+ return typeof value === "object" && value !== null;
218
+ }
219
+ function record(value) {
220
+ return isRecord(value) ? value : {};
221
+ }
222
+ function text(value) {
223
+ return typeof value === "string" ? value : undefined;
224
+ }
225
+ function numeric(value) {
226
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
227
+ }
228
+ /** A money column as a number, or `undefined` when the app did not report a usable one. */
229
+ function amount(value) {
230
+ if (value === undefined)
231
+ return undefined;
232
+ const parsed = Number(value);
233
+ return Number.isFinite(parsed) ? parsed : undefined;
234
+ }
235
+ function versionFinding(deployed, mainSha, mainShaProblem, add) {
236
+ if (mainShaProblem !== undefined) {
237
+ add("version", "fail", mainShaProblem);
238
+ return;
239
+ }
240
+ if (deployed === undefined || mainSha === undefined)
241
+ return;
242
+ if (deployed === null) {
243
+ add("version", "warn", "the app reports no applicationVersion: HF_BUILD_SHA is unset");
244
+ return;
245
+ }
246
+ add("version", deployed === mainSha ? "ok" : "warn", deployed === mainSha
247
+ ? `applicationVersion ${short(mainSha)} is main`
248
+ : `applicationVersion ${short(deployed)} is not main ${short(mainSha)}`);
249
+ }
250
+ function budgetFinding(period, which, add) {
251
+ if (period === null) {
252
+ add("budget", "ok", `no ${which} period row yet`);
253
+ return;
254
+ }
255
+ const spent = amount(period.spentUsd);
256
+ const budget = amount(period.budgetUsd);
257
+ const drift = amount(period.driftUsd);
258
+ const over = spent !== undefined && budget !== undefined && spent > budget;
259
+ const drifting = drift !== undefined && drift !== 0;
260
+ const spend = `${period.period ?? UNKNOWN} spent $${period.spentUsd ?? UNKNOWN} ` +
261
+ `of $${period.budgetUsd ?? UNKNOWN}`;
262
+ if (over || drifting) {
263
+ add("budget", "warn", `${spend}${over ? " — over budget" : ""}${drifting ? ` — drift $${period.driftUsd}` : ""}`);
264
+ return;
265
+ }
266
+ add("budget", "ok", `${spend}, no drift`);
267
+ }
268
+ async function privilegeFindings(context, name, add) {
269
+ let target;
270
+ try {
271
+ const names = deriveNames(name);
272
+ target = {
273
+ app: name,
274
+ databaseName: names.databaseName,
275
+ applicationRole: names.applicationRole,
276
+ };
277
+ }
278
+ catch (error) {
279
+ add("E006", "fail", flatten(error.message));
280
+ return;
281
+ }
282
+ try {
283
+ await context.privileges(target);
284
+ add("E006", "ok", `${target.applicationRole} has USAGE on dbos and INSERT on dbos.workflow_status`);
285
+ }
286
+ catch (error) {
287
+ add("E006", "fail", flatten(error.message));
288
+ }
289
+ }
290
+ function restoreCheckFindings(context, state, add) {
291
+ const last = state.lastRestoreCheckAt;
292
+ if (last === undefined) {
293
+ add("restore-check", "warn", "never run; run hf restore-check");
294
+ return;
295
+ }
296
+ const at = Date.parse(last);
297
+ if (Number.isNaN(at)) {
298
+ add("restore-check", "warn", `lastRestoreCheckAt is not a date: ${last}`);
299
+ return;
300
+ }
301
+ const days = (context.now().getTime() - at) / 86_400_000;
302
+ add("restore-check", days > RESTORE_CHECK_MAX_AGE_DAYS ? "warn" : "ok", `last ran ${days.toFixed(1)} day(s) ago (${last})`);
303
+ }
304
+ async function bumpFindings(context, repo, add) {
305
+ let open;
306
+ try {
307
+ open = await context.github.listPullRequests(repo.owner, repo.repo, {
308
+ state: "open",
309
+ per_page: 100,
310
+ });
311
+ }
312
+ catch (error) {
313
+ add("core-bump", "fail", flatten(error.message));
314
+ return;
315
+ }
316
+ const bumps = open.filter((pull) => pull.head.ref.startsWith(CORE_BUMP_BRANCH_PREFIX));
317
+ if (bumps.length === 0) {
318
+ add("core-bump", "ok", "no open core-bump pull request");
319
+ return;
320
+ }
321
+ for (const pull of bumps) {
322
+ let checks;
323
+ try {
324
+ checks = (await context.github.getCombinedStatus(repo.owner, repo.repo, pull.head.sha)).state;
325
+ }
326
+ catch (error) {
327
+ add("core-bump", "fail", `#${String(pull.number)}: ${flatten(error.message)}`);
328
+ continue;
329
+ }
330
+ add("core-bump", checks === "failure" ? "warn" : "ok", `#${String(pull.number)} ${pull.head.ref}: checks ${checks} — ${pull.html_url}`);
331
+ }
332
+ }
333
+ /**
334
+ * The status request, and nothing of the response body on a refusal: `/api/status` answers 401
335
+ * with a body of its own, and everything it would say about the token belongs nowhere near a
336
+ * terminal.
337
+ */
338
+ async function getStatus(fetchImpl, url, token) {
339
+ const response = await fetchImpl(url, {
340
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
341
+ });
342
+ if (!response.ok)
343
+ throw new Error(`HTTP ${String(response.status)}`);
344
+ return await response.json();
345
+ }
346
+ async function stateNames(dir) {
347
+ const entries = await readdir(dir).catch(() => []);
348
+ return entries
349
+ .filter((entry) => entry.endsWith(".json"))
350
+ .map((entry) => entry.slice(0, -".json".length))
351
+ .sort();
352
+ }
353
+ function parseRepo(repo) {
354
+ const parts = repo?.split("/") ?? [];
355
+ if (parts.length !== 2 || parts[0] === "" || parts[1] === "")
356
+ return undefined;
357
+ return { owner: parts[0], repo: parts[1] };
358
+ }
359
+ async function exists(file) {
360
+ return await access(file).then(() => true, () => false);
361
+ }
362
+ /** One finding is one line, and `BootCheckFailure` spells its details across several. */
363
+ function flatten(message) {
364
+ return message.replace(/\s*\n\s*-?\s*/g, "; ").trim();
365
+ }
366
+ function short(sha) {
367
+ return sha.length > 7 ? sha.slice(0, 7) : sha;
368
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { main, COMMANDS, USAGE, type Command, type Io } from "./cli.js";
2
2
  export { newApp, placeholders, substitute, TemplateError, TEMPLATE_MARKER, EXCLUDED_ENTRIES, type NewAppOptions, type NewAppResult, } from "./new.js";
3
3
  export { findTemplateSource, requireTemplateSource, TEMPLATE_DIR_ENV, } from "./template-source.js";
4
4
  export { deriveNames, APP_ID, GIVEN_NAME, InvalidAppName, type AppNames } from "./names.js";
5
- export { resolveApp, NotAnApp, type ResolvedApp } from "./app.js";
5
+ export { resolveApp, NotAnApp, type ResolveAppOptions, type ResolvedApp } from "./app.js";
6
6
  export { declaredNames, parseEnvFile, readEnvFile } from "./env-file.js";
7
7
  export { MissingEnv, requireEnv } from "./require-env.js";
8
8
  export { credentialsOf, provisionLocalRoles, type LocalRoleOptions, type LocalRoleResult, } from "./roles.js";
@@ -14,3 +14,8 @@ export { probeApp, type AppRegistry } from "./probe.js";
14
14
  export { generate, GENERATOR_BIN, GENERATOR_CONFIG, NoGenerators, type GenerateOptions, } from "./gen.js";
15
15
  export { dev, devBuildSha, DEV_COMPOSE_FILE, type DevOptions, type DevResult } from "./dev.js";
16
16
  export { run, CommandFailed, type RunOptions } from "./spawn.js";
17
+ export { createLocalRunner, createSshRunner, shellQuote, sshExecArgv, sshTunnelArgv, RunnerError, DEFAULT_TUNNEL_READY_TIMEOUT_MS, type ExecOptions, type ExecResult, type LocalRunner, type LocalRunnerOptions, type Runner, type SshRunnerOptions, type Tunnel, } from "./runner.js";
18
+ export { openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, type AdminCredentials, type Database, type DatabaseTransport, type OpenDatabaseOptions, type QueryOptions, type QueryResult, } from "./database.js";
19
+ export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, type BackupDump, type BackupSource, type BackupSourceKind, type LocalDirectoryBackupSourceOptions, } from "./backup-source.js";
20
+ export { formatRestoreCheck, pgRestoreArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, type RestoreCheckAppOptions, type RestoreCheckOptions, type RestoreCheckResult, type RestoreCheckRow, type RestoreVerdict, } from "./restore-check.js";
21
+ export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, type ProvisionDatabaseOptions, type ProvisionDatabaseResult, } from "./provision-database.js";