@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.
- package/dist/app.d.ts +15 -2
- package/dist/app.js +4 -2
- package/dist/backup-source.d.ts +47 -0
- package/dist/backup-source.js +107 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +1 -1
- package/dist/checklist.d.ts +25 -0
- package/dist/checklist.js +32 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +95 -2
- package/dist/cloud-steps/backup.d.ts +17 -0
- package/dist/cloud-steps/backup.js +40 -0
- package/dist/cloud-steps/context.d.ts +120 -0
- package/dist/cloud-steps/context.js +88 -0
- package/dist/cloud-steps/coolify.d.ts +84 -0
- package/dist/cloud-steps/coolify.js +316 -0
- package/dist/cloud-steps/database.d.ts +12 -0
- package/dist/cloud-steps/database.js +25 -0
- package/dist/cloud-steps/deploy.d.ts +18 -0
- package/dist/cloud-steps/deploy.js +110 -0
- package/dist/cloud-steps/dns.d.ts +11 -0
- package/dist/cloud-steps/dns.js +53 -0
- package/dist/cloud-steps/index.d.ts +21 -0
- package/dist/cloud-steps/index.js +30 -0
- package/dist/cloud-steps/install.d.ts +12 -0
- package/dist/cloud-steps/install.js +53 -0
- package/dist/cloud-steps/langfuse.d.ts +17 -0
- package/dist/cloud-steps/langfuse.js +71 -0
- package/dist/cloud-steps/repo.d.ts +20 -0
- package/dist/cloud-steps/repo.js +198 -0
- package/dist/cloud-steps/sentry.d.ts +13 -0
- package/dist/cloud-steps/sentry.js +55 -0
- package/dist/cloud-steps/template.d.ts +22 -0
- package/dist/cloud-steps/template.js +68 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +192 -0
- package/dist/database.d.ts +95 -0
- package/dist/database.js +226 -0
- package/dist/doctor.d.ts +72 -0
- package/dist/doctor.js +368 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +26 -2
- package/dist/new-cloud.d.ts +135 -0
- package/dist/new-cloud.js +219 -0
- package/dist/new.d.ts +2 -0
- package/dist/new.js +2 -1
- package/dist/providers/cloudflare.d.ts +49 -0
- package/dist/providers/cloudflare.js +27 -0
- package/dist/providers/coolify.d.ts +148 -0
- package/dist/providers/coolify.js +87 -0
- package/dist/providers/github.d.ts +117 -0
- package/dist/providers/github.js +98 -0
- package/dist/providers/http.d.ts +41 -0
- package/dist/providers/http.js +56 -0
- package/dist/providers/langfuse.d.ts +41 -0
- package/dist/providers/langfuse.js +29 -0
- package/dist/providers/sentry.d.ts +31 -0
- package/dist/providers/sentry.js +27 -0
- package/dist/provision-database.d.ts +42 -0
- package/dist/provision-database.js +107 -0
- package/dist/restore-check.d.ts +91 -0
- package/dist/restore-check.js +262 -0
- package/dist/runner.d.ts +72 -0
- package/dist/runner.js +221 -0
- package/dist/secret-file.d.ts +30 -0
- package/dist/secret-file.js +69 -0
- package/dist/state.d.ts +124 -0
- package/dist/state.js +217 -0
- package/dist/status-token.d.ts +2 -0
- package/dist/status-token.js +1 -1
- package/dist/template-source.d.ts +23 -0
- package/dist/template-source.js +23 -0
- 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:
|
|
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
|
-
|
|
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
|
+
}
|