@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.
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 +74 -0
  16. package/dist/cloud-steps/coolify.js +300 -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 +12 -0
  28. package/dist/cloud-steps/langfuse.js +35 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +163 -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 +53 -0
  36. package/dist/config.js +155 -0
  37. package/dist/database.d.ts +65 -0
  38. package/dist/database.js +142 -0
  39. package/dist/doctor.d.ts +71 -0
  40. package/dist/doctor.js +310 -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 +126 -0
  46. package/dist/new-cloud.js +210 -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 +257 -0
  65. package/dist/runner.d.ts +65 -0
  66. package/dist/runner.js +199 -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
package/dist/doctor.js ADDED
@@ -0,0 +1,310 @@
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 { loadOperatorConfig, 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
+ /** The cluster role `hf doctor` reads privileges as, before `SET ROLE`. */
16
+ const CLUSTER_ADMIN_USER = "postgres";
17
+ /**
18
+ * `hf doctor` — what is wrong with the deployed apps, one line per finding.
19
+ *
20
+ * Every check is reported rather than thrown: an app whose status endpoint is unreachable is
21
+ * also an app whose E006 and whose bump PRs the operator still wants to know about, and the
22
+ * whole point of this command is one screen that says whether anything needs attention.
23
+ *
24
+ * Nothing here prints a secret. The read token authorizes the status request and never appears
25
+ * in a finding; a provider's response body is dropped for the same reason (`ProviderError`).
26
+ */
27
+ export async function doctor(options = {}) {
28
+ const env = options.env ?? process.env;
29
+ const config = options.config ?? (await loadOperatorConfig({ env }));
30
+ const required = requireOperatorConfig(config, ["HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
31
+ const dir = options.stateDir ?? stateDir(env);
32
+ const context = {
33
+ dir,
34
+ env,
35
+ baseDomain: required.HF_BASE_DOMAIN,
36
+ github: new GithubClient({ token: required.HF_GITHUB_TOKEN, fetch: options.fetch }),
37
+ fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
38
+ now: options.now ?? (() => new Date()),
39
+ privileges: options.privileges ?? defaultPrivilegeCheck(config, env),
40
+ };
41
+ const names = options.name === undefined ? await stateNames(dir) : [options.name];
42
+ const findings = [];
43
+ for (const name of names)
44
+ findings.push(...(await doctorApp(context, name)));
45
+ return { findings, ok: findings.every((finding) => finding.severity === "ok") };
46
+ }
47
+ const MARKER = { ok: "OK ", warn: "WARN", fail: "FAIL" };
48
+ /** The report as printed: a blank line and a header per app, then its findings. */
49
+ export function doctorLines(result) {
50
+ const lines = [];
51
+ let app;
52
+ for (const finding of result.findings) {
53
+ if (finding.app !== app) {
54
+ if (app !== undefined)
55
+ lines.push("");
56
+ lines.push(finding.app);
57
+ app = finding.app;
58
+ }
59
+ lines.push(` ${MARKER[finding.severity]} ${finding.check}: ${finding.message}`);
60
+ }
61
+ if (lines.length === 0)
62
+ lines.push("no apps in the state cache: hf new has provisioned none");
63
+ return lines;
64
+ }
65
+ /**
66
+ * E006 as `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
67
+ *
68
+ * As the app role rather than as an admin because that is the only role whose answer matters —
69
+ * a superuser's privileges are both true whatever the migrator granted.
70
+ */
71
+ export function tunnelPrivilegeCheck(runner, options = {}) {
72
+ return async (target) => {
73
+ const db = await openDatabase(runner, {
74
+ admin: { user: CLUSTER_ADMIN_USER },
75
+ container: options.container,
76
+ });
77
+ try {
78
+ const adminUrl = db.adminUrl(target.databaseName);
79
+ if (adminUrl === undefined) {
80
+ throw new Error(`E006 cannot be read over the ${db.kind} transport: ${CLUSTER_ADMIN_USER} has to be ` +
81
+ "a session a pg client holds open, so that SET ROLE outlives the statement");
82
+ }
83
+ await checkAppRolePrivileges(adminUrl, target.applicationRole);
84
+ }
85
+ finally {
86
+ await db.close();
87
+ }
88
+ };
89
+ }
90
+ /**
91
+ * `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
92
+ * database — after `SET ROLE`. The check itself is not restated here: a second copy of the
93
+ * privilege query is a second thing to keep in step with the grants the migrator makes.
94
+ */
95
+ export async function checkAppRolePrivileges(adminUrl, role) {
96
+ const client = new Client({ connectionString: adminUrl });
97
+ await client.connect();
98
+ try {
99
+ await client.query(`SET ROLE ${quoteIdent(role)}`);
100
+ await checkE006(client);
101
+ }
102
+ finally {
103
+ await client.end();
104
+ }
105
+ }
106
+ function defaultPrivilegeCheck(config, env) {
107
+ const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
108
+ return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST }));
109
+ }
110
+ async function doctorApp(context, name) {
111
+ const findings = [];
112
+ const add = (check, severity, message) => {
113
+ findings.push({ app: name, check, severity, message });
114
+ };
115
+ const file = path.join(context.dir, `${name}.json`);
116
+ if (!(await exists(file))) {
117
+ add("state", "fail", `no state file at ${file}: hf new has not provisioned ${name}`);
118
+ return findings;
119
+ }
120
+ let state;
121
+ try {
122
+ state = (await openAppState(name, { dir: context.dir, env: context.env })).state;
123
+ }
124
+ catch (error) {
125
+ add("state", "fail", flatten(error.message));
126
+ return findings;
127
+ }
128
+ const repo = parseRepo(state.repo);
129
+ let mainSha;
130
+ let mainShaProblem;
131
+ if (repo === undefined) {
132
+ mainShaProblem = "no owner/name repo recorded in state; main's sha cannot be read";
133
+ }
134
+ else {
135
+ try {
136
+ mainSha = (await context.github.getReference(repo.owner, repo.repo, "heads/main")).object.sha;
137
+ }
138
+ catch (error) {
139
+ mainShaProblem = `${state.repo ?? "?"}: ${flatten(error.message)}`;
140
+ }
141
+ }
142
+ const report = await statusFindings(context, name, state, add);
143
+ versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
144
+ if (report !== undefined) {
145
+ budgetFinding(report.budget.current, "current", add);
146
+ budgetFinding(report.budget.previous, "previous", add);
147
+ }
148
+ await privilegeFindings(context, name, add);
149
+ restoreCheckFindings(context, state, add);
150
+ if (repo !== undefined)
151
+ await bumpFindings(context, repo, add);
152
+ return findings;
153
+ }
154
+ /** The health and run lines; `undefined` when the app did not answer, which is its own line. */
155
+ async function statusFindings(context, name, state, add) {
156
+ const url = `https://${name}.${context.baseDomain}/api/status`;
157
+ const token = state.statusTokens?.read;
158
+ if (token === undefined) {
159
+ add("status", "fail", `no read status token in state; hf status-token has not run for ${name}`);
160
+ return undefined;
161
+ }
162
+ let report;
163
+ try {
164
+ report = await getStatus(context.fetch, url, token);
165
+ }
166
+ catch (error) {
167
+ add("status", "fail", `GET ${url}: ${flatten(error.message)}`);
168
+ return undefined;
169
+ }
170
+ add("status", report.health === "ok" ? "ok" : "warn", `health ${report.health}, ${String(report.anomalies)} anomaly/anomalies, core ` +
171
+ report.coreVersion);
172
+ add("runs", "ok", `${String(report.runs.running)} run(s) running`);
173
+ // Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` is an app whose
174
+ // worker has not reported yet — neither is a finding, but a canned draft an operator takes
175
+ // for a real one is.
176
+ if (report.llm.mode === "fixtures") {
177
+ add("llm", "warn", "app is serving fixture drafts — no provider key set");
178
+ }
179
+ return report;
180
+ }
181
+ function versionFinding(deployed, mainSha, mainShaProblem, add) {
182
+ if (mainShaProblem !== undefined) {
183
+ add("version", "fail", mainShaProblem);
184
+ return;
185
+ }
186
+ if (deployed === undefined || mainSha === undefined)
187
+ return;
188
+ if (deployed === null) {
189
+ add("version", "warn", "the app reports no applicationVersion: HF_BUILD_SHA is unset");
190
+ return;
191
+ }
192
+ add("version", deployed === mainSha ? "ok" : "warn", deployed === mainSha
193
+ ? `applicationVersion ${short(mainSha)} is main`
194
+ : `applicationVersion ${short(deployed)} is not main ${short(mainSha)}`);
195
+ }
196
+ function budgetFinding(period, which, add) {
197
+ if (period === null) {
198
+ add("budget", "ok", `no ${which} period row yet`);
199
+ return;
200
+ }
201
+ const over = Number(period.spentUsd) > Number(period.budgetUsd);
202
+ const drifting = Number(period.driftUsd) !== 0;
203
+ const spend = `${period.period} spent $${period.spentUsd} of $${period.budgetUsd}`;
204
+ if (over || drifting) {
205
+ add("budget", "warn", `${spend}${over ? " — over budget" : ""}${drifting ? ` — drift $${period.driftUsd}` : ""}`);
206
+ return;
207
+ }
208
+ add("budget", "ok", `${spend}, no drift`);
209
+ }
210
+ async function privilegeFindings(context, name, add) {
211
+ let target;
212
+ try {
213
+ const names = deriveNames(name);
214
+ target = {
215
+ app: name,
216
+ databaseName: names.databaseName,
217
+ applicationRole: names.applicationRole,
218
+ };
219
+ }
220
+ catch (error) {
221
+ add("E006", "fail", flatten(error.message));
222
+ return;
223
+ }
224
+ try {
225
+ await context.privileges(target);
226
+ add("E006", "ok", `${target.applicationRole} has USAGE on dbos and INSERT on dbos.workflow_status`);
227
+ }
228
+ catch (error) {
229
+ add("E006", "fail", flatten(error.message));
230
+ }
231
+ }
232
+ function restoreCheckFindings(context, state, add) {
233
+ const last = state.lastRestoreCheckAt;
234
+ if (last === undefined) {
235
+ add("restore-check", "warn", "never run; run hf restore-check");
236
+ return;
237
+ }
238
+ const at = Date.parse(last);
239
+ if (Number.isNaN(at)) {
240
+ add("restore-check", "warn", `lastRestoreCheckAt is not a date: ${last}`);
241
+ return;
242
+ }
243
+ const days = (context.now().getTime() - at) / 86_400_000;
244
+ add("restore-check", days > RESTORE_CHECK_MAX_AGE_DAYS ? "warn" : "ok", `last ran ${days.toFixed(1)} day(s) ago (${last})`);
245
+ }
246
+ async function bumpFindings(context, repo, add) {
247
+ let open;
248
+ try {
249
+ open = await context.github.listPullRequests(repo.owner, repo.repo, {
250
+ state: "open",
251
+ per_page: 100,
252
+ });
253
+ }
254
+ catch (error) {
255
+ add("core-bump", "fail", flatten(error.message));
256
+ return;
257
+ }
258
+ const bumps = open.filter((pull) => pull.head.ref.startsWith(CORE_BUMP_BRANCH_PREFIX));
259
+ if (bumps.length === 0) {
260
+ add("core-bump", "ok", "no open core-bump pull request");
261
+ return;
262
+ }
263
+ for (const pull of bumps) {
264
+ let checks;
265
+ try {
266
+ checks = (await context.github.getCombinedStatus(repo.owner, repo.repo, pull.head.sha)).state;
267
+ }
268
+ catch (error) {
269
+ add("core-bump", "fail", `#${String(pull.number)}: ${flatten(error.message)}`);
270
+ continue;
271
+ }
272
+ add("core-bump", checks === "failure" ? "warn" : "ok", `#${String(pull.number)} ${pull.head.ref}: checks ${checks} — ${pull.html_url}`);
273
+ }
274
+ }
275
+ /**
276
+ * The status request, and nothing of the response body on a refusal: `/api/status` answers 401
277
+ * with a body of its own, and everything it would say about the token belongs nowhere near a
278
+ * terminal.
279
+ */
280
+ async function getStatus(fetchImpl, url, token) {
281
+ const response = await fetchImpl(url, {
282
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
283
+ });
284
+ if (!response.ok)
285
+ throw new Error(`HTTP ${String(response.status)}`);
286
+ return (await response.json());
287
+ }
288
+ async function stateNames(dir) {
289
+ const entries = await readdir(dir).catch(() => []);
290
+ return entries
291
+ .filter((entry) => entry.endsWith(".json"))
292
+ .map((entry) => entry.slice(0, -".json".length))
293
+ .sort();
294
+ }
295
+ function parseRepo(repo) {
296
+ const parts = repo?.split("/") ?? [];
297
+ if (parts.length !== 2 || parts[0] === "" || parts[1] === "")
298
+ return undefined;
299
+ return { owner: parts[0], repo: parts[1] };
300
+ }
301
+ async function exists(file) {
302
+ return await access(file).then(() => true, () => false);
303
+ }
304
+ /** One finding is one line, and `BootCheckFailure` spells its details across several. */
305
+ function flatten(message) {
306
+ return message.replace(/\s*\n\s*-?\s*/g, "; ").trim();
307
+ }
308
+ function short(sha) {
309
+ return sha.length > 7 ? sha.slice(0, 7) : sha;
310
+ }
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";
package/dist/index.js CHANGED
@@ -14,3 +14,8 @@ export { probeApp } from "./probe.js";
14
14
  export { generate, GENERATOR_BIN, GENERATOR_CONFIG, NoGenerators, } from "./gen.js";
15
15
  export { dev, devBuildSha, DEV_COMPOSE_FILE } from "./dev.js";
16
16
  export { run, CommandFailed } from "./spawn.js";
17
+ export { createLocalRunner, createSshRunner, shellQuote, sshExecArgv, sshTunnelArgv, RunnerError, DEFAULT_TUNNEL_READY_TIMEOUT_MS, } from "./runner.js";
18
+ export { openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, } from "./database.js";
19
+ export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, } from "./backup-source.js";
20
+ export { formatRestoreCheck, pgRestoreArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, } from "./restore-check.js";
21
+ export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, } from "./provision-database.js";
package/dist/migrate.d.ts CHANGED
@@ -6,7 +6,18 @@ export interface MigrateAppOptions {
6
6
  dir?: string;
7
7
  /** Skips role provisioning — the cloud path, where `hf new` created the roles. */
8
8
  skipRoles?: boolean;
9
+ /** Connection URLs and anything else the app needs, in place of a `.env`; see `ResolveAppOptions`. */
10
+ env?: Record<string, string>;
9
11
  }
12
+ /**
13
+ * The environment the app's `migrate.ts` runs under.
14
+ *
15
+ * With an overlay — the cloud path — it is the overlay alone plus `INHERITED_ENV`, **not**
16
+ * `process.env`: the operator's laptop is where `HF_COOLIFY_TOKEN`, `HF_GITHUB_TOKEN` and the
17
+ * Cloudflare and Sentry tokens live, and none of them is the app's to hold. A local run has no
18
+ * overlay and keeps Phase 1's behaviour, `.env` under the shell it was started from.
19
+ */
20
+ export declare function migrateChildEnv(app: ResolvedApp): NodeJS.ProcessEnv;
10
21
  export interface MigrateAppResult {
11
22
  app: ResolvedApp;
12
23
  roles: LocalRoleResult | undefined;
package/dist/migrate.js CHANGED
@@ -5,6 +5,30 @@ import { credentialsOf, provisionLocalRoles } from "./roles.js";
5
5
  import { run } from "./spawn.js";
6
6
  /** The entrypoint track B's template ships; the same file the deployed `migrate` service runs. */
7
7
  export const MIGRATE_ENTRY = "migrate.ts";
8
+ /**
9
+ * Names the migrator child inherits from this process when an overlay supplies the rest.
10
+ *
11
+ * `HOME` because pnpm, tsx and `psql` all write under it; `TMPDIR` and `SHELL` because Node's
12
+ * own child machinery uses them.
13
+ */
14
+ const INHERITED_ENV = ["PATH", "HOME", "TMPDIR", "SHELL"];
15
+ /**
16
+ * The environment the app's `migrate.ts` runs under.
17
+ *
18
+ * With an overlay — the cloud path — it is the overlay alone plus `INHERITED_ENV`, **not**
19
+ * `process.env`: the operator's laptop is where `HF_COOLIFY_TOKEN`, `HF_GITHUB_TOKEN` and the
20
+ * Cloudflare and Sentry tokens live, and none of them is the app's to hold. A local run has no
21
+ * overlay and keeps Phase 1's behaviour, `.env` under the shell it was started from.
22
+ */
23
+ export function migrateChildEnv(app) {
24
+ if (Object.keys(app.envOverlay).length === 0) {
25
+ return { ...app.env, HF_PROCESS: "migrate" };
26
+ }
27
+ const inherited = {};
28
+ for (const name of INHERITED_ENV)
29
+ inherited[name] = process.env[name];
30
+ return { ...inherited, ...app.envOverlay, HF_PROCESS: "migrate" };
31
+ }
8
32
  /**
9
33
  * `hf migrate` — the application role, then the app's own migrator entrypoint.
10
34
  *
@@ -17,7 +41,7 @@ export const MIGRATE_ENTRY = "migrate.ts";
17
41
  * The first half is the CLI's own, and only local: a container never creates a role.
18
42
  */
19
43
  export async function migrateApp(options = {}) {
20
- const app = await resolveApp(options.dir);
44
+ const app = await resolveApp(options.dir, { env: options.env });
21
45
  const databaseUrl = requireEnv(app, "DATABASE_URL");
22
46
  const migratorUrl = requireEnv(app, "MIGRATOR_DATABASE_URL");
23
47
  let roles;
@@ -31,7 +55,7 @@ export async function migrateApp(options = {}) {
31
55
  }
32
56
  await run(process.execPath, ["--import", "tsx", path.join(app.dir, MIGRATE_ENTRY)], {
33
57
  cwd: app.dir,
34
- env: { ...app.env, HF_PROCESS: "migrate" },
58
+ env: migrateChildEnv(app),
35
59
  });
36
60
  return { app, roles };
37
61
  }
@@ -0,0 +1,126 @@
1
+ import { CLOUD_STEPS, type CloudCommands, type CloudStepContext } from "./cloud-steps/index.js";
2
+ import { type ConfigKey, type OperatorConfig } from "./config.js";
3
+ import { type AdminCredentials } from "./database.js";
4
+ import type { FetchLike } from "./providers/http.js";
5
+ import { type Runner } from "./runner.js";
6
+ import { type AppState, type AppStateStore, type StepName } from "./state.js";
7
+ /** The steps themselves; the runner is what orders and records them. */
8
+ export { CLOUD_STEPS };
9
+ /**
10
+ * What the runner itself is handed. The steps get `CloudStepContext`, which extends it.
11
+ */
12
+ export interface CloudContext {
13
+ state: AppStateStore;
14
+ /**
15
+ * Set by the `database` step when it gave an existing role a new password.
16
+ *
17
+ * The deployed app still holds the old one at that moment, so the run is only safe once the
18
+ * Coolify envs and a redeploy have followed; `assertEnvsCurrent` is what refuses to call it
19
+ * finished before that.
20
+ */
21
+ rotated: boolean;
22
+ }
23
+ export interface Step<Context extends CloudContext = CloudContext> {
24
+ name: StepName;
25
+ run(context: Context): Promise<void>;
26
+ }
27
+ export interface RunStepsResult {
28
+ ran: readonly StepName[];
29
+ /** Recorded by an earlier run, and still current. */
30
+ skipped: readonly StepName[];
31
+ /** Recorded by an earlier run, but against secrets since rotated; forgotten and run again. */
32
+ invalidated: readonly StepName[];
33
+ }
34
+ /** The runner found state it will not act on: the operator has to be told, not worked around. */
35
+ export declare class StepInvariantViolated extends Error {
36
+ constructor(message: string);
37
+ }
38
+ /**
39
+ * True when the secrets the Coolify envs were last PATCHed with are the ones the state holds now.
40
+ *
41
+ * A `coolify` step that has run but recorded no hash counts as stale: re-PATCHing is idempotent
42
+ * and cheap, and the alternative is trusting a file written before this field existed.
43
+ */
44
+ export declare function envsAreCurrent(state: AppState): boolean;
45
+ /**
46
+ * Refuses a recorded state whose deployment is authenticating with secrets that no longer exist.
47
+ *
48
+ * Silent otherwise, including for an app that has not reached `coolify` yet: a run that stops
49
+ * early is resumable, whereas a `coolify` recorded against passwords since rotated is a deployed
50
+ * app locked out of its own database with nothing left to notice it.
51
+ */
52
+ export declare function assertEnvsCurrent(state: AppState): void;
53
+ /**
54
+ * Refuses to finish a run that rotated a password without the Coolify environment catching up.
55
+ *
56
+ * The state-only check above cannot see this case on its own: a list of steps that stops before
57
+ * `coolify` leaves a consistent file and a deployed app on dead credentials.
58
+ */
59
+ export declare function assertRotationApplied(context: CloudContext): void;
60
+ /**
61
+ * Forgets the steps that carried secrets the app no longer has, and reports which.
62
+ *
63
+ * Done once, before the first step, and by clearing the records rather than by ignoring them:
64
+ * `coolify` makes the hash current again the moment it re-runs, so a per-step test would then
65
+ * count `deploy` as done and leave the containers reading the previous environment. Clearing also
66
+ * survives a crash in between — the next run sees the same two steps missing.
67
+ */
68
+ export declare function invalidateStaleSecretSteps(state: AppStateStore): Promise<readonly StepName[]>;
69
+ /**
70
+ * Runs the steps of a cloud `hf new` in order, skipping what a previous run finished.
71
+ *
72
+ * A step is marked done only after its `run` resolves, so a failure leaves the step unrecorded
73
+ * and the next run repeats it — the one direction that is safe, since repeating a create costs a
74
+ * duplicate at worst while recording one that never happened costs an app nobody can finish.
75
+ * Errors propagate untouched: the caller prints them, and the state file is the resume point.
76
+ */
77
+ export declare function runSteps<Context extends CloudContext>(steps: readonly Step<Context>[], context: Context): Promise<RunStepsResult>;
78
+ /**
79
+ * Every operator config key a cloud `hf new` needs, checked before the first step.
80
+ *
81
+ * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
82
+ * and an operator who learns about them one failed step at a time pays for a half-provisioned app
83
+ * each time. The optional keys are deliberately absent — `HF_DB_HOST_INTERNAL` has a default, and
84
+ * the two provider keys are what the checklist warns about when they are unset.
85
+ */
86
+ export declare const REQUIRED_CLOUD_CONFIG: readonly ConfigKey[];
87
+ export interface NewAppCloudOptions {
88
+ name: string;
89
+ /** Required in the cloud: a deployed app never starts under a cap nobody chose. */
90
+ budgetUsd: string;
91
+ /** Required in the cloud: there is no prompt and no `.env` to carry it. */
92
+ email: string;
93
+ from?: string;
94
+ /** Where `<name>` is created. Defaults to the working directory. */
95
+ into?: string;
96
+ io: {
97
+ out(line: string): void;
98
+ };
99
+ config?: OperatorConfig;
100
+ env?: NodeJS.ProcessEnv;
101
+ /** Where the per-app state files are. Defaults to `stateDir()`. */
102
+ stateDir?: string;
103
+ /** Replaces `CLOUD_STEPS`; the tests run a shorter list, never a different order. */
104
+ steps?: readonly Step<CloudStepContext>[];
105
+ commands?: CloudCommands;
106
+ runner?: Runner;
107
+ /** The cluster admin the database is provisioned as. Defaults to `postgres`/`PGPASSWORD`. */
108
+ clusterAdmin?: AdminCredentials;
109
+ fetch?: FetchLike;
110
+ now?: () => number;
111
+ sleep?: (ms: number) => Promise<void>;
112
+ }
113
+ export interface NewAppCloudResult extends RunStepsResult {
114
+ dir: string;
115
+ fqdn: string;
116
+ /** What the operator still has to do, ready to print. */
117
+ checklist: readonly string[];
118
+ }
119
+ /**
120
+ * `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
121
+ *
122
+ * Nothing here is interactive and nothing is prompted for — this runs against five APIs and a box
123
+ * — so every input is a flag or a config key, and a missing one is reported before the first
124
+ * request rather than half way through.
125
+ */
126
+ export declare function newAppCloud(options: NewAppCloudOptions): Promise<NewAppCloudResult>;