@hyperfixation/cli 0.1.5 → 0.1.7

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.
@@ -0,0 +1,81 @@
1
+ import { deployCommit } from "./cloud-steps/deploy.js";
2
+ import { spawnStepExec, StepFailed } from "./cloud-steps/index.js";
3
+ import { gitAuthEnv } from "./cloud-steps/repo.js";
4
+ import { loadOperatorConfig, requireOperatorConfig } from "./config.js";
5
+ import { deriveNames } from "./names.js";
6
+ import { CoolifyClient } from "./providers/coolify.js";
7
+ import { openAppState } from "./state.js";
8
+ /** What a commit looks like once `git` has resolved it; `/api/status` reports the same form. */
9
+ const FULL_SHA = /^[0-9a-f]{40}$/;
10
+ /**
11
+ * `hf deploy <name>` — point the app at a commit and wait until it says it is serving it.
12
+ *
13
+ * The same path as `hf new`'s tenth step, and the reason there is a command at all: auto-deploy
14
+ * is off, so a merge to main changes nothing on the box until this runs. Everything it needs is
15
+ * in the state cache the provisioning run wrote; nothing here is interactive.
16
+ */
17
+ export async function deployApp(options) {
18
+ const env = options.env ?? process.env;
19
+ const config = options.config ?? (await loadOperatorConfig({ env }));
20
+ const names = deriveNames(options.app);
21
+ const required = requireOperatorConfig(config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
22
+ const store = await openAppState(names.given, { dir: options.stateDir, env });
23
+ const { coolify, statusTokens, repo } = store.state;
24
+ const appUuid = coolify?.appUuid;
25
+ const readToken = statusTokens?.read;
26
+ if (appUuid === undefined || readToken === undefined) {
27
+ const missing = appUuid === undefined ? "Coolify application uuid" : "read status token";
28
+ throw new StepFailed(`${store.file} has no ${missing}: hf new has not finished provisioning ${names.given}`);
29
+ }
30
+ const sha = options.sha === undefined
31
+ ? await mainSha(options, repo, required.HF_GITHUB_TOKEN)
32
+ : options.sha;
33
+ if (!FULL_SHA.test(sha)) {
34
+ throw new StepFailed(`${sha} is not a commit sha: --sha takes the full forty hex characters, because that is ` +
35
+ "what /api/status reports back");
36
+ }
37
+ const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
38
+ await deployCommit({
39
+ name: names.given,
40
+ appUuid,
41
+ fqdn,
42
+ sha,
43
+ readToken,
44
+ coolify: new CoolifyClient({
45
+ url: required.HF_COOLIFY_URL,
46
+ token: required.HF_COOLIFY_TOKEN,
47
+ fetch: options.fetch,
48
+ }),
49
+ io: options.io,
50
+ now: options.now ?? (() => Date.now()),
51
+ sleep: options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms))),
52
+ fetch: options.fetch,
53
+ });
54
+ await store.patch({ lastDeployedSha: sha });
55
+ return { app: names.given, sha, url: `https://${fqdn}` };
56
+ }
57
+ /**
58
+ * `main`'s sha on the app's repository, read with `git ls-remote` and no checkout.
59
+ *
60
+ * The remote rather than a local clone: the operator running this has just merged a pull request,
61
+ * and whatever is in a directory on the laptop is not what the box would build.
62
+ */
63
+ async function mainSha(options, repo, token) {
64
+ if (repo === undefined) {
65
+ throw new StepFailed(`no owner/name repository in ${options.app}'s state cache: nothing can be asked what main ` +
66
+ "is. Pass --sha <sha>.");
67
+ }
68
+ const url = `https://github.com/${repo}.git`;
69
+ const exec = options.exec ?? spawnStepExec;
70
+ const outcome = await exec("git", ["ls-remote", url, "refs/heads/main"], {
71
+ cwd: process.cwd(),
72
+ capture: true,
73
+ env: gitAuthEnv(token),
74
+ });
75
+ const sha = /^([0-9a-f]{40})\s/.exec(outcome.stdout.trim())?.[1];
76
+ if (outcome.code !== 0 || sha === undefined) {
77
+ throw new StepFailed(`git ls-remote ${url} refs/heads/main named no commit: the repository may be gone, the ` +
78
+ "branch unborn, or HF_GITHUB_TOKEN unable to read it. Pass --sha <sha>.");
79
+ }
80
+ return sha;
81
+ }
package/dist/doctor.d.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  import { type OperatorConfig } from "./config.js";
2
+ import { type Database } from "./database.js";
2
3
  import type { FetchLike } from "./providers/http.js";
3
4
  import { type Runner } from "./runner.js";
4
5
  /** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
5
6
  export declare const RESTORE_CHECK_MAX_AGE_DAYS = 7;
7
+ /** The `CONNECTION LIMIT` every application role is created with, in `provisionRoles`. */
8
+ export declare const APPLICATION_ROLE_CONNECTION_LIMIT = 25;
9
+ /** Past this share of `max_connections`, the next app to deploy is the one that cannot connect. */
10
+ export declare const CONNECTIONS_WARN_FRACTION = 0.8;
6
11
  /** The branch prefix Phase 4's core bumps open their pull requests on. */
7
12
  export declare const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
8
13
  export type Severity = "ok" | "warn" | "fail";
9
14
  export interface DoctorFinding {
10
15
  /** The app as the state cache names it. */
11
16
  app: string;
12
- /** `state`, `status`, `runs`, `version`, `budget`, `E006`, `restore-check`, `core-bump`. */
17
+ /**
18
+ * `state`, `status`, `runs`, `version`, `budget`, `E006`, `connections`, `lock`,
19
+ * `restore-check`, `core-bump`.
20
+ */
13
21
  check: string;
14
22
  severity: Severity;
15
23
  message: string;
@@ -39,6 +47,11 @@ export interface DoctorOptions {
39
47
  now?: () => Date;
40
48
  /** How E006 is read. Defaults to the tunnel to `HF_SSH_HOST` as `postgres`. */
41
49
  privileges?: PrivilegeCheck;
50
+ /**
51
+ * Where the connection counts and the worker locks are read: the whole cluster, as the admin
52
+ * E006 already goes in as. Defaults to the same tunnel to `HF_SSH_HOST`.
53
+ */
54
+ database?: () => Promise<Database>;
42
55
  env?: NodeJS.ProcessEnv;
43
56
  }
44
57
  /**
@@ -70,3 +83,11 @@ export declare function tunnelPrivilegeCheck(runner: Runner, options?: {
70
83
  * privilege query is a second thing to keep in step with the grants the migrator makes.
71
84
  */
72
85
  export declare function checkAppRolePrivileges(adminUrl: string, role: string): Promise<void>;
86
+ /**
87
+ * The worker's advisory lock in one app's database: exactly one, under the key the worker takes.
88
+ *
89
+ * `pg_try_advisory_lock(bigint)` splits its key across `classid` and `objid`, so the key is
90
+ * reassembled rather than compared whole — and masked rather than only shifted, because
91
+ * `hashtext` answers `int4` and a negative hash widens to a bigint of sign bits.
92
+ */
93
+ export declare function workerLockSql(appName: string): string;
package/dist/doctor.js CHANGED
@@ -6,10 +6,15 @@ import { DEFAULT_PG_ADMIN_USER, loadOperatorConfig, pgAdminUser, postgresContain
6
6
  import { openDatabase } from "./database.js";
7
7
  import { deriveNames } from "./names.js";
8
8
  import { GithubClient } from "./providers/github.js";
9
+ import { quoteLiteral } from "./roles.js";
9
10
  import { createSshRunner } from "./runner.js";
10
11
  import { openAppState, stateDir } from "./state.js";
11
12
  /** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
12
13
  export const RESTORE_CHECK_MAX_AGE_DAYS = 7;
14
+ /** The `CONNECTION LIMIT` every application role is created with, in `provisionRoles`. */
15
+ export const APPLICATION_ROLE_CONNECTION_LIMIT = 25;
16
+ /** Past this share of `max_connections`, the next app to deploy is the one that cannot connect. */
17
+ export const CONNECTIONS_WARN_FRACTION = 0.8;
13
18
  /** The branch prefix Phase 4's core bumps open their pull requests on. */
14
19
  export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
15
20
  /**
@@ -27,6 +32,7 @@ export async function doctor(options = {}) {
27
32
  const config = options.config ?? (await loadOperatorConfig({ env }));
28
33
  const required = requireOperatorConfig(config, ["HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
29
34
  const dir = options.stateDir ?? stateDir(env);
35
+ const cluster = lazyDatabase(options.database ?? defaultDatabase(config, env));
30
36
  const context = {
31
37
  dir,
32
38
  env,
@@ -35,11 +41,20 @@ export async function doctor(options = {}) {
35
41
  fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
36
42
  now: options.now ?? (() => new Date()),
37
43
  privileges: options.privileges ?? defaultPrivilegeCheck(config, env),
44
+ database: cluster.get,
45
+ // One snapshot for the whole run: the counts are the box's, not any one app's, and an app
46
+ // whose line is read a second later has not moved the cluster.
47
+ backends: once(async () => await readBackends(await cluster.get())),
38
48
  };
39
49
  const names = options.name === undefined ? await stateNames(dir) : [options.name];
40
50
  const findings = [];
41
- for (const name of names)
42
- findings.push(...(await doctorApp(context, name)));
51
+ try {
52
+ for (const name of names)
53
+ findings.push(...(await doctorApp(context, name)));
54
+ }
55
+ finally {
56
+ await cluster.close();
57
+ }
43
58
  return { findings, ok: findings.every((finding) => finding.severity === "ok") };
44
59
  }
45
60
  const MARKER = { ok: "OK ", warn: "WARN", fail: "FAIL" };
@@ -109,6 +124,30 @@ function defaultPrivilegeCheck(config, env) {
109
124
  adminUser: pgAdminUser(config),
110
125
  });
111
126
  }
127
+ function defaultDatabase(config, env) {
128
+ const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
129
+ const runner = createSshRunner({ host: HF_SSH_HOST });
130
+ return async () => await openDatabase(runner, {
131
+ admin: { user: pgAdminUser(config) },
132
+ containers: postgresContainers(config),
133
+ });
134
+ }
135
+ /** One cluster connection for the whole run: opened when a check first needs it, closed once. */
136
+ function lazyDatabase(open) {
137
+ let pending;
138
+ return {
139
+ get: () => (pending ??= open()),
140
+ close: async () => {
141
+ // A run whose every app failed before the first query never opened one, and an open that
142
+ // failed is already a finding.
143
+ await pending?.then(async (db) => await db.close(), () => undefined);
144
+ },
145
+ };
146
+ }
147
+ function once(read) {
148
+ let pending;
149
+ return () => (pending ??= read());
150
+ }
112
151
  async function doctorApp(context, name) {
113
152
  const findings = [];
114
153
  const add = (check, severity, message) => {
@@ -142,12 +181,19 @@ async function doctorApp(context, name) {
142
181
  }
143
182
  }
144
183
  const report = await statusFindings(context, name, state, add);
145
- versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
184
+ versionFinding(name, report?.applicationVersion, mainSha, mainShaProblem, add);
146
185
  if (report?.budget !== undefined) {
147
186
  budgetFinding(report.budget.current, "current", add);
148
187
  budgetFinding(report.budget.previous, "previous", add);
149
188
  }
150
189
  await privilegeFindings(context, name, add);
190
+ // A name `deriveNames` refuses has already failed E006 on that same message; the cluster checks
191
+ // have no names to run under and say nothing more.
192
+ const names = tryNames(name);
193
+ if (names !== undefined) {
194
+ await connectionFindings(context, names.applicationRole, add);
195
+ await lockFindings(context, names, add);
196
+ }
151
197
  restoreCheckFindings(context, state, add);
152
198
  if (repo !== undefined)
153
199
  await bumpFindings(context, repo, add);
@@ -232,7 +278,14 @@ function amount(value) {
232
278
  const parsed = Number(value);
233
279
  return Number.isFinite(parsed) ? parsed : undefined;
234
280
  }
235
- function versionFinding(deployed, mainSha, mainShaProblem, add) {
281
+ /**
282
+ * What the app answers against what its repository's main holds.
283
+ *
284
+ * The mismatch names `hf deploy` because nothing else closes it: Coolify's push auto-deploy is
285
+ * disabled on every application `hf new` creates, so a merged pull request sits unpublished until
286
+ * an operator says so.
287
+ */
288
+ function versionFinding(name, deployed, mainSha, mainShaProblem, add) {
236
289
  if (mainShaProblem !== undefined) {
237
290
  add("version", "fail", mainShaProblem);
238
291
  return;
@@ -245,7 +298,7 @@ function versionFinding(deployed, mainSha, mainShaProblem, add) {
245
298
  }
246
299
  add("version", deployed === mainSha ? "ok" : "warn", deployed === mainSha
247
300
  ? `applicationVersion ${short(mainSha)} is main`
248
- : `applicationVersion ${short(deployed)} is not main ${short(mainSha)}`);
301
+ : `applicationVersion ${short(deployed)} is not main ${short(mainSha)} — run hf deploy ${name}`);
249
302
  }
250
303
  function budgetFinding(period, which, add) {
251
304
  if (period === null) {
@@ -287,6 +340,99 @@ async function privilegeFindings(context, name, add) {
287
340
  add("E006", "fail", flatten(error.message));
288
341
  }
289
342
  }
343
+ /** Every backend belonging to an app role — `hf_<app>` and its `_migrator` and `_ro`. */
344
+ const BACKENDS_SQL = "SELECT usename, count(*) FROM pg_stat_activity WHERE usename LIKE 'hf\\_%' GROUP BY usename";
345
+ async function readBackends(db) {
346
+ const setting = (await db.query("SHOW max_connections")).rows[0]?.[0];
347
+ const byRole = new Map();
348
+ for (const row of (await db.query(BACKENDS_SQL)).rows) {
349
+ if (row[0] !== undefined)
350
+ byRole.set(row[0], Number(row[1]));
351
+ }
352
+ let total = 0;
353
+ for (const count of byRole.values())
354
+ total += count;
355
+ return { max: setting === undefined ? undefined : numeric(Number(setting)), total, byRole };
356
+ }
357
+ /**
358
+ * What the box's connection slots are spent on, and what this app has of them.
359
+ *
360
+ * Box-wide rather than per-app because that is where it runs out: every app on the box draws on
361
+ * one `max_connections`, and the app that then cannot connect is whichever one deploys next.
362
+ */
363
+ async function connectionFindings(context, role, add) {
364
+ let backends;
365
+ try {
366
+ backends = await context.backends();
367
+ }
368
+ catch (error) {
369
+ add("connections", "fail", flatten(error.message));
370
+ return;
371
+ }
372
+ const limit = String(APPLICATION_ROLE_CONNECTION_LIMIT);
373
+ const line = `${role} ${String(backends.byRole.get(role) ?? 0)}/${limit}, box ` +
374
+ `${String(backends.total)}/${backends.max === undefined ? UNKNOWN : String(backends.max)} ` +
375
+ "on hf_ roles";
376
+ const crowded = backends.max !== undefined && backends.total > backends.max * CONNECTIONS_WARN_FRACTION;
377
+ add("connections", crowded ? "warn" : "ok", crowded
378
+ ? `${line} — over ${String(CONNECTIONS_WARN_FRACTION * 100)}% of max_connections`
379
+ : line);
380
+ }
381
+ /**
382
+ * The worker's advisory lock in one app's database: exactly one, under the key the worker takes.
383
+ *
384
+ * `pg_try_advisory_lock(bigint)` splits its key across `classid` and `objid`, so the key is
385
+ * reassembled rather than compared whole — and masked rather than only shifted, because
386
+ * `hashtext` answers `int4` and a negative hash widens to a bigint of sign bits.
387
+ */
388
+ export function workerLockSql(appName) {
389
+ return (`WITH k AS (SELECT hashtext('hf-worker:' || ${quoteLiteral(appName)})::bigint AS value) ` +
390
+ "SELECT count(l.pid), count(l.pid) FILTER (WHERE " +
391
+ "l.classid = ((k.value >> 32) & 4294967295)::oid AND " +
392
+ "l.objid = (k.value & 4294967295)::oid) " +
393
+ "FROM k LEFT JOIN pg_locks AS l ON l.locktype = 'advisory' AND " +
394
+ "l.database = (SELECT oid FROM pg_database WHERE datname = current_database())");
395
+ }
396
+ async function lockFindings(context, names, add) {
397
+ let row;
398
+ try {
399
+ const db = await context.database();
400
+ row = (await db.query(workerLockSql(names.appName), { database: names.databaseName })).rows[0];
401
+ }
402
+ catch (error) {
403
+ add("lock", "fail", flatten(error.message));
404
+ return;
405
+ }
406
+ const key = `hf-worker:${names.appName}`;
407
+ const held = Number(row?.[0]);
408
+ const matching = Number(row?.[1]);
409
+ if (!Number.isFinite(held)) {
410
+ add("lock", "fail", `pg_locks in ${names.databaseName} answered no count`);
411
+ return;
412
+ }
413
+ if (held === 0) {
414
+ add("lock", "fail", `no advisory lock in ${names.databaseName}: no worker holds ${key}`);
415
+ return;
416
+ }
417
+ if (held !== 1) {
418
+ add("lock", "fail", `${String(held)} advisory locks in ${names.databaseName}; one worker per app holds one`);
419
+ return;
420
+ }
421
+ if (matching !== 1) {
422
+ add("lock", "fail", `the one advisory lock in ${names.databaseName} is not hashtext('${key}'): ` +
423
+ "something other than the worker holds it");
424
+ return;
425
+ }
426
+ add("lock", "ok", `one worker holds ${key} in ${names.databaseName}`);
427
+ }
428
+ function tryNames(name) {
429
+ try {
430
+ return deriveNames(name);
431
+ }
432
+ catch {
433
+ return undefined;
434
+ }
435
+ }
290
436
  function restoreCheckFindings(context, state, add) {
291
437
  const last = state.lastRestoreCheckAt;
292
438
  if (last === undefined) {
package/dist/index.d.ts CHANGED
@@ -17,5 +17,5 @@ export { run, CommandFailed, type RunOptions } from "./spawn.js";
17
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
18
  export { findPostgresContainer, openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, type AdminCredentials, type Database, type DatabaseTransport, type OpenDatabaseOptions, type QueryOptions, type QueryResult, } from "./database.js";
19
19
  export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, type BackupDump, type BackupSource, type BackupSourceKind, type LocalDirectoryBackupSourceOptions, } from "./backup-source.js";
20
- export { formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, type RestoreCheckAppOptions, type RestoreCheckOptions, type RestoreCheckResult, type RestoreCheckRow, type RestoreVerdict, } from "./restore-check.js";
20
+ export { APPEND_ONLY_TABLES, formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, type RestoreCheckAppOptions, type RestoreCheckOptions, type RestoreCheckResult, type RestoreCheckRow, type RestoreVerdict, } from "./restore-check.js";
21
21
  export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, type ProvisionDatabaseOptions, type ProvisionDatabaseResult, } from "./provision-database.js";
package/dist/index.js CHANGED
@@ -17,5 +17,5 @@ export { run, CommandFailed } from "./spawn.js";
17
17
  export { createLocalRunner, createSshRunner, shellQuote, sshExecArgv, sshTunnelArgv, RunnerError, DEFAULT_TUNNEL_READY_TIMEOUT_MS, } from "./runner.js";
18
18
  export { findPostgresContainer, openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, } from "./database.js";
19
19
  export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, } from "./backup-source.js";
20
- export { formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, } from "./restore-check.js";
20
+ export { APPEND_ONLY_TABLES, formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, } from "./restore-check.js";
21
21
  export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, } from "./provision-database.js";
package/dist/new.d.ts CHANGED
@@ -14,6 +14,23 @@ export declare const EXCLUDED_ENTRIES: readonly string[];
14
14
  export declare class TemplateError extends Error {
15
15
  constructor(message: string);
16
16
  }
17
+ /**
18
+ * Where a cloud `hf new` fetches the template before it becomes the app.
19
+ *
20
+ * Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
21
+ * copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
22
+ */
23
+ export declare function templateTempDir(dir: string): string;
24
+ /**
25
+ * The two refusals a directory in the way earns, worded once because both halves of `hf new`
26
+ * raise them.
27
+ *
28
+ * Each names the absolute path and the single move that clears it: "already exists" alone leaves
29
+ * the operator to work out which of the app directory and the dot-prefixed scratch beside it is
30
+ * meant, and they are one keystroke apart.
31
+ */
32
+ export declare function targetInTheWay(dir: string): TemplateError;
33
+ export declare function scratchInTheWay(scratch: string): TemplateError;
17
34
  export interface NewAppOptions {
18
35
  /** The name as typed; becomes the directory and, underscored, both placeholders. */
19
36
  name: string;
@@ -29,6 +46,11 @@ export interface NewAppOptions {
29
46
  local: boolean;
30
47
  /** The bootstrap admin's address, written to `.env` as `HF_BOOTSTRAP_EMAIL`. Skips the prompt. */
31
48
  email?: string;
49
+ /**
50
+ * The app's monthly LLM budget, written to `.env` as `HF_BOOTSTRAP_BUDGET_USD`. `hf up` seeds
51
+ * the app state from there instead of falling back to its dev default.
52
+ */
53
+ budgetUsd?: string;
32
54
  /** Overrides the real interactive prompt; for tests and other callers with their own stdin. */
33
55
  promptEmail?: () => Promise<string>;
34
56
  }
@@ -41,6 +63,8 @@ export interface NewAppResult extends AppNames {
41
63
  wroteEnv: boolean;
42
64
  /** True when `HF_BOOTSTRAP_EMAIL` was written to `.env`, from `--email` or the prompt. */
43
65
  wroteBootstrapEmail: boolean;
66
+ /** True when `HF_BOOTSTRAP_BUDGET_USD` was written to `.env`, from `--budget-usd`. */
67
+ wroteBootstrapBudget: boolean;
44
68
  }
45
69
  /**
46
70
  * Copies the template checkout and substitutes the two placeholders across it.
package/dist/new.js CHANGED
@@ -46,6 +46,31 @@ export class TemplateError extends Error {
46
46
  this.name = "TemplateError";
47
47
  }
48
48
  }
49
+ /**
50
+ * Where a cloud `hf new` fetches the template before it becomes the app.
51
+ *
52
+ * Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
53
+ * copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
54
+ */
55
+ export function templateTempDir(dir) {
56
+ return path.join(path.dirname(dir), `.${path.basename(dir)}.hf-new`);
57
+ }
58
+ /**
59
+ * The two refusals a directory in the way earns, worded once because both halves of `hf new`
60
+ * raise them.
61
+ *
62
+ * Each names the absolute path and the single move that clears it: "already exists" alone leaves
63
+ * the operator to work out which of the app directory and the dot-prefixed scratch beside it is
64
+ * meant, and they are one keystroke apart.
65
+ */
66
+ export function targetInTheWay(dir) {
67
+ return new TemplateError(`${dir} already exists; hf new will not write into it. Move it away (or delete it) and rerun — ` +
68
+ "the template is fetched into a directory of its own.");
69
+ }
70
+ export function scratchInTheWay(scratch) {
71
+ return new TemplateError(`${scratch} is a leftover hf new scratch directory and hf has no record of creating it. ` +
72
+ "Remove it and rerun; the template is fetched into it fresh.");
73
+ }
49
74
  /**
50
75
  * Copies the template checkout and substitutes the two placeholders across it.
51
76
  *
@@ -64,7 +89,13 @@ export async function newApp(options) {
64
89
  await assertTemplateSource(source);
65
90
  const dir = path.resolve(options.into ?? process.cwd(), names.given);
66
91
  if (await exists(dir)) {
67
- throw new TemplateError(`${dir} already exists; hf new will not write into it`);
92
+ throw targetInTheWay(dir);
93
+ }
94
+ // A scratch directory here is an interrupted cloud run for this same name: copying an app over
95
+ // the top of it would leave that run's rerun to judge a directory neither flow made.
96
+ const scratch = templateTempDir(dir);
97
+ if (await exists(scratch)) {
98
+ throw scratchInTheWay(scratch);
68
99
  }
69
100
  await cp(source, dir, {
70
101
  recursive: true,
@@ -75,6 +106,7 @@ export async function newApp(options) {
75
106
  const example = path.join(dir, ".env.example");
76
107
  const wroteEnv = await exists(example);
77
108
  let wroteBootstrapEmail = false;
109
+ let wroteBootstrapBudget = false;
78
110
  if (wroteEnv) {
79
111
  let contents = await readFile(example, "utf8");
80
112
  const email = options.email ?? (await (options.promptEmail ?? promptForBootstrapEmail)());
@@ -82,9 +114,13 @@ export async function newApp(options) {
82
114
  contents = `${contents.trimEnd()}\nHF_BOOTSTRAP_EMAIL=${email}\n`;
83
115
  wroteBootstrapEmail = true;
84
116
  }
117
+ if (options.budgetUsd !== undefined && options.budgetUsd !== "") {
118
+ contents = `${contents.trimEnd()}\nHF_BOOTSTRAP_BUDGET_USD=${options.budgetUsd}\n`;
119
+ wroteBootstrapBudget = true;
120
+ }
85
121
  await writeFile(path.join(dir, ".env"), contents);
86
122
  }
87
- return { ...names, dir, substituted, wroteEnv, wroteBootstrapEmail };
123
+ return { ...names, dir, substituted, wroteEnv, wroteBootstrapEmail, wroteBootstrapBudget };
88
124
  }
89
125
  /** The one-time prompt: the address `hf up` later hands `hf bootstrap` via `.env`. */
90
126
  async function promptForBootstrapEmail() {
@@ -80,6 +80,23 @@ export interface CoolifyEnvironmentVariable {
80
80
  is_literal?: boolean;
81
81
  is_multiline?: boolean;
82
82
  is_shown_once?: boolean;
83
+ /**
84
+ * Whether the builder interpolates the variable, and whether the containers get it.
85
+ *
86
+ * Both default true in Coolify's UI and neither is in the document's *request* schemas —
87
+ * only in its `EnvironmentVariable` model — but the box accepts and stores them (verified
88
+ * against 4.3.21). `SOURCE_COMMIT` needs both: it is the compose image tag and build arg as
89
+ * well as the `HF_BUILD_SHA` three services read.
90
+ */
91
+ is_buildtime?: boolean;
92
+ is_runtime?: boolean;
93
+ }
94
+ /** One entry of `GET /applications/{uuid}/envs`, narrowed to what an upsert has to match on. */
95
+ export interface CoolifyEnvEntry {
96
+ uuid: string;
97
+ key: string;
98
+ value?: string;
99
+ is_preview?: boolean;
83
100
  }
84
101
  export interface CoolifyDeploymentRequest {
85
102
  deployments: {
@@ -171,6 +188,13 @@ export declare class CoolifyClient {
171
188
  tag?: string;
172
189
  }): Promise<CoolifyApplicationSummary[]>;
173
190
  updateEnvsBulk(appUuid: string, data: readonly CoolifyEnvironmentVariable[]): Promise<unknown>;
191
+ /** Every environment variable on the application, preview and non-preview alike. */
192
+ listEnvs(appUuid: string): Promise<CoolifyEnvEntry[]>;
193
+ createEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<{
194
+ uuid: string;
195
+ }>;
196
+ /** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
197
+ updateEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<unknown>;
174
198
  deploy(uuid: string, options?: {
175
199
  force?: boolean;
176
200
  }): Promise<CoolifyDeploymentRequest>;
@@ -55,6 +55,27 @@ export class CoolifyClient {
55
55
  secrets: data.map((variable) => variable.value),
56
56
  });
57
57
  }
58
+ /** Every environment variable on the application, preview and non-preview alike. */
59
+ async listEnvs(appUuid) {
60
+ return await this.request({ method: "GET", path: `/applications/${segment(appUuid)}/envs` });
61
+ }
62
+ async createEnv(appUuid, variable) {
63
+ return await this.request({
64
+ method: "POST",
65
+ path: `/applications/${segment(appUuid)}/envs`,
66
+ body: variable,
67
+ secrets: [variable.value],
68
+ });
69
+ }
70
+ /** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
71
+ async updateEnv(appUuid, variable) {
72
+ return await this.request({
73
+ method: "PATCH",
74
+ path: `/applications/${segment(appUuid)}/envs`,
75
+ body: variable,
76
+ secrets: [variable.value],
77
+ });
78
+ }
58
79
  async deploy(uuid, options = {}) {
59
80
  return await this.request({
60
81
  method: "POST",
@@ -4,8 +4,21 @@ import { type Runner } from "./runner.js";
4
4
  import { type AppStateStore } from "./state.js";
5
5
  /** Appended to `hf_<app>` for the database the dump is restored into and then dropped. */
6
6
  export declare const SCRATCH_SUFFIX = "_restore_check";
7
- /** Older than this and the dump gets a warning line; it never changes the exit code. */
8
- export declare const STALE_DUMP_HOURS = 36;
7
+ /** Older than this and the dump gets a WARN line, which as in `hf doctor` — exits 1. */
8
+ export declare const STALE_DUMP_HOURS = 24;
9
+ /**
10
+ * Tables a live row is only ever added to, so a live count above the dump's is the app working,
11
+ * not lost data.
12
+ *
13
+ * X1 found this the hard way: against a 1.3-hour-old dump of a running app, 9 of 24 tables
14
+ * "mismatched" purely from churn since the dump, and the same check against a fresh dump matched
15
+ * all 24. A table earns a place here only when no code path deletes from it and none updates it
16
+ * in a way that lowers its count — checked against `packages/db/src/schema` and every statement
17
+ * in `core`, `workflows`, `auth` and `admin`. Anything else, including a table whose rows merely
18
+ * look permanent, stays exact: a false `ok` here hides exactly the data loss this command exists
19
+ * to catch.
20
+ */
21
+ export declare const APPEND_ONLY_TABLES: readonly string[];
9
22
  export type RestoreVerdict = "ok" | "mismatch" | "live only" | "restored only";
10
23
  export interface RestoreCheckRow {
11
24
  table: string;
@@ -13,6 +26,14 @@ export interface RestoreCheckRow {
13
26
  live?: number;
14
27
  /** Absent when the table is not in the restored dump. */
15
28
  restored?: number;
29
+ /**
30
+ * Rows the live side gained since the dump, on an `APPEND_ONLY_TABLES` table.
31
+ *
32
+ * Drift is not its own verdict because it is not its own outcome: the restore held everything
33
+ * the dump had, which is `ok`. The column prints it as `ok (drift +N)` so the operator can see
34
+ * why two counts differ without having to decide whether it mattered.
35
+ */
36
+ drift?: number;
16
37
  verdict: RestoreVerdict;
17
38
  }
18
39
  export interface RestoreCheckResult {
@@ -21,11 +42,15 @@ export interface RestoreCheckResult {
21
42
  scratchDatabase: string;
22
43
  dump: BackupDump;
23
44
  dumpAgeHours: number;
24
- /** The dump is older than `STALE_DUMP_HOURS`. Informational. */
45
+ /** The dump is older than `STALE_DUMP_HOURS`; on its own enough to exit 1. */
25
46
  dumpStale: boolean;
47
+ /** Exact matching was asked for, so no table was allowed to drift. */
48
+ strict: boolean;
26
49
  /** One row per table, `hf_*` or carrying `normalized_name`, sorted by name. */
27
50
  rows: readonly RestoreCheckRow[];
28
- /** Every row's verdict is `ok`. The command's exit code is `ok ? 0 : 1`. */
51
+ /** Every row's verdict is `ok`, drift included. What `lastRestoreCheckAt` is written on. */
52
+ matched: boolean;
53
+ /** `matched` and the dump is not stale. The command's exit code is `ok ? 0 : 1`. */
29
54
  ok: boolean;
30
55
  }
31
56
  export declare class RestoreCheckError extends Error {
@@ -57,6 +82,8 @@ export interface RestoreCheckOptions {
57
82
  restoreAdminUrl?: string;
58
83
  /** The `pg_restore` binary inside the container. */
59
84
  pgRestorePath?: string;
85
+ /** Compare every table exactly, `APPEND_ONLY_TABLES` included. */
86
+ strict?: boolean;
60
87
  now?: Date;
61
88
  }
62
89
  /**
@@ -104,6 +131,8 @@ export interface RestoreCheckAppOptions {
104
131
  backupDir?: string;
105
132
  /** Read the dump from Hetzner object storage instead. Not implemented; see `backup-source`. */
106
133
  fromS3?: boolean;
134
+ /** Compare every table exactly, `APPEND_ONLY_TABLES` included. */
135
+ strict?: boolean;
107
136
  env?: NodeJS.ProcessEnv;
108
137
  }
109
138
  /**