@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,210 @@
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 { loadOperatorConfig, 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
+ * Every operator config key a cloud `hf new` needs, checked before the first step.
111
+ *
112
+ * All at once, and before anything is created: `requireOperatorConfig` names every missing key,
113
+ * and an operator who learns about them one failed step at a time pays for a half-provisioned app
114
+ * each time. The optional keys are deliberately absent — `HF_DB_HOST_INTERNAL` has a default, and
115
+ * the two provider keys are what the checklist warns about when they are unset.
116
+ */
117
+ export const REQUIRED_CLOUD_CONFIG = [
118
+ "HF_COOLIFY_URL",
119
+ "HF_COOLIFY_TOKEN",
120
+ "HF_COOLIFY_SERVER_UUID",
121
+ "HF_COOLIFY_GITHUB_APP_UUID",
122
+ "HF_COOLIFY_POSTGRES_UUID",
123
+ "HF_SSH_HOST",
124
+ "HF_BASE_DOMAIN",
125
+ "HF_SMTP_URL",
126
+ "HF_EMAIL_FROM",
127
+ "HF_LANGFUSE_URL",
128
+ ];
129
+ /** The cluster role `hf new` provisions the app's database and roles as. */
130
+ const CLUSTER_ADMIN_USER = "postgres";
131
+ /**
132
+ * `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
133
+ *
134
+ * Nothing here is interactive and nothing is prompted for — this runs against five APIs and a box
135
+ * — so every input is a flag or a config key, and a missing one is reported before the first
136
+ * request rather than half way through.
137
+ */
138
+ export async function newAppCloud(options) {
139
+ const env = options.env ?? process.env;
140
+ const config = options.config ?? (await loadOperatorConfig({ env }));
141
+ const required = requireOperatorConfig(config, REQUIRED_CLOUD_CONFIG, { env });
142
+ const names = deriveNames(options.name);
143
+ const state = await openAppState(names.given, { dir: options.stateDir, env });
144
+ const runner = options.runner ?? createSshRunner({ host: required.HF_SSH_HOST });
145
+ // `PGPASSWORD` is libpq's own name for it, and the same place `hf restore-check` reads it:
146
+ // Coolify's cluster password is not an hf config key, because nothing of ours should hold it.
147
+ const clusterAdmin = options.clusterAdmin ?? { user: CLUSTER_ADMIN_USER, password: env.PGPASSWORD };
148
+ let database;
149
+ const hadWriteToken = state.state.statusTokens?.write !== undefined;
150
+ const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
151
+ const context = {
152
+ state,
153
+ rotated: false,
154
+ names,
155
+ dir: path.resolve(options.into ?? process.cwd(), names.given),
156
+ config,
157
+ env,
158
+ io: options.io,
159
+ checklist: [],
160
+ exec: spawnStepExec,
161
+ from: options.from,
162
+ fetchTemplate: defaultTemplateFetch,
163
+ fetch: options.fetch,
164
+ email: options.email,
165
+ budgetUsd: options.budgetUsd,
166
+ database: async () => {
167
+ // No `container`: the `docker exec psql` transport has no address, and every use of the
168
+ // cluster here — `provisionRoles`, the migrator, the tokens — is a pg client.
169
+ database ??= await openDatabase(runner, { admin: clusterAdmin });
170
+ return database;
171
+ },
172
+ commands: options.commands ?? cloudCommands,
173
+ now: options.now ?? (() => Date.now()),
174
+ sleep: options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms))),
175
+ };
176
+ let result;
177
+ try {
178
+ result = await runSteps(options.steps ?? CLOUD_STEPS, context);
179
+ }
180
+ finally {
181
+ await database?.close();
182
+ }
183
+ const checklist = checklistLines({
184
+ names,
185
+ fqdn,
186
+ repo: state.state.repo,
187
+ dbHost: config.HF_DB_HOST_INTERNAL ?? required.HF_COOLIFY_POSTGRES_UUID,
188
+ stateFile: state.file,
189
+ providerKeysSent: providerKeys(config).map(([key]) => key),
190
+ // Whatever the steps themselves asked the operator to look at — the backup schedule included,
191
+ // which is the one thing Coolify's API cannot be asked about.
192
+ fromSteps: context.checklist,
193
+ // Shown once means the run that minted it; every later run leaves it in the state file alone.
194
+ writeToken: hadWriteToken ? undefined : state.state.statusTokens?.write,
195
+ });
196
+ return { ...result, dir: context.dir, fqdn, checklist };
197
+ }
198
+ /**
199
+ * `STEPS`' order is the rotation-safety argument — every fallible create before `database`, and
200
+ * `coolify` straight after it — so a caller that assembles its list in another order is a bug
201
+ * here rather than a stranded app on the box.
202
+ */
203
+ function assertStepOrder(steps) {
204
+ const positions = steps.map((step) => STEPS.indexOf(step.name));
205
+ for (let index = 1; index < positions.length; index += 1) {
206
+ if (positions[index] <= positions[index - 1]) {
207
+ throw new StepInvariantViolated(`steps out of order: ${steps[index - 1].name} before ${steps[index].name}`);
208
+ }
209
+ }
210
+ }
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
+ }
@@ -0,0 +1,87 @@
1
+ import { createTransport, segment } from "./http.js";
2
+ export class CoolifyClient {
3
+ request;
4
+ constructor(options) {
5
+ this.request = createTransport({
6
+ provider: "coolify",
7
+ baseUrl: `${options.url.replace(/\/+$/, "")}/api/v1`,
8
+ headers: { authorization: `Bearer ${options.token}` },
9
+ fetch: options.fetch,
10
+ });
11
+ }
12
+ async createProject(body) {
13
+ return await this.request({ method: "POST", path: "/projects", body });
14
+ }
15
+ /** Every project on the instance; `hf new` finds its own by name rather than creating a second. */
16
+ async listProjects() {
17
+ return await this.request({ method: "GET", path: "/projects" });
18
+ }
19
+ async getProject(uuid) {
20
+ return await this.request({ method: "GET", path: `/projects/${segment(uuid)}` });
21
+ }
22
+ async listEnvironments(projectUuid) {
23
+ return await this.request({
24
+ method: "GET",
25
+ path: `/projects/${segment(projectUuid)}/environments`,
26
+ });
27
+ }
28
+ async createPrivateGithubAppApplication(body) {
29
+ return await this.request({ method: "POST", path: "/applications/private-github-app", body });
30
+ }
31
+ /**
32
+ * Every application, so a rerun can find the one it created last time by name.
33
+ *
34
+ * The state cache is the first place to look for the uuid; this is what answers the case where
35
+ * the application exists but the state file does not, which is a cold run against a live app.
36
+ */
37
+ async listApplications(options = {}) {
38
+ return await this.request({ method: "GET", path: "/applications", query: { tag: options.tag } });
39
+ }
40
+ async updateEnvsBulk(appUuid, data) {
41
+ return await this.request({
42
+ method: "PATCH",
43
+ path: `/applications/${segment(appUuid)}/envs/bulk`,
44
+ body: { data },
45
+ });
46
+ }
47
+ async deploy(uuid, options = {}) {
48
+ return await this.request({
49
+ method: "POST",
50
+ path: "/deploy",
51
+ query: { uuid, force: options.force },
52
+ });
53
+ }
54
+ async getDeployment(deploymentUuid) {
55
+ return await this.request({
56
+ method: "GET",
57
+ path: `/deployments/${segment(deploymentUuid)}`,
58
+ });
59
+ }
60
+ async createDatabaseBackup(databaseUuid, body) {
61
+ return await this.request({
62
+ method: "POST",
63
+ path: `/databases/${segment(databaseUuid)}/backups`,
64
+ body,
65
+ });
66
+ }
67
+ /**
68
+ * The database's scheduled backups.
69
+ *
70
+ * `unknown`, not a model: upstream documents this response as a string whose example reads
71
+ * "Content is very complex. Will be implemented later.", so there is nothing to type against.
72
+ * A caller that needs a field has to narrow it against the box itself.
73
+ */
74
+ async listDatabaseBackups(databaseUuid) {
75
+ return await this.request({
76
+ method: "GET",
77
+ path: `/databases/${segment(databaseUuid)}/backups`,
78
+ });
79
+ }
80
+ /** The database as Coolify holds it — undocumented in shape, same as the backups list. */
81
+ async getDatabase(uuid) {
82
+ return await this.request({ method: "GET", path: `/databases/${segment(uuid)}` });
83
+ }
84
+ async updateDatabase(uuid, body) {
85
+ return await this.request({ method: "PATCH", path: `/databases/${segment(uuid)}`, body });
86
+ }
87
+ }
@@ -0,0 +1,117 @@
1
+ import { type FetchLike } from "./http.js";
2
+ export declare const GITHUB_API_URL = "https://api.github.com";
3
+ export interface GithubRepository {
4
+ full_name: string;
5
+ clone_url: string;
6
+ default_branch: string;
7
+ private: boolean;
8
+ }
9
+ export interface GithubReference {
10
+ ref: string;
11
+ object: {
12
+ sha: string;
13
+ type: string;
14
+ };
15
+ }
16
+ export interface GithubPullRequest {
17
+ number: number;
18
+ title: string;
19
+ html_url: string;
20
+ head: {
21
+ ref: string;
22
+ sha: string;
23
+ };
24
+ }
25
+ export interface GithubCombinedStatus {
26
+ /** `success` | `pending` | `failure`. */
27
+ state: string;
28
+ total_count: number;
29
+ }
30
+ /** `GET /users/{username}`, for the one question `hf new` asks of it: account or organization. */
31
+ export interface GithubUser {
32
+ login: string;
33
+ /** `User` | `Organization`; which of the two repository-creation endpoints applies. */
34
+ type: string;
35
+ }
36
+ export interface GithubInstallation {
37
+ id: number;
38
+ app_id: number;
39
+ /** The slug `HF_GITHUB_APP_SLUGS` names — Coolify's app, and the bump bot's. */
40
+ app_slug: string;
41
+ }
42
+ export interface GithubInstallations {
43
+ total_count: number;
44
+ installations: GithubInstallation[];
45
+ }
46
+ export interface GithubInstallationRepositories {
47
+ total_count: number;
48
+ repository_selection?: string;
49
+ repositories: GithubRepository[];
50
+ }
51
+ export interface GithubRepositoryRequest {
52
+ name: string;
53
+ description?: string;
54
+ private?: boolean;
55
+ auto_init?: boolean;
56
+ }
57
+ export interface GithubClientOptions {
58
+ /** `HF_GITHUB_TOKEN`. */
59
+ token: string;
60
+ url?: string;
61
+ fetch?: FetchLike;
62
+ }
63
+ export declare class GithubClient {
64
+ private readonly request;
65
+ constructor(options: GithubClientOptions);
66
+ /**
67
+ * Who `HF_GITHUB_OWNER` is: `type` decides between `/user/repos` and `/orgs/{org}/repos`.
68
+ *
69
+ * Unauthenticated-shaped data on purpose — this endpoint answers for any account, so it is the
70
+ * cheapest way to settle the question without assuming the token owns the name.
71
+ */
72
+ getUser(username: string): Promise<GithubUser>;
73
+ /**
74
+ * The repository, when there may already be one.
75
+ *
76
+ * A 404 from here is "no such repository **for this token**": the same status covers absent and
77
+ * invisible, so a caller that means to create one must treat it as "create and let the create
78
+ * fail" rather than as proof the name is free.
79
+ */
80
+ getRepository(owner: string, repo: string): Promise<GithubRepository>;
81
+ /** The repository for an app whose `HF_GITHUB_OWNER` is the token's own account. */
82
+ createUserRepository(body: GithubRepositoryRequest): Promise<GithubRepository>;
83
+ /** The same, when `HF_GITHUB_OWNER` is an organization instead. */
84
+ createOrgRepository(org: string, body: GithubRepositoryRequest): Promise<GithubRepository>;
85
+ /**
86
+ * `hf doctor`'s idea of what the repository says is deployed. `ref` is `heads/main`, and is
87
+ * not percent-encoded: GitHub spells this parameter with the slash as a path separator.
88
+ */
89
+ getReference(owner: string, repo: string, ref: string): Promise<GithubReference>;
90
+ /**
91
+ * Open pull requests, unfiltered.
92
+ *
93
+ * `hf doctor` wants the `core-bump/` ones, but GitHub's `head` filter takes a whole
94
+ * `user:branch`, not a prefix, so the prefix match belongs to the caller.
95
+ */
96
+ listPullRequests(owner: string, repo: string, options?: {
97
+ state?: "open" | "closed" | "all";
98
+ per_page?: number;
99
+ }): Promise<GithubPullRequest[]>;
100
+ /**
101
+ * The GitHub Apps installed for the token's user, with their slugs.
102
+ *
103
+ * `hf new` asserts both `HF_GITHUB_APP_SLUGS` entries are installed on the new repository —
104
+ * Coolify cannot deploy from a repository its app cannot see, and that failure otherwise
105
+ * surfaces as a deploy that clones nothing.
106
+ */
107
+ listInstallations(options?: {
108
+ per_page?: number;
109
+ page?: number;
110
+ }): Promise<GithubInstallations>;
111
+ /** Which repositories one installation actually reaches; the other half of that assertion. */
112
+ listInstallationRepositories(installationId: number, options?: {
113
+ per_page?: number;
114
+ page?: number;
115
+ }): Promise<GithubInstallationRepositories>;
116
+ getCombinedStatus(owner: string, repo: string, ref: string): Promise<GithubCombinedStatus>;
117
+ }