@hyperfixation/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/app.d.ts +15 -2
  2. package/dist/app.js +4 -2
  3. package/dist/backup-source.d.ts +47 -0
  4. package/dist/backup-source.js +107 -0
  5. package/dist/bootstrap.d.ts +2 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/checklist.d.ts +25 -0
  8. package/dist/checklist.js +32 -0
  9. package/dist/cli.d.ts +2 -2
  10. package/dist/cli.js +95 -2
  11. package/dist/cloud-steps/backup.d.ts +17 -0
  12. package/dist/cloud-steps/backup.js +40 -0
  13. package/dist/cloud-steps/context.d.ts +120 -0
  14. package/dist/cloud-steps/context.js +88 -0
  15. package/dist/cloud-steps/coolify.d.ts +84 -0
  16. package/dist/cloud-steps/coolify.js +316 -0
  17. package/dist/cloud-steps/database.d.ts +12 -0
  18. package/dist/cloud-steps/database.js +25 -0
  19. package/dist/cloud-steps/deploy.d.ts +18 -0
  20. package/dist/cloud-steps/deploy.js +110 -0
  21. package/dist/cloud-steps/dns.d.ts +11 -0
  22. package/dist/cloud-steps/dns.js +53 -0
  23. package/dist/cloud-steps/index.d.ts +21 -0
  24. package/dist/cloud-steps/index.js +30 -0
  25. package/dist/cloud-steps/install.d.ts +12 -0
  26. package/dist/cloud-steps/install.js +53 -0
  27. package/dist/cloud-steps/langfuse.d.ts +17 -0
  28. package/dist/cloud-steps/langfuse.js +71 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +198 -0
  31. package/dist/cloud-steps/sentry.d.ts +13 -0
  32. package/dist/cloud-steps/sentry.js +55 -0
  33. package/dist/cloud-steps/template.d.ts +22 -0
  34. package/dist/cloud-steps/template.js +68 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +192 -0
  37. package/dist/database.d.ts +95 -0
  38. package/dist/database.js +226 -0
  39. package/dist/doctor.d.ts +72 -0
  40. package/dist/doctor.js +368 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/migrate.d.ts +11 -0
  44. package/dist/migrate.js +26 -2
  45. package/dist/new-cloud.d.ts +135 -0
  46. package/dist/new-cloud.js +219 -0
  47. package/dist/new.d.ts +2 -0
  48. package/dist/new.js +2 -1
  49. package/dist/providers/cloudflare.d.ts +49 -0
  50. package/dist/providers/cloudflare.js +27 -0
  51. package/dist/providers/coolify.d.ts +148 -0
  52. package/dist/providers/coolify.js +87 -0
  53. package/dist/providers/github.d.ts +117 -0
  54. package/dist/providers/github.js +98 -0
  55. package/dist/providers/http.d.ts +41 -0
  56. package/dist/providers/http.js +56 -0
  57. package/dist/providers/langfuse.d.ts +41 -0
  58. package/dist/providers/langfuse.js +29 -0
  59. package/dist/providers/sentry.d.ts +31 -0
  60. package/dist/providers/sentry.js +27 -0
  61. package/dist/provision-database.d.ts +42 -0
  62. package/dist/provision-database.js +107 -0
  63. package/dist/restore-check.d.ts +91 -0
  64. package/dist/restore-check.js +262 -0
  65. package/dist/runner.d.ts +72 -0
  66. package/dist/runner.js +221 -0
  67. package/dist/secret-file.d.ts +30 -0
  68. package/dist/secret-file.js +69 -0
  69. package/dist/state.d.ts +124 -0
  70. package/dist/state.js +217 -0
  71. package/dist/status-token.d.ts +2 -0
  72. package/dist/status-token.js +1 -1
  73. package/dist/template-source.d.ts +23 -0
  74. package/dist/template-source.js +23 -0
  75. package/package.json +10 -7
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,135 @@
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
+ * The keys a cloud `hf new` runs without: three have a default derived from another key, the two
80
+ * provider keys are what the checklist warns about when they are unset, and the three Langfuse
81
+ * keys are three ways of configuring one step — an org key, a project key pair, or neither, which
82
+ * the step degrades to a warning and a checklist line.
83
+ */
84
+ export declare const OPTIONAL_CLOUD_CONFIG: readonly ConfigKey[];
85
+ /**
86
+ * Every operator config key a cloud `hf new` needs, checked before the first step.
87
+ *
88
+ * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
89
+ * and an operator who learns about them one failed step at a time pays for a half-provisioned app
90
+ * each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
91
+ * what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
92
+ * ten steps between them read every key there is, so the required set is the complement of the
93
+ * optional one, and a key added for a step is required the moment it is named.
94
+ */
95
+ export declare const REQUIRED_CLOUD_CONFIG: readonly ConfigKey[];
96
+ export interface NewAppCloudOptions {
97
+ name: string;
98
+ /** Required in the cloud: a deployed app never starts under a cap nobody chose. */
99
+ budgetUsd: string;
100
+ /** Required in the cloud: there is no prompt and no `.env` to carry it. */
101
+ email: string;
102
+ from?: string;
103
+ /** Where `<name>` is created. Defaults to the working directory. */
104
+ into?: string;
105
+ io: {
106
+ out(line: string): void;
107
+ };
108
+ config?: OperatorConfig;
109
+ env?: NodeJS.ProcessEnv;
110
+ /** Where the per-app state files are. Defaults to `stateDir()`. */
111
+ stateDir?: string;
112
+ /** Replaces `CLOUD_STEPS`; the tests run a shorter list, never a different order. */
113
+ steps?: readonly Step<CloudStepContext>[];
114
+ commands?: CloudCommands;
115
+ runner?: Runner;
116
+ /** The cluster admin the database is provisioned as. Defaults to `postgres`/`PGPASSWORD`. */
117
+ clusterAdmin?: AdminCredentials;
118
+ fetch?: FetchLike;
119
+ now?: () => number;
120
+ sleep?: (ms: number) => Promise<void>;
121
+ }
122
+ export interface NewAppCloudResult extends RunStepsResult {
123
+ dir: string;
124
+ fqdn: string;
125
+ /** What the operator still has to do, ready to print. */
126
+ checklist: readonly string[];
127
+ }
128
+ /**
129
+ * `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
130
+ *
131
+ * Nothing here is interactive and nothing is prompted for — this runs against five APIs and a box
132
+ * — so every input is a flag or a config key, and a missing one is reported before the first
133
+ * request rather than half way through.
134
+ */
135
+ export declare function newAppCloud(options: NewAppCloudOptions): Promise<NewAppCloudResult>;
@@ -0,0 +1,219 @@
1
+ import path from "node:path";
2
+ import { checklistLines } from "./checklist.js";
3
+ import { providerKeys } from "./cloud-steps/coolify.js";
4
+ import { cloudCommands, CLOUD_STEPS, defaultTemplateFetch, spawnStepExec, } from "./cloud-steps/index.js";
5
+ import { CONFIG_KEYS, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
6
+ import { openDatabase } from "./database.js";
7
+ import { deriveNames } from "./names.js";
8
+ import { createSshRunner } from "./runner.js";
9
+ import { openAppState, secretsHash, STEPS, } from "./state.js";
10
+ /** The steps themselves; the runner is what orders and records them. */
11
+ export { CLOUD_STEPS };
12
+ /**
13
+ * The steps whose "done" depends on more than having run once.
14
+ *
15
+ * Both carry the app's secrets into the deployment — `coolify` PATCHes them as environment
16
+ * variables, `deploy` is what makes the containers read them — so a run that rotated a password
17
+ * has to redo both, even though a previous run recorded them.
18
+ */
19
+ const SECRET_CARRYING_STEPS = ["coolify", "deploy"];
20
+ /** The runner found state it will not act on: the operator has to be told, not worked around. */
21
+ export class StepInvariantViolated extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "StepInvariantViolated";
25
+ }
26
+ }
27
+ /**
28
+ * True when the secrets the Coolify envs were last PATCHed with are the ones the state holds now.
29
+ *
30
+ * A `coolify` step that has run but recorded no hash counts as stale: re-PATCHing is idempotent
31
+ * and cheap, and the alternative is trusting a file written before this field existed.
32
+ */
33
+ export function envsAreCurrent(state) {
34
+ return state.coolify?.envsSecretsHash === secretsHash(state);
35
+ }
36
+ /**
37
+ * Refuses a recorded state whose deployment is authenticating with secrets that no longer exist.
38
+ *
39
+ * Silent otherwise, including for an app that has not reached `coolify` yet: a run that stops
40
+ * early is resumable, whereas a `coolify` recorded against passwords since rotated is a deployed
41
+ * app locked out of its own database with nothing left to notice it.
42
+ */
43
+ export function assertEnvsCurrent(state) {
44
+ if (state.steps.coolify === undefined || envsAreCurrent(state))
45
+ return;
46
+ throw new StepInvariantViolated("the app's secrets have changed since its Coolify environment was set: re-run hf new to " +
47
+ "PATCH the environment and redeploy (nothing was rolled back)");
48
+ }
49
+ /**
50
+ * Refuses to finish a run that rotated a password without the Coolify environment catching up.
51
+ *
52
+ * The state-only check above cannot see this case on its own: a list of steps that stops before
53
+ * `coolify` leaves a consistent file and a deployed app on dead credentials.
54
+ */
55
+ export function assertRotationApplied(context) {
56
+ if (!context.rotated)
57
+ return;
58
+ if (context.state.isDone("coolify") && envsAreCurrent(context.state.state))
59
+ return;
60
+ throw new StepInvariantViolated("this run rotated the app's database passwords but never PATCHed them into Coolify: the " +
61
+ "deployed app still holds the old ones. Re-run hf new to finish the rotation.");
62
+ }
63
+ /**
64
+ * Forgets the steps that carried secrets the app no longer has, and reports which.
65
+ *
66
+ * Done once, before the first step, and by clearing the records rather than by ignoring them:
67
+ * `coolify` makes the hash current again the moment it re-runs, so a per-step test would then
68
+ * count `deploy` as done and leave the containers reading the previous environment. Clearing also
69
+ * survives a crash in between — the next run sees the same two steps missing.
70
+ */
71
+ export async function invalidateStaleSecretSteps(state) {
72
+ if (envsAreCurrent(state.state))
73
+ return [];
74
+ const cleared = [];
75
+ for (const name of SECRET_CARRYING_STEPS) {
76
+ if (!state.isDone(name))
77
+ continue;
78
+ await state.clearDone(name);
79
+ cleared.push(name);
80
+ }
81
+ return cleared;
82
+ }
83
+ /**
84
+ * Runs the steps of a cloud `hf new` in order, skipping what a previous run finished.
85
+ *
86
+ * A step is marked done only after its `run` resolves, so a failure leaves the step unrecorded
87
+ * and the next run repeats it — the one direction that is safe, since repeating a create costs a
88
+ * duplicate at worst while recording one that never happened costs an app nobody can finish.
89
+ * Errors propagate untouched: the caller prints them, and the state file is the resume point.
90
+ */
91
+ export async function runSteps(steps, context) {
92
+ assertStepOrder(steps);
93
+ const invalidated = await invalidateStaleSecretSteps(context.state);
94
+ const ran = [];
95
+ const skipped = [];
96
+ for (const step of steps) {
97
+ if (context.state.isDone(step.name)) {
98
+ skipped.push(step.name);
99
+ continue;
100
+ }
101
+ await step.run(context);
102
+ await context.state.markDone(step.name);
103
+ ran.push(step.name);
104
+ }
105
+ assertEnvsCurrent(context.state.state);
106
+ assertRotationApplied(context);
107
+ return { ran, skipped, invalidated };
108
+ }
109
+ /**
110
+ * The keys a cloud `hf new` runs without: three have a default derived from another key, the two
111
+ * provider keys are what the checklist warns about when they are unset, and the three Langfuse
112
+ * keys are three ways of configuring one step — an org key, a project key pair, or neither, which
113
+ * the step degrades to a warning and a checklist line.
114
+ */
115
+ export const OPTIONAL_CLOUD_CONFIG = [
116
+ "HF_DB_HOST_INTERNAL",
117
+ "HF_DB_CONTAINER",
118
+ "HF_PG_ADMIN_USER",
119
+ "HF_ANTHROPIC_API_KEY",
120
+ "HF_OPENAI_API_KEY",
121
+ "HF_LANGFUSE_ORG_KEY",
122
+ "HF_LANGFUSE_PUBLIC_KEY",
123
+ "HF_LANGFUSE_SECRET_KEY",
124
+ ];
125
+ /**
126
+ * Every operator config key a cloud `hf new` needs, checked before the first step.
127
+ *
128
+ * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
129
+ * and an operator who learns about them one failed step at a time pays for a half-provisioned app
130
+ * each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
131
+ * what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
132
+ * ten steps between them read every key there is, so the required set is the complement of the
133
+ * optional one, and a key added for a step is required the moment it is named.
134
+ */
135
+ export const REQUIRED_CLOUD_CONFIG = CONFIG_KEYS.filter((key) => !OPTIONAL_CLOUD_CONFIG.includes(key));
136
+ /**
137
+ * `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
138
+ *
139
+ * Nothing here is interactive and nothing is prompted for — this runs against five APIs and a box
140
+ * — so every input is a flag or a config key, and a missing one is reported before the first
141
+ * request rather than half way through.
142
+ */
143
+ export async function newAppCloud(options) {
144
+ const env = options.env ?? process.env;
145
+ const config = options.config ?? (await loadOperatorConfig({ env }));
146
+ const required = requireOperatorConfig(config, REQUIRED_CLOUD_CONFIG, { env });
147
+ const names = deriveNames(options.name);
148
+ const state = await openAppState(names.given, { dir: options.stateDir, env });
149
+ const runner = options.runner ?? createSshRunner({ host: required.HF_SSH_HOST });
150
+ // `PGPASSWORD` is libpq's own name for it, and the same place `hf restore-check` reads it:
151
+ // Coolify's cluster password is not an hf config key, because nothing of ours should hold it.
152
+ const clusterAdmin = options.clusterAdmin ?? { user: pgAdminUser(config), password: env.PGPASSWORD };
153
+ let database;
154
+ const hadWriteToken = state.state.statusTokens?.write !== undefined;
155
+ const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
156
+ const context = {
157
+ state,
158
+ rotated: false,
159
+ names,
160
+ dir: path.resolve(options.into ?? process.cwd(), names.given),
161
+ config,
162
+ env,
163
+ io: options.io,
164
+ checklist: [],
165
+ exec: spawnStepExec,
166
+ from: options.from,
167
+ fetchTemplate: defaultTemplateFetch,
168
+ fetch: options.fetch,
169
+ email: options.email,
170
+ budgetUsd: options.budgetUsd,
171
+ database: async () => {
172
+ // The container is named so the tunnel can discover its address, but no `dockerExec`: that
173
+ // transport has no address, and every use of the cluster here — `provisionRoles`, the
174
+ // migrator, the tokens — is a pg client.
175
+ database ??= await openDatabase(runner, {
176
+ admin: clusterAdmin,
177
+ containers: postgresContainers(config),
178
+ });
179
+ return database;
180
+ },
181
+ commands: options.commands ?? cloudCommands,
182
+ now: options.now ?? (() => Date.now()),
183
+ sleep: options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms))),
184
+ };
185
+ let result;
186
+ try {
187
+ result = await runSteps(options.steps ?? CLOUD_STEPS, context);
188
+ }
189
+ finally {
190
+ await database?.close();
191
+ }
192
+ const checklist = checklistLines({
193
+ names,
194
+ fqdn,
195
+ repo: state.state.repo,
196
+ dbHost: config.HF_DB_HOST_INTERNAL ?? required.HF_COOLIFY_POSTGRES_UUID,
197
+ stateFile: state.file,
198
+ providerKeysSent: providerKeys(config).map(([key]) => key),
199
+ // Whatever the steps themselves asked the operator to look at — the backup schedule included,
200
+ // which is the one thing Coolify's API cannot be asked about.
201
+ fromSteps: context.checklist,
202
+ // Shown once means the run that minted it; every later run leaves it in the state file alone.
203
+ writeToken: hadWriteToken ? undefined : state.state.statusTokens?.write,
204
+ });
205
+ return { ...result, dir: context.dir, fqdn, checklist };
206
+ }
207
+ /**
208
+ * `STEPS`' order is the rotation-safety argument — every fallible create before `database`, and
209
+ * `coolify` straight after it — so a caller that assembles its list in another order is a bug
210
+ * here rather than a stranded app on the box.
211
+ */
212
+ function assertStepOrder(steps) {
213
+ const positions = steps.map((step) => STEPS.indexOf(step.name));
214
+ for (let index = 1; index < positions.length; index += 1) {
215
+ if (positions[index] <= positions[index - 1]) {
216
+ throw new StepInvariantViolated(`steps out of order: ${steps[index - 1].name} before ${steps[index].name}`);
217
+ }
218
+ }
219
+ }
package/dist/new.d.ts CHANGED
@@ -55,3 +55,5 @@ export declare function newApp(options: NewAppOptions): Promise<NewAppResult>;
55
55
  /** The placeholder map. Exported because `hf check` reports a leftover placeholder by name. */
56
56
  export declare function placeholders(names: AppNames): Record<string, string>;
57
57
  export declare function substitute(contents: string, names: AppNames): string;
58
+ /** Substitutes every placeholder in a copied tree; the cloud `template` step reuses this one. */
59
+ export declare function substituteTree(dir: string, names: AppNames): Promise<string[]>;
package/dist/new.js CHANGED
@@ -115,7 +115,8 @@ async function assertTemplateSource(source) {
115
115
  throw new TemplateError(`${source} has no ${TEMPLATE_MARKER}: it is not a hyperfixation template checkout`);
116
116
  }
117
117
  }
118
- async function substituteTree(dir, names) {
118
+ /** Substitutes every placeholder in a copied tree; the cloud `template` step reuses this one. */
119
+ export async function substituteTree(dir, names) {
119
120
  const changed = [];
120
121
  for (const file of await walk(dir)) {
121
122
  if (BINARY_EXTENSIONS.has(path.extname(file)))
@@ -0,0 +1,49 @@
1
+ import { type FetchLike } from "./http.js";
2
+ export declare const CLOUDFLARE_API_URL = "https://api.cloudflare.com/client/v4";
3
+ export interface CloudflareDnsRecord {
4
+ id: string;
5
+ type: string;
6
+ name: string;
7
+ content: string;
8
+ proxied: boolean;
9
+ ttl: number;
10
+ }
11
+ /** Cloudflare wraps every answer; `success: false` still arrives as HTTP 200 on some routes. */
12
+ export interface CloudflareEnvelope<Result> {
13
+ success: boolean;
14
+ errors: {
15
+ code: number;
16
+ message: string;
17
+ }[];
18
+ result: Result;
19
+ }
20
+ /**
21
+ * The one record `hf new` writes: `A <app>.<HF_BASE_DOMAIN>` → `HF_BOX_IP`, DNS-only.
22
+ *
23
+ * `proxied: false` is the decided domain scheme — Coolify's proxy terminates TLS, and a
24
+ * proxied record would both need Full-strict and hide the box from Coolify's ACME challenge.
25
+ */
26
+ export interface CloudflareARecord {
27
+ type: "A";
28
+ name: string;
29
+ content: string;
30
+ ttl: number;
31
+ proxied: boolean;
32
+ comment?: string;
33
+ }
34
+ export interface CloudflareClientOptions {
35
+ /** `HF_CLOUDFLARE_TOKEN`. */
36
+ token: string;
37
+ /** Overridden only by tests; the real API has one address. */
38
+ url?: string;
39
+ fetch?: FetchLike;
40
+ }
41
+ export declare class CloudflareClient {
42
+ private readonly request;
43
+ constructor(options: CloudflareClientOptions);
44
+ listDnsRecords(zoneId: string, filters?: {
45
+ name?: string;
46
+ type?: string;
47
+ }): Promise<CloudflareEnvelope<CloudflareDnsRecord[]>>;
48
+ createDnsRecord(zoneId: string, record: CloudflareARecord): Promise<CloudflareEnvelope<CloudflareDnsRecord>>;
49
+ }
@@ -0,0 +1,27 @@
1
+ import { createTransport, segment } from "./http.js";
2
+ export const CLOUDFLARE_API_URL = "https://api.cloudflare.com/client/v4";
3
+ export class CloudflareClient {
4
+ request;
5
+ constructor(options) {
6
+ this.request = createTransport({
7
+ provider: "cloudflare",
8
+ baseUrl: options.url ?? CLOUDFLARE_API_URL,
9
+ headers: { authorization: `Bearer ${options.token}` },
10
+ fetch: options.fetch,
11
+ });
12
+ }
13
+ async listDnsRecords(zoneId, filters = {}) {
14
+ return await this.request({
15
+ method: "GET",
16
+ path: `/zones/${segment(zoneId)}/dns_records`,
17
+ query: { name: filters.name, type: filters.type },
18
+ });
19
+ }
20
+ async createDnsRecord(zoneId, record) {
21
+ return await this.request({
22
+ method: "POST",
23
+ path: `/zones/${segment(zoneId)}/dns_records`,
24
+ body: record,
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,148 @@
1
+ import { type FetchLike } from "./http.js";
2
+ export interface CoolifyProject {
3
+ uuid: string;
4
+ name: string;
5
+ description?: string;
6
+ }
7
+ /** Coolify's environment model carries a uuid the documented schema does not list. */
8
+ export interface CoolifyEnvironment {
9
+ uuid: string;
10
+ name: string;
11
+ }
12
+ /**
13
+ * The application `hf new` creates: a docker-compose build pack on the app's own private
14
+ * repository, reached through the Coolify GitHub App.
15
+ *
16
+ * `environment_uuid` and `environment_name` are both required by the API document even though
17
+ * its prose says either will do — so both are required here, and the uuid is what
18
+ * `listEnvironments` is for.
19
+ */
20
+ export interface CoolifyApplicationRequest {
21
+ project_uuid: string;
22
+ server_uuid: string;
23
+ environment_name: string;
24
+ environment_uuid: string;
25
+ github_app_uuid: string;
26
+ /** `owner/name`, as Coolify stores it for a GitHub App source. */
27
+ git_repository: string;
28
+ git_branch: string;
29
+ build_pack: "nixpacks" | "railpack" | "static" | "dockerfile" | "dockercompose";
30
+ name?: string;
31
+ description?: string;
32
+ /** Comma-separated; the app's `https://<name>.<HF_BASE_DOMAIN>`. */
33
+ domains?: string;
34
+ ports_exposes?: string;
35
+ docker_compose_location?: string;
36
+ connect_to_docker_network?: boolean;
37
+ instant_deploy?: boolean;
38
+ is_auto_deploy_enabled?: boolean;
39
+ }
40
+ export interface CoolifyApplication {
41
+ uuid: string;
42
+ }
43
+ /** What `GET /applications` returns per item; the document's `Application` has both of these. */
44
+ export interface CoolifyApplicationSummary {
45
+ uuid: string;
46
+ name: string;
47
+ fqdn?: string;
48
+ }
49
+ /**
50
+ * What `PATCH /databases/{uuid}` accepts of the fields E2 might need.
51
+ *
52
+ * Narrow on purpose: the document lists every engine's credentials, and nothing in it attaches a
53
+ * database to a docker network — `is_public`/`public_port` is the only reach the API has over how
54
+ * a database is addressed.
55
+ */
56
+ export interface CoolifyDatabaseUpdate {
57
+ name?: string;
58
+ description?: string;
59
+ is_public?: boolean;
60
+ public_port?: number;
61
+ }
62
+ export interface CoolifyEnvironmentVariable {
63
+ key: string;
64
+ value: string;
65
+ is_preview?: boolean;
66
+ is_literal?: boolean;
67
+ is_multiline?: boolean;
68
+ is_shown_once?: boolean;
69
+ }
70
+ export interface CoolifyDeploymentRequest {
71
+ deployments: {
72
+ message: string;
73
+ resource_uuid: string;
74
+ deployment_uuid: string;
75
+ }[];
76
+ }
77
+ export interface CoolifyDeployment {
78
+ deployment_uuid: string;
79
+ /** `queued` | `in_progress` | `finished` | `failed` | `cancelled-by-user`, as Coolify sets it. */
80
+ status: string;
81
+ commit?: string;
82
+ logs?: string;
83
+ }
84
+ export interface CoolifyBackupRequest {
85
+ /** A cron expression, or one of `every_minute`…`yearly`. */
86
+ frequency: string;
87
+ enabled?: boolean;
88
+ save_s3?: boolean;
89
+ s3_storage_uuid?: string;
90
+ /** Comma-separated database names — the app's `hf_<name>`, not the whole cluster. */
91
+ databases_to_backup?: string;
92
+ dump_all?: boolean;
93
+ backup_now?: boolean;
94
+ database_backup_retention_amount_locally?: number;
95
+ database_backup_retention_days_locally?: number;
96
+ }
97
+ export interface CoolifyBackup {
98
+ uuid: string;
99
+ message?: string;
100
+ }
101
+ export interface CoolifyClientOptions {
102
+ /** `HF_COOLIFY_URL` — the instance's origin, without `/api/v1`. */
103
+ url: string;
104
+ /** `HF_COOLIFY_TOKEN`. */
105
+ token: string;
106
+ fetch?: FetchLike;
107
+ }
108
+ export declare class CoolifyClient {
109
+ private readonly request;
110
+ constructor(options: CoolifyClientOptions);
111
+ createProject(body: {
112
+ name: string;
113
+ description?: string;
114
+ }): Promise<{
115
+ uuid: string;
116
+ }>;
117
+ /** Every project on the instance; `hf new` finds its own by name rather than creating a second. */
118
+ listProjects(): Promise<CoolifyProject[]>;
119
+ getProject(uuid: string): Promise<CoolifyProject>;
120
+ listEnvironments(projectUuid: string): Promise<CoolifyEnvironment[]>;
121
+ createPrivateGithubAppApplication(body: CoolifyApplicationRequest): Promise<CoolifyApplication>;
122
+ /**
123
+ * Every application, so a rerun can find the one it created last time by name.
124
+ *
125
+ * The state cache is the first place to look for the uuid; this is what answers the case where
126
+ * the application exists but the state file does not, which is a cold run against a live app.
127
+ */
128
+ listApplications(options?: {
129
+ tag?: string;
130
+ }): Promise<CoolifyApplicationSummary[]>;
131
+ updateEnvsBulk(appUuid: string, data: readonly CoolifyEnvironmentVariable[]): Promise<unknown>;
132
+ deploy(uuid: string, options?: {
133
+ force?: boolean;
134
+ }): Promise<CoolifyDeploymentRequest>;
135
+ getDeployment(deploymentUuid: string): Promise<CoolifyDeployment>;
136
+ createDatabaseBackup(databaseUuid: string, body: CoolifyBackupRequest): Promise<CoolifyBackup>;
137
+ /**
138
+ * The database's scheduled backups.
139
+ *
140
+ * `unknown`, not a model: upstream documents this response as a string whose example reads
141
+ * "Content is very complex. Will be implemented later.", so there is nothing to type against.
142
+ * A caller that needs a field has to narrow it against the box itself.
143
+ */
144
+ listDatabaseBackups(databaseUuid: string): Promise<unknown>;
145
+ /** The database as Coolify holds it — undocumented in shape, same as the backups list. */
146
+ getDatabase(uuid: string): Promise<unknown>;
147
+ updateDatabase(uuid: string, body: CoolifyDatabaseUpdate): Promise<unknown>;
148
+ }