@hyperfixation/cli 0.1.5 → 0.1.6

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/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up", "doctor", "restore-check"];
1
+ export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up", "deploy", "doctor", "restore-check"];
2
2
  export type Command = (typeof COMMANDS)[number];
3
- export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> provision the app in the cloud: fetch the template, push a private\n repo, register a backup, Sentry, Langfuse and DNS, create the\n database and its roles, create the Coolify application and its\n environment, deploy, and print what is left to do by hand.\n Resumable \u2014 a rerun repeats only what did not finish\n --budget-usd <amount> the app's monthly LLM budget (required; no default)\n --email <address> the bootstrap admin's address (required)\n --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf doctor [name] every deployed app in the state cache, or one: /api/status under its\n read token, the deployed version against main, E006 as the app role,\n the last restore check, and open core-bump PRs. Exits 1 on any finding\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\n hf restore-check <name> restore the newest hf_<name> dump beside the live database and\n compare row counts; exits 1 on any mismatch\n --backup-dir <dir> where the dumps are (default: Coolify's on the box)\n --from-s3 read the dump from object storage (not implemented)\n\nEvery command but `new`, `doctor` and `restore-check` runs against the app at or above the working directory, or --dir.\n";
3
+ export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> provision the app in the cloud: fetch the template, push a private\n repo, register a backup, Sentry, Langfuse and DNS, create the\n database and its roles, create the Coolify application and its\n environment, deploy, and print what is left to do by hand.\n Resumable \u2014 a rerun repeats only what did not finish\n --budget-usd <amount> the app's monthly LLM budget (required; no default)\n --email <address> the bootstrap admin's address (required)\n --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf deploy <name> set SOURCE_COMMIT to a commit, deploy it, and wait until /api/status\n reports it. Coolify's push auto-deploy is off, so this is what\n publishes a merge to main\n --sha <sha> the commit to deploy (default: main's, via git ls-remote)\n\n hf doctor [name] every deployed app in the state cache, or one: /api/status under its\n read token, the deployed version against main, E006 as the app role,\n the last restore check, and open core-bump PRs. Exits 1 on any finding\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\n hf restore-check <name> restore the newest hf_<name> dump beside the live database and\n compare row counts; exits 1 on any mismatch\n --backup-dir <dir> where the dumps are (default: Coolify's on the box)\n --from-s3 read the dump from object storage (not implemented)\n\nEvery command but `new`, `deploy`, `doctor` and `restore-check` runs against the app at or above the working directory, or --dir.\n";
4
4
  export interface Io {
5
5
  out(line: string): void;
6
6
  err(line: string): void;
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { bootstrapApp } from "./bootstrap.js";
3
3
  import { checkApp } from "./check.js";
4
+ import { deployApp } from "./deploy-app.js";
4
5
  import { dev, devBuildSha } from "./dev.js";
5
6
  import { doctor, doctorLines } from "./doctor.js";
6
7
  import { generate } from "./gen.js";
@@ -20,6 +21,7 @@ export const COMMANDS = [
20
21
  "gen",
21
22
  "dev",
22
23
  "up",
24
+ "deploy",
23
25
  "doctor",
24
26
  "restore-check",
25
27
  ];
@@ -60,6 +62,11 @@ export const USAGE = `hf — the hyperfixation CLI
60
62
 
61
63
  hf check declared env, pending migrations, and E001-E006
62
64
 
65
+ hf deploy <name> set SOURCE_COMMIT to a commit, deploy it, and wait until /api/status
66
+ reports it. Coolify's push auto-deploy is off, so this is what
67
+ publishes a merge to main
68
+ --sha <sha> the commit to deploy (default: main's, via git ls-remote)
69
+
63
70
  hf doctor [name] every deployed app in the state cache, or one: /api/status under its
64
71
  read token, the deployed version against main, E006 as the app role,
65
72
  the last restore check, and open core-bump PRs. Exits 1 on any finding
@@ -75,7 +82,7 @@ export const USAGE = `hf — the hyperfixation CLI
75
82
  --backup-dir <dir> where the dumps are (default: Coolify's on the box)
76
83
  --from-s3 read the dump from object storage (not implemented)
77
84
 
78
- Every command but \`new\`, \`doctor\` and \`restore-check\` runs against the app at or above the working directory, or --dir.
85
+ Every command but \`new\`, \`deploy\`, \`doctor\` and \`restore-check\` runs against the app at or above the working directory, or --dir.
79
86
  `;
80
87
  const consoleIo = {
81
88
  out: (line) => console.log(line),
@@ -125,6 +132,8 @@ async function dispatch(command, argv, io) {
125
132
  return await commandDev(argv, io);
126
133
  case "up":
127
134
  return await commandUp(argv, io);
135
+ case "deploy":
136
+ return await commandDeploy(argv, io);
128
137
  case "doctor":
129
138
  return await commandDoctor(argv, io);
130
139
  case "restore-check":
@@ -275,6 +284,21 @@ async function commandCheck(argv, io) {
275
284
  io.err(`${finding.code}: ${finding.message}`);
276
285
  return 1;
277
286
  }
287
+ async function commandDeploy(argv, io) {
288
+ const { values, positionals } = parseArgs({
289
+ args: [...argv],
290
+ options: { sha: { type: "string" } },
291
+ allowPositionals: true,
292
+ });
293
+ const name = positionals[0];
294
+ if (name === undefined) {
295
+ io.err("hf deploy needs a name: hf deploy <name> [--sha <sha>]");
296
+ return 1;
297
+ }
298
+ // Every progress line the deploy prints — including the last one — is the command's output.
299
+ await deployApp({ app: name, sha: values.sha, io });
300
+ return 0;
301
+ }
278
302
  async function commandDoctor(argv, io) {
279
303
  const { positionals } = parseArgs({ args: [...argv], allowPositionals: true });
280
304
  const result = await doctor({ name: positionals[0] });
@@ -19,9 +19,12 @@ export declare const COMPOSE_DOMAIN_SERVICE = "web";
19
19
  /** The compose file the drift assertion reads, under the app's directory. */
20
20
  export declare const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
21
21
  /**
22
- * Set by compose itself rather than by us: `DOCKER_IMAGE` has a default in the `x-app` anchor and
23
- * `SOURCE_COMMIT` is what Coolify's builder exports for the image tag and the build arg. Sending
24
- * either as an application environment variable would override the deploy's own.
22
+ * Interpolated by the compose file but never part of this step's bulk PATCH.
23
+ *
24
+ * `DOCKER_IMAGE` has a default in the `x-app` anchor. `SOURCE_COMMIT` is written by the `deploy`
25
+ * step instead, once per deployment and against the commit being deployed — it is not one of the
26
+ * app's secrets, so it belongs neither in `secretsHash` nor in a PATCH that a rotation replays.
27
+ * Both are excused here rather than counted as drift.
25
28
  */
26
29
  export declare const COMPOSE_PROVIDED_ENV: readonly ["DOCKER_IMAGE", "SOURCE_COMMIT"];
27
30
  /** Written literally into every `environment:` block, so the deploy never carries them. */
@@ -22,9 +22,12 @@ export const COMPOSE_DOMAIN_SERVICE = "web";
22
22
  /** The compose file the drift assertion reads, under the app's directory. */
23
23
  export const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
24
24
  /**
25
- * Set by compose itself rather than by us: `DOCKER_IMAGE` has a default in the `x-app` anchor and
26
- * `SOURCE_COMMIT` is what Coolify's builder exports for the image tag and the build arg. Sending
27
- * either as an application environment variable would override the deploy's own.
25
+ * Interpolated by the compose file but never part of this step's bulk PATCH.
26
+ *
27
+ * `DOCKER_IMAGE` has a default in the `x-app` anchor. `SOURCE_COMMIT` is written by the `deploy`
28
+ * step instead, once per deployment and against the commit being deployed — it is not one of the
29
+ * app's secrets, so it belongs neither in `secretsHash` nor in a PATCH that a rotation replays.
30
+ * Both are excused here rather than counted as drift.
28
31
  */
29
32
  export const COMPOSE_PROVIDED_ENV = ["DOCKER_IMAGE", "SOURCE_COMMIT"];
30
33
  /** Written literally into every `environment:` block, so the deploy never carries them. */
@@ -267,6 +270,10 @@ async function findOrCreateApplication(context, coolify, projectUuid, environmen
267
270
  docker_compose_domains: [{ name: COMPOSE_DOMAIN_SERVICE, domain: `https://${fqdn}` }],
268
271
  // The `deploy` step is what deploys, once the environment is set and the database migrated.
269
272
  instant_deploy: false,
273
+ // Coolify's default is true: a push to main would then deploy whatever arrived against
274
+ // whatever `SOURCE_COMMIT` happened to hold, and the app would report a version it is not
275
+ // running. Every deploy goes through `hf` — `hf new`'s tenth step, or `hf deploy <name>`.
276
+ is_auto_deploy_enabled: false,
270
277
  });
271
278
  await context.state.patch({ coolify: { appUuid: created.uuid } });
272
279
  context.io.out(`${name}: created the Coolify application at https://${fqdn}`);
@@ -1,5 +1,7 @@
1
1
  import type { Step } from "../new-cloud.js";
2
- import { type CloudStepContext } from "./context.js";
2
+ import { CoolifyClient } from "../providers/coolify.js";
3
+ import type { FetchLike } from "../providers/http.js";
4
+ import { type CloudStepContext, type StepOut } from "./context.js";
3
5
  /**
4
6
  * How long the step waits for Coolify to build and for the app to report the sha it built.
5
7
  *
@@ -7,6 +9,41 @@ import { type CloudStepContext } from "./context.js";
7
9
  * the right version, and a build that took fourteen minutes has not left time for anything else.
8
10
  */
9
11
  export declare const DEPLOY_TIMEOUT_MS: number;
12
+ /**
13
+ * The compose variable that carries the deployed commit, and the one channel there is for it.
14
+ *
15
+ * Coolify's docker-compose build pack passes the commit into nothing: no `--build-arg`, nothing
16
+ * in the compose environment, and no `.git` in the build context even with
17
+ * `is_preserve_repository_enabled` — so the Dockerfile's `git rev-parse HEAD` fallback cannot run
18
+ * there either, and the worker dies at startup on an unresolved `HF_BUILD_SHA` (verified on the
19
+ * box, 4.3.21). What Coolify *does* do is materialise an application environment entry for every
20
+ * `${VAR}` the compose file interpolates, which is what this fills in before every deploy.
21
+ */
22
+ export declare const SOURCE_COMMIT_ENV = "SOURCE_COMMIT";
23
+ /** One deployment of one commit: everything `deployCommit` needs that a step context also has. */
24
+ export interface DeployTarget {
25
+ /** The app as the state cache names it — the prefix on every line this prints. */
26
+ name: string;
27
+ appUuid: string;
28
+ fqdn: string;
29
+ /** The exact commit being deployed; `/api/status` has to report this one. */
30
+ sha: string;
31
+ /** `/api/status`'s read token, which is the only way to ask what the app is running. */
32
+ readToken: string;
33
+ coolify: CoolifyClient;
34
+ io: StepOut;
35
+ now(): number;
36
+ sleep(ms: number): Promise<void>;
37
+ fetch?: FetchLike;
38
+ }
39
+ /**
40
+ * `SOURCE_COMMIT`, then the deploy, then the wait — in that order and never another.
41
+ *
42
+ * The environment write is what the build reads, so a deploy triggered first would build the
43
+ * previous commit's tag and the wait would then time out against a version the app is right to
44
+ * report. Both `hf new`'s tenth step and `hf deploy` come through here for that reason.
45
+ */
46
+ export declare function deployCommit(target: DeployTarget): Promise<void>;
10
47
  /**
11
48
  * Deploy, wait for the build, then wait for the app to say it is running that commit.
12
49
  *
@@ -12,6 +12,37 @@ const FIRST_POLL_MS = 2_000;
12
12
  const MAX_POLL_MS = 15_000;
13
13
  /** Coolify's one terminal success; `failed` and `cancelled-*` are the terminal failures. */
14
14
  const FINISHED = "finished";
15
+ /**
16
+ * The compose variable that carries the deployed commit, and the one channel there is for it.
17
+ *
18
+ * Coolify's docker-compose build pack passes the commit into nothing: no `--build-arg`, nothing
19
+ * in the compose environment, and no `.git` in the build context even with
20
+ * `is_preserve_repository_enabled` — so the Dockerfile's `git rev-parse HEAD` fallback cannot run
21
+ * there either, and the worker dies at startup on an unresolved `HF_BUILD_SHA` (verified on the
22
+ * box, 4.3.21). What Coolify *does* do is materialise an application environment entry for every
23
+ * `${VAR}` the compose file interpolates, which is what this fills in before every deploy.
24
+ */
25
+ export const SOURCE_COMMIT_ENV = "SOURCE_COMMIT";
26
+ /**
27
+ * `SOURCE_COMMIT`, then the deploy, then the wait — in that order and never another.
28
+ *
29
+ * The environment write is what the build reads, so a deploy triggered first would build the
30
+ * previous commit's tag and the wait would then time out against a version the app is right to
31
+ * report. Both `hf new`'s tenth step and `hf deploy` come through here for that reason.
32
+ */
33
+ export async function deployCommit(target) {
34
+ const deadline = target.now() + DEPLOY_TIMEOUT_MS;
35
+ await setSourceCommit(target);
36
+ const { deployments } = await target.coolify.deploy(target.appUuid, { force: true });
37
+ const deploymentUuid = deployments[0]?.deployment_uuid;
38
+ if (deploymentUuid === undefined) {
39
+ throw new StepFailed(`Coolify accepted the deploy of ${target.name} but named no deployment`);
40
+ }
41
+ target.io.out(`${target.name}: deployment ${deploymentUuid} queued`);
42
+ await waitForBuild(target, deploymentUuid, deadline);
43
+ await waitForVersion(target, deadline);
44
+ target.io.out(`${target.name}: serving ${short(target.sha)} at https://${target.fqdn}`);
45
+ }
15
46
  /**
16
47
  * Deploy, wait for the build, then wait for the app to say it is running that commit.
17
48
  *
@@ -32,59 +63,92 @@ export const deployStep = {
32
63
  if (sha === undefined) {
33
64
  throw new StepFailed(`${context.dir} has no commit to deploy: the install step has not run`);
34
65
  }
66
+ const fqdn = appFqdn(context);
67
+ const readToken = context.state.state.statusTokens?.read;
68
+ if (readToken === undefined) {
69
+ throw new StepFailed(`no read status token in the state cache: nothing can ask https://${fqdn}/api/status ` +
70
+ "what it is running");
71
+ }
35
72
  const required = requireOperatorConfig(context.config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN"], { env: context.env });
36
- const coolify = new CoolifyClient({
37
- url: required.HF_COOLIFY_URL,
38
- token: required.HF_COOLIFY_TOKEN,
73
+ await deployCommit({
74
+ name: names.given,
75
+ appUuid,
76
+ fqdn,
77
+ sha,
78
+ readToken,
79
+ coolify: new CoolifyClient({
80
+ url: required.HF_COOLIFY_URL,
81
+ token: required.HF_COOLIFY_TOKEN,
82
+ fetch: context.fetch,
83
+ }),
84
+ io: context.io,
85
+ now: context.now,
86
+ sleep: context.sleep,
39
87
  fetch: context.fetch,
40
88
  });
41
- const deadline = context.now() + DEPLOY_TIMEOUT_MS;
42
- const { deployments } = await coolify.deploy(appUuid, { force: true });
43
- const deploymentUuid = deployments[0]?.deployment_uuid;
44
- if (deploymentUuid === undefined) {
45
- throw new StepFailed(`Coolify accepted the deploy of ${names.given} but named no deployment`);
46
- }
47
- context.io.out(`${names.given}: deployment ${deploymentUuid} queued`);
48
- await waitForBuild(context, coolify, deploymentUuid, deadline);
49
- await waitForVersion(context, sha, deadline);
50
89
  await context.state.patch({ lastDeployedSha: sha });
51
- context.io.out(`${names.given}: serving ${short(sha)} at https://${appFqdn(context)}`);
52
90
  },
53
91
  };
54
- async function waitForBuild(context, coolify, deploymentUuid, deadline) {
92
+ /**
93
+ * Points `SOURCE_COMMIT` at the commit about to be deployed, creating the entry if it is absent.
94
+ *
95
+ * Idempotent, and deliberately outside the `coolify` step's bulk PATCH: this is a value `hf` sets
96
+ * per deploy rather than one of the app's secrets, so it is neither in `secretsHash` nor in what
97
+ * `assertEnvsMatchCompose` compares. Every entry Coolify lists under the name is written, because
98
+ * a compose parse materialises a preview entry beside the non-preview one and the deploy reads
99
+ * its own.
100
+ */
101
+ async function setSourceCommit(target) {
102
+ const existing = (await target.coolify.listEnvs(target.appUuid)).filter((entry) => entry.key === SOURCE_COMMIT_ENV);
103
+ const variable = {
104
+ key: SOURCE_COMMIT_ENV,
105
+ value: target.sha,
106
+ is_buildtime: true,
107
+ is_runtime: true,
108
+ };
109
+ if (existing.length === 0) {
110
+ await target.coolify.createEnv(target.appUuid, variable);
111
+ }
112
+ else {
113
+ for (const entry of existing) {
114
+ await target.coolify.updateEnv(target.appUuid, {
115
+ ...variable,
116
+ is_preview: entry.is_preview ?? false,
117
+ });
118
+ }
119
+ }
120
+ target.io.out(`${target.name}: ${SOURCE_COMMIT_ENV}=${short(target.sha)} set in Coolify`);
121
+ }
122
+ async function waitForBuild(target, deploymentUuid, deadline) {
55
123
  for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
56
- const deployment = await coolify.getDeployment(deploymentUuid);
124
+ const deployment = await target.coolify.getDeployment(deploymentUuid);
57
125
  if (deployment.status === FINISHED)
58
126
  return;
59
127
  if (deployment.status.startsWith("failed") || deployment.status.startsWith("cancelled")) {
60
128
  throw new StepFailed(`Coolify deployment ${deploymentUuid} ended ${deployment.status}: read the build log in ` +
61
129
  "Coolify, fix it, and re-run hf new");
62
130
  }
63
- if (context.now() >= deadline) {
131
+ if (target.now() >= deadline) {
64
132
  throw new StepFailed(`Coolify deployment ${deploymentUuid} was still ${deployment.status} after ` +
65
133
  `${String(DEPLOY_TIMEOUT_MS / 60_000)} minutes`);
66
134
  }
67
- await context.sleep(wait);
135
+ await target.sleep(wait);
68
136
  }
69
137
  }
70
138
  /**
71
- * Polls `/api/status` under the read token until it reports `sha`.
139
+ * Polls `/api/status` under the read token until it reports `target.sha`.
72
140
  *
73
141
  * A refusal or an unparseable answer is not a failure here — the containers are restarting, and the
74
142
  * old ones answer until the new ones are healthy — so only the deadline ends this loop.
75
143
  */
76
- async function waitForVersion(context, sha, deadline) {
77
- const url = `https://${appFqdn(context)}/api/status`;
78
- const token = context.state.state.statusTokens?.read;
79
- if (token === undefined) {
80
- throw new StepFailed(`no read status token in the state cache: nothing can ask ${url} what it is running`);
81
- }
82
- const doFetch = context.fetch ?? ((input, init) => globalThis.fetch(input, init));
144
+ async function waitForVersion(target, deadline) {
145
+ const url = `https://${target.fqdn}/api/status`;
146
+ const doFetch = target.fetch ?? ((input, init) => globalThis.fetch(input, init));
83
147
  let last = "nothing yet";
84
148
  for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
85
149
  try {
86
150
  const response = await doFetch(url, {
87
- headers: { authorization: `Bearer ${token}`, accept: "application/json" },
151
+ headers: { authorization: `Bearer ${target.readToken}`, accept: "application/json" },
88
152
  });
89
153
  if (!response.ok) {
90
154
  last = `HTTP ${String(response.status)}`;
@@ -92,7 +156,7 @@ async function waitForVersion(context, sha, deadline) {
92
156
  else {
93
157
  const report = (await response.json());
94
158
  const version = report.applicationVersion;
95
- if (version === sha)
159
+ if (version === target.sha)
96
160
  return;
97
161
  last = version === null ? "no applicationVersion" : `applicationVersion ${short(version)}`;
98
162
  }
@@ -100,11 +164,11 @@ async function waitForVersion(context, sha, deadline) {
100
164
  catch (error) {
101
165
  last = error.message;
102
166
  }
103
- if (context.now() >= deadline) {
104
- throw new StepFailed(`${url} never reported ${short(sha)} within ` +
167
+ if (target.now() >= deadline) {
168
+ throw new StepFailed(`${url} never reported ${short(target.sha)} within ` +
105
169
  `${String(DEPLOY_TIMEOUT_MS / 60_000)} minutes (last: ${last}). The build finished, so ` +
106
- "check that SOURCE_COMMIT reached the image — hf doctor reports the same mismatch.");
170
+ `check that ${SOURCE_COMMIT_ENV} reached the image — hf doctor reports the same mismatch.`);
107
171
  }
108
- await context.sleep(wait);
172
+ await target.sleep(wait);
109
173
  }
110
174
  }
@@ -0,0 +1,32 @@
1
+ import { type StepExec, type StepOut } from "./cloud-steps/index.js";
2
+ import { type OperatorConfig } from "./config.js";
3
+ import type { FetchLike } from "./providers/http.js";
4
+ export interface DeployAppOptions {
5
+ /** The app as `hf new` named it, which is also its state file's name. */
6
+ app: string;
7
+ /** The commit to deploy. Defaults to `main`'s on the app's own repository. */
8
+ sha?: string;
9
+ io: StepOut;
10
+ config?: OperatorConfig;
11
+ /** Where the per-app state files are. Defaults to `stateDir()`. */
12
+ stateDir?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ fetch?: FetchLike;
15
+ exec?: StepExec;
16
+ now?: () => number;
17
+ sleep?: (ms: number) => Promise<void>;
18
+ }
19
+ export interface DeployAppResult {
20
+ app: string;
21
+ /** The commit the app answered `/api/status` with before this returned. */
22
+ sha: string;
23
+ url: string;
24
+ }
25
+ /**
26
+ * `hf deploy <name>` — point the app at a commit and wait until it says it is serving it.
27
+ *
28
+ * The same path as `hf new`'s tenth step, and the reason there is a command at all: auto-deploy
29
+ * is off, so a merge to main changes nothing on the box until this runs. Everything it needs is
30
+ * in the state cache the provisioning run wrote; nothing here is interactive.
31
+ */
32
+ export declare function deployApp(options: DeployAppOptions): Promise<DeployAppResult>;
@@ -0,0 +1,81 @@
1
+ import { deployCommit } from "./cloud-steps/deploy.js";
2
+ import { spawnStepExec, StepFailed } from "./cloud-steps/index.js";
3
+ import { gitAuthEnv } from "./cloud-steps/repo.js";
4
+ import { loadOperatorConfig, requireOperatorConfig } from "./config.js";
5
+ import { deriveNames } from "./names.js";
6
+ import { CoolifyClient } from "./providers/coolify.js";
7
+ import { openAppState } from "./state.js";
8
+ /** What a commit looks like once `git` has resolved it; `/api/status` reports the same form. */
9
+ const FULL_SHA = /^[0-9a-f]{40}$/;
10
+ /**
11
+ * `hf deploy <name>` — point the app at a commit and wait until it says it is serving it.
12
+ *
13
+ * The same path as `hf new`'s tenth step, and the reason there is a command at all: auto-deploy
14
+ * is off, so a merge to main changes nothing on the box until this runs. Everything it needs is
15
+ * in the state cache the provisioning run wrote; nothing here is interactive.
16
+ */
17
+ export async function deployApp(options) {
18
+ const env = options.env ?? process.env;
19
+ const config = options.config ?? (await loadOperatorConfig({ env }));
20
+ const names = deriveNames(options.app);
21
+ const required = requireOperatorConfig(config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
22
+ const store = await openAppState(names.given, { dir: options.stateDir, env });
23
+ const { coolify, statusTokens, repo } = store.state;
24
+ const appUuid = coolify?.appUuid;
25
+ const readToken = statusTokens?.read;
26
+ if (appUuid === undefined || readToken === undefined) {
27
+ const missing = appUuid === undefined ? "Coolify application uuid" : "read status token";
28
+ throw new StepFailed(`${store.file} has no ${missing}: hf new has not finished provisioning ${names.given}`);
29
+ }
30
+ const sha = options.sha === undefined
31
+ ? await mainSha(options, repo, required.HF_GITHUB_TOKEN)
32
+ : options.sha;
33
+ if (!FULL_SHA.test(sha)) {
34
+ throw new StepFailed(`${sha} is not a commit sha: --sha takes the full forty hex characters, because that is ` +
35
+ "what /api/status reports back");
36
+ }
37
+ const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
38
+ await deployCommit({
39
+ name: names.given,
40
+ appUuid,
41
+ fqdn,
42
+ sha,
43
+ readToken,
44
+ coolify: new CoolifyClient({
45
+ url: required.HF_COOLIFY_URL,
46
+ token: required.HF_COOLIFY_TOKEN,
47
+ fetch: options.fetch,
48
+ }),
49
+ io: options.io,
50
+ now: options.now ?? (() => Date.now()),
51
+ sleep: options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms))),
52
+ fetch: options.fetch,
53
+ });
54
+ await store.patch({ lastDeployedSha: sha });
55
+ return { app: names.given, sha, url: `https://${fqdn}` };
56
+ }
57
+ /**
58
+ * `main`'s sha on the app's repository, read with `git ls-remote` and no checkout.
59
+ *
60
+ * The remote rather than a local clone: the operator running this has just merged a pull request,
61
+ * and whatever is in a directory on the laptop is not what the box would build.
62
+ */
63
+ async function mainSha(options, repo, token) {
64
+ if (repo === undefined) {
65
+ throw new StepFailed(`no owner/name repository in ${options.app}'s state cache: nothing can be asked what main ` +
66
+ "is. Pass --sha <sha>.");
67
+ }
68
+ const url = `https://github.com/${repo}.git`;
69
+ const exec = options.exec ?? spawnStepExec;
70
+ const outcome = await exec("git", ["ls-remote", url, "refs/heads/main"], {
71
+ cwd: process.cwd(),
72
+ capture: true,
73
+ env: gitAuthEnv(token),
74
+ });
75
+ const sha = /^([0-9a-f]{40})\s/.exec(outcome.stdout.trim())?.[1];
76
+ if (outcome.code !== 0 || sha === undefined) {
77
+ throw new StepFailed(`git ls-remote ${url} refs/heads/main named no commit: the repository may be gone, the ` +
78
+ "branch unborn, or HF_GITHUB_TOKEN unable to read it. Pass --sha <sha>.");
79
+ }
80
+ return sha;
81
+ }
package/dist/doctor.js CHANGED
@@ -142,7 +142,7 @@ async function doctorApp(context, name) {
142
142
  }
143
143
  }
144
144
  const report = await statusFindings(context, name, state, add);
145
- versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
145
+ versionFinding(name, report?.applicationVersion, mainSha, mainShaProblem, add);
146
146
  if (report?.budget !== undefined) {
147
147
  budgetFinding(report.budget.current, "current", add);
148
148
  budgetFinding(report.budget.previous, "previous", add);
@@ -232,7 +232,14 @@ function amount(value) {
232
232
  const parsed = Number(value);
233
233
  return Number.isFinite(parsed) ? parsed : undefined;
234
234
  }
235
- function versionFinding(deployed, mainSha, mainShaProblem, add) {
235
+ /**
236
+ * What the app answers against what its repository's main holds.
237
+ *
238
+ * The mismatch names `hf deploy` because nothing else closes it: Coolify's push auto-deploy is
239
+ * disabled on every application `hf new` creates, so a merged pull request sits unpublished until
240
+ * an operator says so.
241
+ */
242
+ function versionFinding(name, deployed, mainSha, mainShaProblem, add) {
236
243
  if (mainShaProblem !== undefined) {
237
244
  add("version", "fail", mainShaProblem);
238
245
  return;
@@ -245,7 +252,7 @@ function versionFinding(deployed, mainSha, mainShaProblem, add) {
245
252
  }
246
253
  add("version", deployed === mainSha ? "ok" : "warn", deployed === mainSha
247
254
  ? `applicationVersion ${short(mainSha)} is main`
248
- : `applicationVersion ${short(deployed)} is not main ${short(mainSha)}`);
255
+ : `applicationVersion ${short(deployed)} is not main ${short(mainSha)} — run hf deploy ${name}`);
249
256
  }
250
257
  function budgetFinding(period, which, add) {
251
258
  if (period === null) {
@@ -80,6 +80,23 @@ export interface CoolifyEnvironmentVariable {
80
80
  is_literal?: boolean;
81
81
  is_multiline?: boolean;
82
82
  is_shown_once?: boolean;
83
+ /**
84
+ * Whether the builder interpolates the variable, and whether the containers get it.
85
+ *
86
+ * Both default true in Coolify's UI and neither is in the document's *request* schemas —
87
+ * only in its `EnvironmentVariable` model — but the box accepts and stores them (verified
88
+ * against 4.3.21). `SOURCE_COMMIT` needs both: it is the compose image tag and build arg as
89
+ * well as the `HF_BUILD_SHA` three services read.
90
+ */
91
+ is_buildtime?: boolean;
92
+ is_runtime?: boolean;
93
+ }
94
+ /** One entry of `GET /applications/{uuid}/envs`, narrowed to what an upsert has to match on. */
95
+ export interface CoolifyEnvEntry {
96
+ uuid: string;
97
+ key: string;
98
+ value?: string;
99
+ is_preview?: boolean;
83
100
  }
84
101
  export interface CoolifyDeploymentRequest {
85
102
  deployments: {
@@ -171,6 +188,13 @@ export declare class CoolifyClient {
171
188
  tag?: string;
172
189
  }): Promise<CoolifyApplicationSummary[]>;
173
190
  updateEnvsBulk(appUuid: string, data: readonly CoolifyEnvironmentVariable[]): Promise<unknown>;
191
+ /** Every environment variable on the application, preview and non-preview alike. */
192
+ listEnvs(appUuid: string): Promise<CoolifyEnvEntry[]>;
193
+ createEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<{
194
+ uuid: string;
195
+ }>;
196
+ /** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
197
+ updateEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<unknown>;
174
198
  deploy(uuid: string, options?: {
175
199
  force?: boolean;
176
200
  }): Promise<CoolifyDeploymentRequest>;
@@ -55,6 +55,27 @@ export class CoolifyClient {
55
55
  secrets: data.map((variable) => variable.value),
56
56
  });
57
57
  }
58
+ /** Every environment variable on the application, preview and non-preview alike. */
59
+ async listEnvs(appUuid) {
60
+ return await this.request({ method: "GET", path: `/applications/${segment(appUuid)}/envs` });
61
+ }
62
+ async createEnv(appUuid, variable) {
63
+ return await this.request({
64
+ method: "POST",
65
+ path: `/applications/${segment(appUuid)}/envs`,
66
+ body: variable,
67
+ secrets: [variable.value],
68
+ });
69
+ }
70
+ /** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
71
+ async updateEnv(appUuid, variable) {
72
+ return await this.request({
73
+ method: "PATCH",
74
+ path: `/applications/${segment(appUuid)}/envs`,
75
+ body: variable,
76
+ secrets: [variable.value],
77
+ });
78
+ }
58
79
  async deploy(uuid, options = {}) {
59
80
  return await this.request({
60
81
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "license": "MIT",
5
5
  "description": "The hf binary and its Turborepo generator templates",
6
6
  "repository": {
@@ -29,15 +29,15 @@
29
29
  "!dist/test-support/**"
30
30
  ],
31
31
  "dependencies": {
32
- "@hyperfixation/auth": "0.1.5",
33
- "@hyperfixation/core": "0.1.5",
34
- "@hyperfixation/db": "0.1.5",
32
+ "@hyperfixation/auth": "0.1.6",
33
+ "@hyperfixation/core": "0.1.6",
34
+ "@hyperfixation/db": "0.1.6",
35
35
  "giget": "3.3.1",
36
36
  "pg": "^8.23.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@hyperfixation/eslint-config": "0.1.5",
40
- "@hyperfixation/testing": "0.1.5",
39
+ "@hyperfixation/eslint-config": "0.1.6",
40
+ "@hyperfixation/testing": "0.1.6",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",