@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
@@ -0,0 +1,120 @@
1
+ import { type OperatorConfig } from "../config.js";
2
+ import type { Database } from "../database.js";
3
+ import type { AppNames } from "../names.js";
4
+ import type { CloudContext } from "../new-cloud.js";
5
+ import type { FetchLike } from "../providers/http.js";
6
+ /**
7
+ * A step refused to act, or a command it ran failed.
8
+ *
9
+ * Carries no provider body and no environment: the only thing a step is allowed to say about a
10
+ * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half.
11
+ */
12
+ export declare class StepFailed extends Error {
13
+ constructor(message: string);
14
+ }
15
+ /** Where a step's progress lines go; the CLI's `Io` satisfies it. */
16
+ export interface StepOut {
17
+ out(line: string): void;
18
+ }
19
+ export interface StepExecOptions {
20
+ cwd: string;
21
+ /** Laid over this process's environment for the child alone — where a token is passed. */
22
+ env?: Record<string, string>;
23
+ /** Collect the output instead of letting the child write to the terminal. */
24
+ capture?: boolean;
25
+ }
26
+ export interface StepExecOutcome {
27
+ /** `null` when the child was killed by a signal. */
28
+ code: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+ /**
33
+ * How a step runs `git` and `pnpm`.
34
+ *
35
+ * An argument array and a separate environment, never a command string: the token the repo step
36
+ * hands `git push` must reach it through the environment, where `ps` cannot read it, and an
37
+ * interpolated command line is the one shape that cannot promise that.
38
+ */
39
+ export type StepExec = (command: string, args: readonly string[], options: StepExecOptions) => Promise<StepExecOutcome>;
40
+ /** `fetchTemplate`, narrowed to what the template step asks of it so a test can be one. */
41
+ export type TemplateFetch = (source: string | undefined, dir: string) => Promise<string>;
42
+ /**
43
+ * The app's own commands, as the `coolify` step runs them through the tunnel.
44
+ *
45
+ * An interface rather than three direct calls because these are the three things a test cannot
46
+ * run — each needs the generated app's toolchain and a migrated database — and because the `env`
47
+ * overlay they take is the whole point: the cloud path never reads or writes a `.env`.
48
+ */
49
+ export interface CloudCommands {
50
+ migrate(options: {
51
+ dir: string;
52
+ env: Record<string, string>;
53
+ }): Promise<void>;
54
+ bootstrap(options: {
55
+ dir: string;
56
+ env: Record<string, string>;
57
+ email: string;
58
+ budgetUsd: string;
59
+ }): Promise<void>;
60
+ /** The plaintext of each token it generated — the only moment either exists outside a hash. */
61
+ statusToken(options: {
62
+ dir: string;
63
+ env: Record<string, string>;
64
+ }): Promise<{
65
+ read?: string;
66
+ write?: string;
67
+ }>;
68
+ }
69
+ /** What every step of a cloud `hf new` needs beyond the state the runner keeps. */
70
+ export interface CloudStepContext extends CloudContext {
71
+ names: AppNames;
72
+ /** Absolute path of the app directory the run creates, or adopts. */
73
+ dir: string;
74
+ config: OperatorConfig;
75
+ io: StepOut;
76
+ /**
77
+ * Lines to print once the run is over — things only the operator can do.
78
+ *
79
+ * Appended to, never printed here: a line about the app's DNS is no use in the middle of the
80
+ * provisioning it is about.
81
+ */
82
+ checklist: string[];
83
+ exec: StepExec;
84
+ /** `--from`: a giget specifier. `undefined` fetches `TEMPLATE_REPOSITORY`. */
85
+ from?: string;
86
+ fetchTemplate: TemplateFetch;
87
+ fetch?: FetchLike;
88
+ /** Named by `MissingConfig` when a step needs a key the operator has not set. */
89
+ env?: NodeJS.ProcessEnv;
90
+ /** The bootstrap admin's address and the app's starting budget; both required in the cloud. */
91
+ email: string;
92
+ budgetUsd: string;
93
+ /**
94
+ * The box's Postgres cluster, opened on first use and shared for the rest of the run.
95
+ *
96
+ * One tunnel: `database` and `coolify` both need one, and a second `ssh -L` would be a second
97
+ * thing to leak. The run closes it in a `finally`.
98
+ */
99
+ database(): Promise<Database>;
100
+ commands: CloudCommands;
101
+ /** The clock and the wait the `deploy` step polls against; a test replaces both. */
102
+ now(): number;
103
+ sleep(ms: number): Promise<void>;
104
+ }
105
+ export declare const spawnStepExec: StepExec;
106
+ export declare const defaultTemplateFetch: TemplateFetch;
107
+ /** The real three, each under the env overlay the `coolify` step builds. */
108
+ export declare const cloudCommands: CloudCommands;
109
+ /** `<app>.<HF_BASE_DOMAIN>` — the host Coolify serves and the passkey relying-party origin. */
110
+ export declare function appFqdn(context: CloudStepContext): string;
111
+ /** Runs a command in the app directory and throws on a non-zero exit. */
112
+ export declare function mustRun(context: CloudStepContext, command: string, args: readonly string[], options?: {
113
+ env?: Record<string, string>;
114
+ capture?: boolean;
115
+ }): Promise<StepExecOutcome>;
116
+ /** `HEAD`'s sha, or `undefined` when the directory has no commit yet — or no repository. */
117
+ export declare function gitHead(context: CloudStepContext): Promise<string | undefined>;
118
+ export declare function exists(target: string): Promise<boolean>;
119
+ export declare function firstLine(text: string): string;
120
+ export declare function short(sha: string): string;
@@ -0,0 +1,88 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { bootstrapApp } from "../bootstrap.js";
4
+ import { requireOperatorConfig } from "../config.js";
5
+ import { migrateApp } from "../migrate.js";
6
+ import { statusTokenApp } from "../status-token.js";
7
+ import { fetchTemplate } from "../template-source.js";
8
+ /**
9
+ * A step refused to act, or a command it ran failed.
10
+ *
11
+ * Carries no provider body and no environment: the only thing a step is allowed to say about a
12
+ * secret is that it has one. `ProviderError` keeps the same rule for the HTTP half.
13
+ */
14
+ export class StepFailed extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "StepFailed";
18
+ }
19
+ }
20
+ export const spawnStepExec = async (command, args, options) => {
21
+ const child = spawn(command, [...args], {
22
+ cwd: options.cwd,
23
+ env: options.env === undefined ? process.env : { ...process.env, ...options.env },
24
+ // `inherit` for a command whose output the operator is meant to watch — `pnpm install` is
25
+ // minutes long — and pipes only where a step reads the answer back.
26
+ stdio: options.capture === true ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
27
+ });
28
+ let stdout = "";
29
+ let stderr = "";
30
+ child.stdout?.on("data", (chunk) => {
31
+ stdout += chunk.toString("utf8");
32
+ });
33
+ child.stderr?.on("data", (chunk) => {
34
+ stderr += chunk.toString("utf8");
35
+ });
36
+ const code = await new Promise((resolve, reject) => {
37
+ child.once("error", reject);
38
+ child.once("close", (exitCode) => resolve(exitCode));
39
+ });
40
+ return { code, stdout, stderr };
41
+ };
42
+ export const defaultTemplateFetch = async (source, dir) => await fetchTemplate(source, dir);
43
+ /** The real three, each under the env overlay the `coolify` step builds. */
44
+ export const cloudCommands = {
45
+ // `skipRoles`: the database step created all three, and the cloud migrator cannot create one.
46
+ migrate: async ({ dir, env }) => {
47
+ await migrateApp({ dir, skipRoles: true, env });
48
+ },
49
+ bootstrap: async ({ dir, env, email, budgetUsd }) => {
50
+ await bootstrapApp({ dir, env, email, budgetUsd });
51
+ },
52
+ // `rotate`, because reaching this call at all means the state cache has no plaintext to reuse:
53
+ // whatever hash the column holds is one nothing can authenticate against any more.
54
+ statusToken: async ({ dir, env }) => (await statusTokenApp({ dir, env, kinds: ["read", "write"], rotate: true })).tokens,
55
+ };
56
+ /** `<app>.<HF_BASE_DOMAIN>` — the host Coolify serves and the passkey relying-party origin. */
57
+ export function appFqdn(context) {
58
+ const { HF_BASE_DOMAIN } = requireOperatorConfig(context.config, ["HF_BASE_DOMAIN"], {
59
+ env: context.env,
60
+ });
61
+ return `${context.names.given}.${HF_BASE_DOMAIN}`;
62
+ }
63
+ /** Runs a command in the app directory and throws on a non-zero exit. */
64
+ export async function mustRun(context, command, args, options = {}) {
65
+ const outcome = await context.exec(command, args, { cwd: context.dir, ...options });
66
+ if (outcome.code !== 0) {
67
+ throw new StepFailed(`${command} ${args.join(" ")} exited with code ${String(outcome.code)}` +
68
+ (outcome.stderr.trim() === "" ? "" : `: ${firstLine(outcome.stderr)}`));
69
+ }
70
+ return outcome;
71
+ }
72
+ /** `HEAD`'s sha, or `undefined` when the directory has no commit yet — or no repository. */
73
+ export async function gitHead(context) {
74
+ const outcome = await context.exec("git", ["rev-parse", "HEAD"], {
75
+ cwd: context.dir,
76
+ capture: true,
77
+ });
78
+ return outcome.code === 0 ? outcome.stdout.trim() : undefined;
79
+ }
80
+ export async function exists(target) {
81
+ return await access(target).then(() => true, () => false);
82
+ }
83
+ export function firstLine(text) {
84
+ return text.trim().split("\n")[0] ?? "";
85
+ }
86
+ export function short(sha) {
87
+ return sha.length > 7 ? sha.slice(0, 7) : sha;
88
+ }
@@ -0,0 +1,74 @@
1
+ import { type OperatorConfig } from "../config.js";
2
+ import type { Step } from "../new-cloud.js";
3
+ import { type CoolifyEnvironmentVariable } from "../providers/coolify.js";
4
+ import { StepFailed, type CloudStepContext } from "./context.js";
5
+ /** The Coolify environment every `hf new` application lives in; created with the project. */
6
+ export declare const COOLIFY_ENVIRONMENT = "production";
7
+ /** Where the build pack looks for the compose file, relative to the repository root. */
8
+ export declare const COMPOSE_LOCATION = "/docker-compose.prod.yml";
9
+ /** The compose file the drift assertion reads, under the app's directory. */
10
+ export declare const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
11
+ /**
12
+ * Set by compose itself rather than by us: `DOCKER_IMAGE` has a default in the `x-app` anchor and
13
+ * `SOURCE_COMMIT` is what Coolify's builder exports for the image tag and the build arg. Sending
14
+ * either as an application environment variable would override the deploy's own.
15
+ */
16
+ export declare const COMPOSE_PROVIDED_ENV: readonly ["DOCKER_IMAGE", "SOURCE_COMMIT"];
17
+ /** Written literally into every `environment:` block, so the deploy never carries them. */
18
+ export declare const CONTAINER_PROVIDED_ENV: readonly ["HF_PROCESS", "HF_BUILD_SHA"];
19
+ /**
20
+ * The two variables `hf new` omits when the operator has no key for them.
21
+ *
22
+ * An app with neither serves fixture drafts and says so on `/api/status` (`llm.mode`); an empty
23
+ * variable in Coolify's UI reads as configured, so the absence is the honest state and the
24
+ * checklist is what says it out loud.
25
+ */
26
+ export declare const OPTIONAL_PROVIDER_ENV: readonly ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
27
+ /**
28
+ * The app's environment and what `docker-compose.prod.yml` interpolates have diverged.
29
+ *
30
+ * Raised before the first Coolify request of the run, because the failure it prevents is silent: a
31
+ * variable the template added and nothing sent is an empty string in three containers, and only
32
+ * the one that reads it finds out.
33
+ */
34
+ export declare class EnvDrift extends StepFailed {
35
+ readonly missing: readonly string[];
36
+ readonly extra: readonly string[];
37
+ constructor(missing: readonly string[], extra: readonly string[]);
38
+ }
39
+ /** Every `${VAR}` a compose file interpolates, `${VAR:-default}` and `${VAR?err}` included. */
40
+ export declare function composeInterpolatedNames(contents: string): string[];
41
+ /**
42
+ * What the deployed app needs from its environment, read from the generated app itself.
43
+ *
44
+ * Both halves, because they fail differently: `.env.example` is the declared contract (and equals
45
+ * `REQUIRED_ENV`, which the template's own test enforces), while the compose interpolations are
46
+ * what actually reaches a container. A variable in one and not the other is drift too.
47
+ */
48
+ export declare function neededEnvNames(dir: string): Promise<string[]>;
49
+ /**
50
+ * Refuses to PATCH an environment that is not the one the compose file needs.
51
+ *
52
+ * A provider key the operator has not configured is deliberately absent rather than missing, so it
53
+ * is excused here and reported in the checklist instead.
54
+ */
55
+ export declare function assertEnvsMatchCompose(dir: string, sent: readonly string[]): Promise<void>;
56
+ /**
57
+ * The twelve variables the deployed app runs on, or ten when no provider key is configured.
58
+ *
59
+ * Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
60
+ * that no provider hands back, so a run that did not persist it would lock every existing session
61
+ * out on the next deploy.
62
+ */
63
+ export declare function buildAppEnvs(context: CloudStepContext): Promise<CoolifyEnvironmentVariable[]>;
64
+ /** The provider keys the operator configured, in `REQUIRED_ENV` order; absent ones are omitted. */
65
+ export declare function providerKeys(config: OperatorConfig): [string, string][];
66
+ /**
67
+ * The Coolify project, environment and application, the app's whole environment, and the three
68
+ * commands that have to run against the database before the first deploy.
69
+ *
70
+ * Every sub-action is found by name before it is created, and every uuid is recorded the moment
71
+ * the API hands it back, so a crash anywhere in here leaves a rerun with something to find rather
72
+ * than a second project beside the first.
73
+ */
74
+ export declare const coolifyStep: Step<CloudStepContext>;
@@ -0,0 +1,300 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { requireOperatorConfig } from "../config.js";
4
+ import { declaredNames } from "../env-file.js";
5
+ import { CoolifyClient } from "../providers/coolify.js";
6
+ import { generateBetterAuthSecret, secretsHash } from "../state.js";
7
+ import { appFqdn, StepFailed } from "./context.js";
8
+ /** The Coolify environment every `hf new` application lives in; created with the project. */
9
+ export const COOLIFY_ENVIRONMENT = "production";
10
+ /** Where the build pack looks for the compose file, relative to the repository root. */
11
+ export const COMPOSE_LOCATION = "/docker-compose.prod.yml";
12
+ /** The compose file the drift assertion reads, under the app's directory. */
13
+ export const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
14
+ /**
15
+ * Set by compose itself rather than by us: `DOCKER_IMAGE` has a default in the `x-app` anchor and
16
+ * `SOURCE_COMMIT` is what Coolify's builder exports for the image tag and the build arg. Sending
17
+ * either as an application environment variable would override the deploy's own.
18
+ */
19
+ export const COMPOSE_PROVIDED_ENV = ["DOCKER_IMAGE", "SOURCE_COMMIT"];
20
+ /** Written literally into every `environment:` block, so the deploy never carries them. */
21
+ export const CONTAINER_PROVIDED_ENV = ["HF_PROCESS", "HF_BUILD_SHA"];
22
+ /**
23
+ * The two variables `hf new` omits when the operator has no key for them.
24
+ *
25
+ * An app with neither serves fixture drafts and says so on `/api/status` (`llm.mode`); an empty
26
+ * variable in Coolify's UI reads as configured, so the absence is the honest state and the
27
+ * checklist is what says it out loud.
28
+ */
29
+ export const OPTIONAL_PROVIDER_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
30
+ /** Which operator key carries each of them, in `REQUIRED_ENV` order. */
31
+ const PROVIDER_ENV_SOURCE = [
32
+ ["ANTHROPIC_API_KEY", "HF_ANTHROPIC_API_KEY"],
33
+ ["OPENAI_API_KEY", "HF_OPENAI_API_KEY"],
34
+ ];
35
+ /** The operator config the environment itself is built out of. */
36
+ const REQUIRED_FOR_ENVS = [
37
+ "HF_COOLIFY_POSTGRES_UUID",
38
+ "HF_SMTP_URL",
39
+ "HF_EMAIL_FROM",
40
+ "HF_LANGFUSE_URL",
41
+ ];
42
+ /**
43
+ * The app's environment and what `docker-compose.prod.yml` interpolates have diverged.
44
+ *
45
+ * Raised before the first Coolify request of the run, because the failure it prevents is silent: a
46
+ * variable the template added and nothing sent is an empty string in three containers, and only
47
+ * the one that reads it finds out.
48
+ */
49
+ export class EnvDrift extends StepFailed {
50
+ missing;
51
+ extra;
52
+ constructor(missing, extra) {
53
+ super(`the app's environment has drifted from ${PROD_COMPOSE_FILE}: ` +
54
+ [
55
+ missing.length === 0 ? "" : `${missing.join(", ")} needed but not sent`,
56
+ extra.length === 0 ? "" : `${extra.join(", ")} sent but not needed`,
57
+ ]
58
+ .filter((part) => part !== "")
59
+ .join("; ") +
60
+ ". Nothing was sent to Coolify — reconcile .env.example, REQUIRED_ENV and every " +
61
+ "environment: block first.");
62
+ this.name = "EnvDrift";
63
+ this.missing = missing;
64
+ this.extra = extra;
65
+ }
66
+ }
67
+ /** Every `${VAR}` a compose file interpolates, `${VAR:-default}` and `${VAR?err}` included. */
68
+ export function composeInterpolatedNames(contents) {
69
+ const names = new Set();
70
+ for (const match of contents.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/g)) {
71
+ names.add(match[1]);
72
+ }
73
+ return [...names];
74
+ }
75
+ /**
76
+ * What the deployed app needs from its environment, read from the generated app itself.
77
+ *
78
+ * Both halves, because they fail differently: `.env.example` is the declared contract (and equals
79
+ * `REQUIRED_ENV`, which the template's own test enforces), while the compose interpolations are
80
+ * what actually reaches a container. A variable in one and not the other is drift too.
81
+ */
82
+ export async function neededEnvNames(dir) {
83
+ const declared = declaredNames(await readFile(path.join(dir, ".env.example"), "utf8"));
84
+ const interpolated = composeInterpolatedNames(await readFile(path.join(dir, PROD_COMPOSE_FILE), "utf8"));
85
+ const provided = new Set([...COMPOSE_PROVIDED_ENV, ...CONTAINER_PROVIDED_ENV]);
86
+ return [...new Set([...declared, ...interpolated])].filter((name) => !provided.has(name));
87
+ }
88
+ /**
89
+ * Refuses to PATCH an environment that is not the one the compose file needs.
90
+ *
91
+ * A provider key the operator has not configured is deliberately absent rather than missing, so it
92
+ * is excused here and reported in the checklist instead.
93
+ */
94
+ export async function assertEnvsMatchCompose(dir, sent) {
95
+ const needed = await neededEnvNames(dir);
96
+ const sentNames = new Set(sent);
97
+ const optional = new Set(OPTIONAL_PROVIDER_ENV);
98
+ const missing = needed.filter((name) => !sentNames.has(name) && !optional.has(name));
99
+ const extra = sent.filter((name) => !needed.includes(name));
100
+ if (missing.length > 0 || extra.length > 0)
101
+ throw new EnvDrift(missing, extra);
102
+ }
103
+ /**
104
+ * The twelve variables the deployed app runs on, or ten when no provider key is configured.
105
+ *
106
+ * Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
107
+ * that no provider hands back, so a run that did not persist it would lock every existing session
108
+ * out on the next deploy.
109
+ */
110
+ export async function buildAppEnvs(context) {
111
+ const config = requireOperatorConfig(context.config, REQUIRED_FOR_ENVS, { env: context.env });
112
+ const { names } = context;
113
+ const database = context.state.state.database ?? {};
114
+ if (database.applicationPassword === undefined || database.migratorPassword === undefined) {
115
+ throw new StepFailed("no database passwords in the state cache: the database step has not run for this app");
116
+ }
117
+ if (context.state.state.betterAuthSecret === undefined) {
118
+ await context.state.patch({ betterAuthSecret: generateBetterAuthSecret() });
119
+ }
120
+ // The hostname the app's containers reach Postgres by, which is not the one the tunnel uses.
121
+ // Coolify's own compose generator names the container after the database's uuid;
122
+ // `HF_DB_HOST_INTERNAL` is how a box that disagrees says so.
123
+ const host = context.config.HF_DB_HOST_INTERNAL ?? config.HF_COOLIFY_POSTGRES_UUID;
124
+ const langfuse = context.state.state.langfuse ?? {};
125
+ const envs = [
126
+ {
127
+ key: "DATABASE_URL",
128
+ value: internalUrl(names.applicationRole, database.applicationPassword, host, names.databaseName),
129
+ },
130
+ {
131
+ key: "MIGRATOR_DATABASE_URL",
132
+ value: internalUrl(names.migratorRole, database.migratorPassword, host, names.databaseName),
133
+ },
134
+ { key: "APP_URL", value: `https://${appFqdn(context)}` },
135
+ { key: "BETTER_AUTH_SECRET", value: context.state.state.betterAuthSecret ?? "" },
136
+ { key: "SMTP_URL", value: config.HF_SMTP_URL },
137
+ { key: "EMAIL_FROM", value: config.HF_EMAIL_FROM },
138
+ { key: "SENTRY_DSN", value: context.state.state.sentryDsn ?? "" },
139
+ { key: "LANGFUSE_BASE_URL", value: config.HF_LANGFUSE_URL },
140
+ { key: "LANGFUSE_PUBLIC_KEY", value: langfuse.publicKey ?? "" },
141
+ { key: "LANGFUSE_SECRET_KEY", value: langfuse.secretKey ?? "" },
142
+ ];
143
+ for (const [key, value] of providerKeys(context.config))
144
+ envs.push({ key, value });
145
+ return envs;
146
+ }
147
+ /** The provider keys the operator configured, in `REQUIRED_ENV` order; absent ones are omitted. */
148
+ export function providerKeys(config) {
149
+ const pairs = [];
150
+ for (const [name, source] of PROVIDER_ENV_SOURCE) {
151
+ const value = config[source];
152
+ if (value !== undefined && value !== "")
153
+ pairs.push([name, value]);
154
+ }
155
+ return pairs;
156
+ }
157
+ /**
158
+ * The Coolify project, environment and application, the app's whole environment, and the three
159
+ * commands that have to run against the database before the first deploy.
160
+ *
161
+ * Every sub-action is found by name before it is created, and every uuid is recorded the moment
162
+ * the API hands it back, so a crash anywhere in here leaves a rerun with something to find rather
163
+ * than a second project beside the first.
164
+ */
165
+ export const coolifyStep = {
166
+ name: "coolify",
167
+ run: async (context) => {
168
+ const { state, names } = context;
169
+ const required = requireOperatorConfig(context.config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN"], { env: context.env });
170
+ const coolify = new CoolifyClient({
171
+ url: required.HF_COOLIFY_URL,
172
+ token: required.HF_COOLIFY_TOKEN,
173
+ fetch: context.fetch,
174
+ });
175
+ // Before the first request, so drift costs nothing but the message.
176
+ const envs = await buildAppEnvs(context);
177
+ await assertEnvsMatchCompose(context.dir, envs.map((env) => env.key));
178
+ const projectUuid = await findOrCreateProject(context, coolify);
179
+ const environmentUuid = await productionEnvironment(context, coolify, projectUuid);
180
+ const appUuid = await findOrCreateApplication(context, coolify, projectUuid, environmentUuid);
181
+ // Unconditionally, every time this step runs. The step is recorded only while
182
+ // `coolify.envsSecretsHash` still matches the state's secrets, so a rotation has already made
183
+ // the runner forget it — which means "not done" and "the secrets moved" both land here.
184
+ await coolify.updateEnvsBulk(appUuid, envs);
185
+ context.io.out(`${names.given}: ${String(envs.length)} environment variable(s) set in Coolify`);
186
+ await runThroughTunnel(context, envs);
187
+ // Last, not straight after the PATCH: `status-token` mints secrets that `secretsHash` covers,
188
+ // so a hash recorded before it would be stale the moment this step returned.
189
+ await state.patch({ coolify: { envsSecretsHash: secretsHash(state.state) } });
190
+ },
191
+ };
192
+ async function findOrCreateProject(context, coolify) {
193
+ const name = context.names.given;
194
+ const existing = (await coolify.listProjects()).find((project) => project.name === name);
195
+ if (existing !== undefined) {
196
+ await context.state.patch({ coolify: { projectUuid: existing.uuid } });
197
+ return existing.uuid;
198
+ }
199
+ const created = await coolify.createProject({
200
+ name,
201
+ description: `hyperfixation app ${name}`,
202
+ });
203
+ await context.state.patch({ coolify: { projectUuid: created.uuid } });
204
+ context.io.out(`${name}: created the Coolify project ${name}`);
205
+ return created.uuid;
206
+ }
207
+ async function productionEnvironment(context, coolify, projectUuid) {
208
+ const environments = await coolify.listEnvironments(projectUuid);
209
+ const production = environments.find((environment) => environment.name === COOLIFY_ENVIRONMENT);
210
+ if (production === undefined) {
211
+ throw new StepFailed(`the Coolify project ${context.names.given} has no ${COOLIFY_ENVIRONMENT} environment ` +
212
+ `(it has ${environments.map((environment) => environment.name).join(", ") || "none"}), ` +
213
+ "and the API has no endpoint that creates one: add it in Coolify and re-run hf new");
214
+ }
215
+ return production.uuid;
216
+ }
217
+ async function findOrCreateApplication(context, coolify, projectUuid, environmentUuid) {
218
+ const name = context.names.given;
219
+ const existing = (await coolify.listApplications()).find((application) => application.name === name);
220
+ if (existing !== undefined) {
221
+ await context.state.patch({ coolify: { appUuid: existing.uuid } });
222
+ return existing.uuid;
223
+ }
224
+ const repo = context.state.state.repo;
225
+ if (repo === undefined) {
226
+ throw new StepFailed("no owner/name repository in the state cache: the repo step has not run for this app");
227
+ }
228
+ const required = requireOperatorConfig(context.config, ["HF_COOLIFY_SERVER_UUID", "HF_COOLIFY_GITHUB_APP_UUID"], { env: context.env });
229
+ const fqdn = appFqdn(context);
230
+ const created = await coolify.createPrivateGithubAppApplication({
231
+ project_uuid: projectUuid,
232
+ server_uuid: required.HF_COOLIFY_SERVER_UUID,
233
+ environment_name: COOLIFY_ENVIRONMENT,
234
+ environment_uuid: environmentUuid,
235
+ github_app_uuid: required.HF_COOLIFY_GITHUB_APP_UUID,
236
+ git_repository: repo,
237
+ git_branch: "main",
238
+ build_pack: "dockercompose",
239
+ docker_compose_location: COMPOSE_LOCATION,
240
+ connect_to_docker_network: true,
241
+ name,
242
+ domains: `https://${fqdn}`,
243
+ // The `deploy` step is what deploys, once the environment is set and the database migrated.
244
+ instant_deploy: false,
245
+ });
246
+ await context.state.patch({ coolify: { appUuid: created.uuid } });
247
+ context.io.out(`${name}: created the Coolify application at https://${fqdn}`);
248
+ return created.uuid;
249
+ }
250
+ /**
251
+ * `migrate`, `bootstrap` and `status-token` against the new database, through the E2 tunnel.
252
+ *
253
+ * The children get the app's own environment with the two connection URLs pointed at the tunnel —
254
+ * never a `.env`, and never this laptop's: an operator with their own `DATABASE_URL` exported would
255
+ * otherwise have the tokens provisioned into a dev database.
256
+ */
257
+ async function runThroughTunnel(context, envs) {
258
+ const { names } = context;
259
+ const database = await context.database();
260
+ const local = database.adminUrl(names.databaseName);
261
+ if (local === undefined) {
262
+ throw new StepFailed(`migrate, bootstrap and status-token cannot run over the ${database.kind} transport: each ` +
263
+ "is a pg client and needs an address. Publish the Coolify Postgres port on the box's " +
264
+ "loopback so the tunnel works.");
265
+ }
266
+ const stored = context.state.state.database ?? {};
267
+ const overlay = {};
268
+ for (const env of envs)
269
+ overlay[env.key] = env.value;
270
+ overlay.DATABASE_URL = asRole(local, names.applicationRole, stored.applicationPassword ?? "");
271
+ overlay.MIGRATOR_DATABASE_URL = asRole(local, names.migratorRole, stored.migratorPassword ?? "");
272
+ // The roles already exist — the database step created them — and the migrator role in the cloud
273
+ // cannot create one anyway.
274
+ await context.commands.migrate({ dir: context.dir, env: overlay });
275
+ context.io.out(`${names.given}: migrated ${names.databaseName}`);
276
+ await context.commands.bootstrap({
277
+ dir: context.dir,
278
+ env: overlay,
279
+ email: context.email,
280
+ budgetUsd: context.budgetUsd,
281
+ });
282
+ context.io.out(`${names.given}: bootstrapped ${context.email} with a $${context.budgetUsd} budget`);
283
+ // The state file is the only copy of the plaintext, so "already minted" means "in the state",
284
+ // not "hashed in the database" — and a rerun that finds a hash it has no plaintext for has to
285
+ // replace it, which is what `rotate` is for. Nothing else holds either token.
286
+ if (context.state.state.statusTokens?.read === undefined) {
287
+ const tokens = await context.commands.statusToken({ dir: context.dir, env: overlay });
288
+ await context.state.patch({ statusTokens: tokens });
289
+ context.io.out(`${names.given}: minted the /api/status read and write tokens`);
290
+ }
291
+ }
292
+ function internalUrl(role, password, host, databaseName) {
293
+ return `postgres://${encodeURIComponent(role)}:${encodeURIComponent(password)}@${host}:5432/${databaseName}`;
294
+ }
295
+ function asRole(url, role, password) {
296
+ const parsed = new URL(url);
297
+ parsed.username = encodeURIComponent(role);
298
+ parsed.password = encodeURIComponent(password);
299
+ return parsed.toString();
300
+ }
@@ -0,0 +1,12 @@
1
+ import type { Step } from "../new-cloud.js";
2
+ import type { CloudStepContext } from "./context.js";
3
+ /**
4
+ * `hf_<app>`, its extensions and its three roles, immediately before `coolify`.
5
+ *
6
+ * Immediately before on purpose. This is the one step that rotates — a run with no password in the
7
+ * state cache gives an existing role a new one — and a deployed app keeps the old password until
8
+ * `coolify` PATCHes the environment and `deploy` restarts the containers. Everything fallible
9
+ * therefore happens earlier, and `rotated` is what refuses to call a run that stopped in between
10
+ * finished.
11
+ */
12
+ export declare const databaseStep: Step<CloudStepContext>;
@@ -0,0 +1,25 @@
1
+ import { provisionDatabase } from "../provision-database.js";
2
+ /**
3
+ * `hf_<app>`, its extensions and its three roles, immediately before `coolify`.
4
+ *
5
+ * Immediately before on purpose. This is the one step that rotates — a run with no password in the
6
+ * state cache gives an existing role a new one — and a deployed app keeps the old password until
7
+ * `coolify` PATCHes the environment and `deploy` restarts the containers. Everything fallible
8
+ * therefore happens earlier, and `rotated` is what refuses to call a run that stopped in between
9
+ * finished.
10
+ */
11
+ export const databaseStep = {
12
+ name: "database",
13
+ run: async (context) => {
14
+ const { names } = context;
15
+ const result = await provisionDatabase(await context.database(), {
16
+ app: names.given,
17
+ state: context.state,
18
+ });
19
+ if (result.rotated)
20
+ context.rotated = true;
21
+ context.io.out(`${names.given}: ${result.createdDatabase ? "created" : "found"} ${result.databaseName}, ` +
22
+ `roles ${result.roles.migrator}, ${result.roles.application}, ${result.roles.readonly}` +
23
+ (result.rotated ? " (passwords rotated — Coolify and a redeploy follow)" : ""));
24
+ },
25
+ };
@@ -0,0 +1,18 @@
1
+ import type { Step } from "../new-cloud.js";
2
+ import { type CloudStepContext } from "./context.js";
3
+ /**
4
+ * How long the step waits for Coolify to build and for the app to report the sha it built.
5
+ *
6
+ * One deadline for both halves: what the operator is waiting on is a deployed app answering with
7
+ * the right version, and a build that took fourteen minutes has not left time for anything else.
8
+ */
9
+ export declare const DEPLOY_TIMEOUT_MS: number;
10
+ /**
11
+ * Deploy, wait for the build, then wait for the app to say it is running that commit.
12
+ *
13
+ * Never skipped while it is not recorded, and recorded only once `/api/status` reports the pushed
14
+ * sha: a redeploy costs a rebuild, whereas a `deploy` marked done off the API's own "finished"
15
+ * would hide a container that came up on the previous image — which is exactly what a rotation
16
+ * needs this step to rule out.
17
+ */
18
+ export declare const deployStep: Step<CloudStepContext>;