@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { CoolifyClient } from "../providers/coolify.js";
|
|
3
|
+
import { appFqdn, gitHead, short, StepFailed } from "./context.js";
|
|
4
|
+
/**
|
|
5
|
+
* How long the step waits for Coolify to build and for the app to report the sha it built.
|
|
6
|
+
*
|
|
7
|
+
* One deadline for both halves: what the operator is waiting on is a deployed app answering with
|
|
8
|
+
* the right version, and a build that took fourteen minutes has not left time for anything else.
|
|
9
|
+
*/
|
|
10
|
+
export const DEPLOY_TIMEOUT_MS = 15 * 60_000;
|
|
11
|
+
const FIRST_POLL_MS = 2_000;
|
|
12
|
+
const MAX_POLL_MS = 15_000;
|
|
13
|
+
/** Coolify's one terminal success; `failed` and `cancelled-*` are the terminal failures. */
|
|
14
|
+
const FINISHED = "finished";
|
|
15
|
+
/**
|
|
16
|
+
* Deploy, wait for the build, then wait for the app to say it is running that commit.
|
|
17
|
+
*
|
|
18
|
+
* Never skipped while it is not recorded, and recorded only once `/api/status` reports the pushed
|
|
19
|
+
* sha: a redeploy costs a rebuild, whereas a `deploy` marked done off the API's own "finished"
|
|
20
|
+
* would hide a container that came up on the previous image — which is exactly what a rotation
|
|
21
|
+
* needs this step to rule out.
|
|
22
|
+
*/
|
|
23
|
+
export const deployStep = {
|
|
24
|
+
name: "deploy",
|
|
25
|
+
run: async (context) => {
|
|
26
|
+
const { names } = context;
|
|
27
|
+
const appUuid = context.state.state.coolify?.appUuid;
|
|
28
|
+
if (appUuid === undefined) {
|
|
29
|
+
throw new StepFailed("no Coolify application uuid in the state cache: the coolify step has not run for this app");
|
|
30
|
+
}
|
|
31
|
+
const sha = await gitHead(context);
|
|
32
|
+
if (sha === undefined) {
|
|
33
|
+
throw new StepFailed(`${context.dir} has no commit to deploy: the install step has not run`);
|
|
34
|
+
}
|
|
35
|
+
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,
|
|
39
|
+
fetch: context.fetch,
|
|
40
|
+
});
|
|
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
|
+
await context.state.patch({ lastDeployedSha: sha });
|
|
51
|
+
context.io.out(`${names.given}: serving ${short(sha)} at https://${appFqdn(context)}`);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
async function waitForBuild(context, coolify, deploymentUuid, deadline) {
|
|
55
|
+
for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
|
|
56
|
+
const deployment = await coolify.getDeployment(deploymentUuid);
|
|
57
|
+
if (deployment.status === FINISHED)
|
|
58
|
+
return;
|
|
59
|
+
if (deployment.status.startsWith("failed") || deployment.status.startsWith("cancelled")) {
|
|
60
|
+
throw new StepFailed(`Coolify deployment ${deploymentUuid} ended ${deployment.status}: read the build log in ` +
|
|
61
|
+
"Coolify, fix it, and re-run hf new");
|
|
62
|
+
}
|
|
63
|
+
if (context.now() >= deadline) {
|
|
64
|
+
throw new StepFailed(`Coolify deployment ${deploymentUuid} was still ${deployment.status} after ` +
|
|
65
|
+
`${String(DEPLOY_TIMEOUT_MS / 60_000)} minutes`);
|
|
66
|
+
}
|
|
67
|
+
await context.sleep(wait);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Polls `/api/status` under the read token until it reports `sha`.
|
|
72
|
+
*
|
|
73
|
+
* A refusal or an unparseable answer is not a failure here — the containers are restarting, and the
|
|
74
|
+
* old ones answer until the new ones are healthy — so only the deadline ends this loop.
|
|
75
|
+
*/
|
|
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));
|
|
83
|
+
let last = "nothing yet";
|
|
84
|
+
for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
|
|
85
|
+
try {
|
|
86
|
+
const response = await doFetch(url, {
|
|
87
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
last = `HTTP ${String(response.status)}`;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const report = (await response.json());
|
|
94
|
+
const version = report.applicationVersion;
|
|
95
|
+
if (version === sha)
|
|
96
|
+
return;
|
|
97
|
+
last = version === null ? "no applicationVersion" : `applicationVersion ${short(version)}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
last = error.message;
|
|
102
|
+
}
|
|
103
|
+
if (context.now() >= deadline) {
|
|
104
|
+
throw new StepFailed(`${url} never reported ${short(sha)} within ` +
|
|
105
|
+
`${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.");
|
|
107
|
+
}
|
|
108
|
+
await context.sleep(wait);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* `A <app>.<HF_BASE_DOMAIN>` → `HF_BOX_IP`, DNS-only, and exactly one of them.
|
|
5
|
+
*
|
|
6
|
+
* An existing record with the right address is the step's own previous work. One with a different
|
|
7
|
+
* address is somebody's live hostname: it is never overwritten, and a second A record is never
|
|
8
|
+
* added either — two of them would round-robin between the box and whatever that is, which looks
|
|
9
|
+
* like an intermittent outage rather than a misconfiguration.
|
|
10
|
+
*/
|
|
11
|
+
export declare const dnsStep: Step<CloudStepContext>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { CloudflareClient } from "../providers/cloudflare.js";
|
|
3
|
+
import { StepFailed } from "./context.js";
|
|
4
|
+
/** Cloudflare's "automatic"; the record is DNS-only, so nothing caches it for long. */
|
|
5
|
+
const TTL_AUTOMATIC = 1;
|
|
6
|
+
/**
|
|
7
|
+
* `A <app>.<HF_BASE_DOMAIN>` → `HF_BOX_IP`, DNS-only, and exactly one of them.
|
|
8
|
+
*
|
|
9
|
+
* An existing record with the right address is the step's own previous work. One with a different
|
|
10
|
+
* address is somebody's live hostname: it is never overwritten, and a second A record is never
|
|
11
|
+
* added either — two of them would round-robin between the box and whatever that is, which looks
|
|
12
|
+
* like an intermittent outage rather than a misconfiguration.
|
|
13
|
+
*/
|
|
14
|
+
export const dnsStep = {
|
|
15
|
+
name: "dns",
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const { names } = context;
|
|
18
|
+
const required = requireOperatorConfig(context.config, ["HF_CLOUDFLARE_TOKEN", "HF_CLOUDFLARE_ZONE_ID", "HF_BASE_DOMAIN", "HF_BOX_IP"], { env: context.env });
|
|
19
|
+
const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
|
|
20
|
+
const cloudflare = new CloudflareClient({
|
|
21
|
+
token: required.HF_CLOUDFLARE_TOKEN,
|
|
22
|
+
fetch: context.fetch,
|
|
23
|
+
});
|
|
24
|
+
const listed = assertSuccess(await cloudflare.listDnsRecords(required.HF_CLOUDFLARE_ZONE_ID, { name: fqdn, type: "A" }), `list the A records for ${fqdn}`);
|
|
25
|
+
const existing = listed[0];
|
|
26
|
+
if (existing !== undefined) {
|
|
27
|
+
if (existing.content !== required.HF_BOX_IP) {
|
|
28
|
+
throw new StepFailed(`${fqdn} already has an A record pointing at ${existing.content}, not the box at ` +
|
|
29
|
+
`${required.HF_BOX_IP}: hf new neither overwrites an A record nor adds a second one. ` +
|
|
30
|
+
`Point it at the box, or delete it, and re-run hf new.`);
|
|
31
|
+
}
|
|
32
|
+
context.io.out(`${names.given}: ${fqdn} already points at ${required.HF_BOX_IP}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
assertSuccess(await cloudflare.createDnsRecord(required.HF_CLOUDFLARE_ZONE_ID, {
|
|
36
|
+
type: "A",
|
|
37
|
+
name: fqdn,
|
|
38
|
+
content: required.HF_BOX_IP,
|
|
39
|
+
ttl: TTL_AUTOMATIC,
|
|
40
|
+
proxied: false,
|
|
41
|
+
comment: `hf new ${names.given}`,
|
|
42
|
+
}), `create the A record for ${fqdn}`);
|
|
43
|
+
context.io.out(`${names.given}: ${fqdn} A ${required.HF_BOX_IP}, DNS-only`);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
/** Cloudflare answers `success: false` with HTTP 200 on some routes, which no transport catches. */
|
|
47
|
+
function assertSuccess(envelope, what) {
|
|
48
|
+
if (!envelope.success) {
|
|
49
|
+
const detail = envelope.errors.map((error) => error.message).join("; ");
|
|
50
|
+
throw new StepFailed(`cloudflare could not ${what}${detail === "" ? "" : `: ${detail}`}`);
|
|
51
|
+
}
|
|
52
|
+
return envelope.result;
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { backupStep } from "./backup.js";
|
|
3
|
+
import type { CloudStepContext } from "./context.js";
|
|
4
|
+
import { coolifyStep } from "./coolify.js";
|
|
5
|
+
import { databaseStep } from "./database.js";
|
|
6
|
+
import { deployStep } from "./deploy.js";
|
|
7
|
+
import { dnsStep } from "./dns.js";
|
|
8
|
+
import { installStep } from "./install.js";
|
|
9
|
+
import { langfuseStep } from "./langfuse.js";
|
|
10
|
+
import { repoStep } from "./repo.js";
|
|
11
|
+
import { sentryStep } from "./sentry.js";
|
|
12
|
+
import { templateStep } from "./template.js";
|
|
13
|
+
/**
|
|
14
|
+
* The steps of a cloud `hf new`, in `STEPS` order — which `runSteps` asserts, because that order
|
|
15
|
+
* is the rotation-safety argument rather than a preference.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CLOUD_STEPS: readonly Step<CloudStepContext>[];
|
|
18
|
+
export { backupStep, coolifyStep, databaseStep, deployStep, dnsStep, installStep, langfuseStep, repoStep, sentryStep, templateStep, };
|
|
19
|
+
export { EnvDrift, neededEnvNames } from "./coolify.js";
|
|
20
|
+
export { DEPLOY_TIMEOUT_MS } from "./deploy.js";
|
|
21
|
+
export { appFqdn, cloudCommands, defaultTemplateFetch, spawnStepExec, StepFailed, type CloudCommands, type CloudStepContext, type StepExec, type StepExecOptions, type StepExecOutcome, type StepOut, type TemplateFetch, } from "./context.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { backupStep } from "./backup.js";
|
|
2
|
+
import { coolifyStep } from "./coolify.js";
|
|
3
|
+
import { databaseStep } from "./database.js";
|
|
4
|
+
import { deployStep } from "./deploy.js";
|
|
5
|
+
import { dnsStep } from "./dns.js";
|
|
6
|
+
import { installStep } from "./install.js";
|
|
7
|
+
import { langfuseStep } from "./langfuse.js";
|
|
8
|
+
import { repoStep } from "./repo.js";
|
|
9
|
+
import { sentryStep } from "./sentry.js";
|
|
10
|
+
import { templateStep } from "./template.js";
|
|
11
|
+
/**
|
|
12
|
+
* The steps of a cloud `hf new`, in `STEPS` order — which `runSteps` asserts, because that order
|
|
13
|
+
* is the rotation-safety argument rather than a preference.
|
|
14
|
+
*/
|
|
15
|
+
export const CLOUD_STEPS = [
|
|
16
|
+
templateStep,
|
|
17
|
+
installStep,
|
|
18
|
+
repoStep,
|
|
19
|
+
backupStep,
|
|
20
|
+
sentryStep,
|
|
21
|
+
langfuseStep,
|
|
22
|
+
dnsStep,
|
|
23
|
+
databaseStep,
|
|
24
|
+
coolifyStep,
|
|
25
|
+
deployStep,
|
|
26
|
+
];
|
|
27
|
+
export { backupStep, coolifyStep, databaseStep, deployStep, dnsStep, installStep, langfuseStep, repoStep, sentryStep, templateStep, };
|
|
28
|
+
export { EnvDrift, neededEnvNames } from "./coolify.js";
|
|
29
|
+
export { DEPLOY_TIMEOUT_MS } from "./deploy.js";
|
|
30
|
+
export { appFqdn, cloudCommands, defaultTemplateFetch, spawnStepExec, StepFailed, } from "./context.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* `pnpm install`, then the app's own first commit.
|
|
5
|
+
*
|
|
6
|
+
* Done means `git rev-parse HEAD` answers, which is also how a run with no state file detects the
|
|
7
|
+
* work of a previous one: the commit is the artefact, and a `node_modules` is not evidence of
|
|
8
|
+
* anything.
|
|
9
|
+
*/
|
|
10
|
+
export declare const installStep: Step<CloudStepContext>;
|
|
11
|
+
/** Every file the template fetch left, relative to the app directory, `EXCLUDED_ENTRIES` aside. */
|
|
12
|
+
export declare function templatedFiles(dir: string): Promise<string[]>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EXCLUDED_ENTRIES } from "../new.js";
|
|
4
|
+
import { gitHead, mustRun, short } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* `pnpm install`, then the app's own first commit.
|
|
7
|
+
*
|
|
8
|
+
* Done means `git rev-parse HEAD` answers, which is also how a run with no state file detects the
|
|
9
|
+
* work of a previous one: the commit is the artefact, and a `node_modules` is not evidence of
|
|
10
|
+
* anything.
|
|
11
|
+
*/
|
|
12
|
+
export const installStep = {
|
|
13
|
+
name: "install",
|
|
14
|
+
run: async (context) => {
|
|
15
|
+
const { names } = context;
|
|
16
|
+
const head = await gitHead(context);
|
|
17
|
+
if (head !== undefined) {
|
|
18
|
+
context.io.out(`${names.given}: adopting the commit already in ${context.dir} (${short(head)})`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Listed before `pnpm install`, so the paths handed to `git add` cannot include a
|
|
22
|
+
// `node_modules` the lockfile install is about to create.
|
|
23
|
+
const files = await templatedFiles(context.dir);
|
|
24
|
+
await mustRun(context, "pnpm", ["install"]);
|
|
25
|
+
await mustRun(context, "git", ["init", "-b", "main"]);
|
|
26
|
+
// Explicit paths, never `git add -A`: the template's `.gitignore` is one of the files being
|
|
27
|
+
// added and is therefore not in force yet, and `.env` is the file that must not be committed.
|
|
28
|
+
await mustRun(context, "git", ["add", "--", ...files]);
|
|
29
|
+
await mustRun(context, "git", [
|
|
30
|
+
"commit",
|
|
31
|
+
"-m",
|
|
32
|
+
`Create ${names.given} from hyperfixation-template`,
|
|
33
|
+
]);
|
|
34
|
+
context.io.out(`${names.given}: ${String(files.length)} file(s) in the initial commit`);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** Every file the template fetch left, relative to the app directory, `EXCLUDED_ENTRIES` aside. */
|
|
38
|
+
export async function templatedFiles(dir) {
|
|
39
|
+
const found = [];
|
|
40
|
+
const walk = async (current) => {
|
|
41
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
42
|
+
if (EXCLUDED_ENTRIES.includes(entry.name))
|
|
43
|
+
continue;
|
|
44
|
+
const full = path.join(current, entry.name);
|
|
45
|
+
if (entry.isDirectory())
|
|
46
|
+
await walk(full);
|
|
47
|
+
else if (entry.isFile())
|
|
48
|
+
found.push(path.relative(dir, full));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
await walk(dir);
|
|
52
|
+
return found.sort();
|
|
53
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import type { CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* The app's Langfuse project and a key pair for it.
|
|
5
|
+
*
|
|
6
|
+
* The project is found by name, so a cold run reuses the one it made before rather than filling
|
|
7
|
+
* the organization with duplicates. The key pair is **not** reused: Langfuse returns a secret key
|
|
8
|
+
* once, at creation, and the only copy is the state file this run may not have — so a new key is
|
|
9
|
+
* created and the old ones keep working, which costs an unused key and never an app that cannot
|
|
10
|
+
* authenticate.
|
|
11
|
+
*
|
|
12
|
+
* Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
|
|
13
|
+
* the step falls back to the project key pair the operator configured, and without that to a
|
|
14
|
+
* warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
|
|
15
|
+
* the operator can decide otherwise.
|
|
16
|
+
*/
|
|
17
|
+
export declare const langfuseStep: Step<CloudStepContext>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { LangfuseClient } from "../providers/langfuse.js";
|
|
3
|
+
/** Langfuse keeps data indefinitely at 0, and any other value needs a paid entitlement. */
|
|
4
|
+
const RETENTION_DAYS = 0;
|
|
5
|
+
/** Said in the warning and again in the closing checklist, so neither run nor log has to be read. */
|
|
6
|
+
const NOT_CONFIGURED = "Langfuse tracing is not configured: neither HF_LANGFUSE_ORG_KEY nor an " +
|
|
7
|
+
"HF_LANGFUSE_PUBLIC_KEY/HF_LANGFUSE_SECRET_KEY pair is set, so LANGFUSE_BASE_URL, " +
|
|
8
|
+
"LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY were not sent to Coolify and the app records no " +
|
|
9
|
+
"traces. To add them later, take a project's key pair from Langfuse → project settings → API " +
|
|
10
|
+
"keys, set all three in Coolify's environment for this application, and redeploy.";
|
|
11
|
+
/**
|
|
12
|
+
* The app's Langfuse project and a key pair for it.
|
|
13
|
+
*
|
|
14
|
+
* The project is found by name, so a cold run reuses the one it made before rather than filling
|
|
15
|
+
* the organization with duplicates. The key pair is **not** reused: Langfuse returns a secret key
|
|
16
|
+
* once, at creation, and the only copy is the state file this run may not have — so a new key is
|
|
17
|
+
* created and the old ones keep working, which costs an unused key and never an app that cannot
|
|
18
|
+
* authenticate.
|
|
19
|
+
*
|
|
20
|
+
* Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
|
|
21
|
+
* the step falls back to the project key pair the operator configured, and without that to a
|
|
22
|
+
* warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
|
|
23
|
+
* the operator can decide otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export const langfuseStep = {
|
|
26
|
+
name: "langfuse",
|
|
27
|
+
run: async (context) => {
|
|
28
|
+
const { names } = context;
|
|
29
|
+
if ((context.config.HF_LANGFUSE_ORG_KEY ?? "") === "") {
|
|
30
|
+
await reuseOrSkip(context);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const required = requireOperatorConfig(context.config, ["HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY"], { env: context.env });
|
|
34
|
+
const langfuse = new LangfuseClient({
|
|
35
|
+
url: required.HF_LANGFUSE_URL,
|
|
36
|
+
orgKey: required.HF_LANGFUSE_ORG_KEY,
|
|
37
|
+
fetch: context.fetch,
|
|
38
|
+
});
|
|
39
|
+
const { data } = await langfuse.listProjects();
|
|
40
|
+
const existing = data.find((project) => project.name === names.appName);
|
|
41
|
+
const projectId = existing?.id ??
|
|
42
|
+
(await langfuse.createProject({ name: names.appName, retention: RETENTION_DAYS })).id;
|
|
43
|
+
context.io.out(`${names.given}: ${existing === undefined ? "created" : "adopting"} the Langfuse project ` +
|
|
44
|
+
names.appName);
|
|
45
|
+
const key = await langfuse.createApiKey(projectId, { note: `hf new ${names.given}` });
|
|
46
|
+
await context.state.patch({
|
|
47
|
+
langfuse: { publicKey: key.publicKey, secretKey: key.secretKey },
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The two paths without an org key: the operator's own project key pair, or nothing.
|
|
53
|
+
*
|
|
54
|
+
* No request either way — a project-scoped pair cannot list or create projects, so there is
|
|
55
|
+
* nothing to ask Langfuse that would not fail.
|
|
56
|
+
*/
|
|
57
|
+
async function reuseOrSkip(context) {
|
|
58
|
+
const { names, config } = context;
|
|
59
|
+
const publicKey = config.HF_LANGFUSE_PUBLIC_KEY ?? "";
|
|
60
|
+
const secretKey = config.HF_LANGFUSE_SECRET_KEY ?? "";
|
|
61
|
+
if (publicKey === "" || secretKey === "") {
|
|
62
|
+
context.io.out(`WARNING: ${names.given}: ${NOT_CONFIGURED}`);
|
|
63
|
+
context.checklist.push(NOT_CONFIGURED);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// The public key names the project without being a secret, which is the only identifier this
|
|
67
|
+
// path has: nothing here may ask Langfuse what the project is called.
|
|
68
|
+
context.io.out(`${names.given}: reusing the configured Langfuse project keys (${publicKey}) — every app ` +
|
|
69
|
+
"configured with them traces into that one project");
|
|
70
|
+
await context.state.patch({ langfuse: { publicKey, secretKey } });
|
|
71
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* The token reaches `git` through the child's environment alone.
|
|
5
|
+
*
|
|
6
|
+
* Not in argv, where `ps` reads it; not in the remote URL, which `git remote add` writes into
|
|
7
|
+
* `.git/config` and every later `git push` from the operator's shell would then use; and not in
|
|
8
|
+
* anything a step prints. `GIT_CONFIG_COUNT` is how git takes configuration from the environment
|
|
9
|
+
* without a file, so the header outlives neither the child nor this step.
|
|
10
|
+
*/
|
|
11
|
+
export declare function gitAuthEnv(token: string): Record<string, string>;
|
|
12
|
+
/**
|
|
13
|
+
* The app's private GitHub repository, its first push, and both GitHub Apps on it.
|
|
14
|
+
*
|
|
15
|
+
* The cold-run question is not "is there a repository called this" but "is there a repository
|
|
16
|
+
* holding *this* app": a name someone else took answers 200 just as well, so an existing one is
|
|
17
|
+
* adopted only when its `main` is the commit the install step made. Anything else is refused with
|
|
18
|
+
* the name in the message — pushing over it is not recoverable.
|
|
19
|
+
*/
|
|
20
|
+
export declare const repoStep: Step<CloudStepContext>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { githubAppSlugs, requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { GithubClient } from "../providers/github.js";
|
|
3
|
+
import { ProviderError } from "../providers/http.js";
|
|
4
|
+
import { gitHead, mustRun, short, StepFailed } from "./context.js";
|
|
5
|
+
/** One page of installations, and of an installation's repositories. */
|
|
6
|
+
const PER_PAGE = 100;
|
|
7
|
+
/**
|
|
8
|
+
* What GitHub answers a token that may not list installations.
|
|
9
|
+
*
|
|
10
|
+
* `GET /user/installations` is documented as a GitHub App user-to-server endpoint, so every
|
|
11
|
+
* classic PAT, fine-grained PAT and OAuth token — which is what `HF_GITHUB_TOKEN` is — is refused:
|
|
12
|
+
* 403 for a `gh` OAuth token, and 401/404 for the other ways a token can be told no.
|
|
13
|
+
*/
|
|
14
|
+
const CANNOT_LIST = new Set([401, 403, 404]);
|
|
15
|
+
/**
|
|
16
|
+
* The token reaches `git` through the child's environment alone.
|
|
17
|
+
*
|
|
18
|
+
* Not in argv, where `ps` reads it; not in the remote URL, which `git remote add` writes into
|
|
19
|
+
* `.git/config` and every later `git push` from the operator's shell would then use; and not in
|
|
20
|
+
* anything a step prints. `GIT_CONFIG_COUNT` is how git takes configuration from the environment
|
|
21
|
+
* without a file, so the header outlives neither the child nor this step.
|
|
22
|
+
*/
|
|
23
|
+
export function gitAuthEnv(token) {
|
|
24
|
+
return {
|
|
25
|
+
GIT_CONFIG_COUNT: "1",
|
|
26
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
27
|
+
// GitHub's documented form for a token over HTTPS git; `Bearer` is the API's, not git's.
|
|
28
|
+
GIT_CONFIG_VALUE_0: "Authorization: Basic " + Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The app's private GitHub repository, its first push, and both GitHub Apps on it.
|
|
33
|
+
*
|
|
34
|
+
* The cold-run question is not "is there a repository called this" but "is there a repository
|
|
35
|
+
* holding *this* app": a name someone else took answers 200 just as well, so an existing one is
|
|
36
|
+
* adopted only when its `main` is the commit the install step made. Anything else is refused with
|
|
37
|
+
* the name in the message — pushing over it is not recoverable.
|
|
38
|
+
*/
|
|
39
|
+
export const repoStep = {
|
|
40
|
+
name: "repo",
|
|
41
|
+
run: async (context) => {
|
|
42
|
+
const { names } = context;
|
|
43
|
+
const required = requireOperatorConfig(context.config, ["HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS"], { env: context.env });
|
|
44
|
+
const owner = required.HF_GITHUB_OWNER;
|
|
45
|
+
const repo = names.given;
|
|
46
|
+
const fullName = `${owner}/${repo}`;
|
|
47
|
+
const head = await gitHead(context);
|
|
48
|
+
if (head === undefined) {
|
|
49
|
+
throw new StepFailed(`${context.dir} has no commit to push: the install step has not run`);
|
|
50
|
+
}
|
|
51
|
+
const github = new GithubClient({ token: required.HF_GITHUB_TOKEN, fetch: context.fetch });
|
|
52
|
+
const existing = await getRepository(github, owner, repo);
|
|
53
|
+
let pushNeeded = true;
|
|
54
|
+
if (existing === undefined) {
|
|
55
|
+
const user = await github.getUser(owner);
|
|
56
|
+
const body = { name: repo, private: true };
|
|
57
|
+
if (user.type === "Organization")
|
|
58
|
+
await github.createOrgRepository(owner, body);
|
|
59
|
+
else
|
|
60
|
+
await github.createUserRepository(body);
|
|
61
|
+
context.io.out(`${names.given}: created the private repository ${fullName}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const sha = await mainSha(github, owner, repo);
|
|
65
|
+
if (sha === head) {
|
|
66
|
+
pushNeeded = false;
|
|
67
|
+
context.io.out(`${names.given}: adopting ${fullName}, whose main is ${short(head)}`);
|
|
68
|
+
}
|
|
69
|
+
else if (sha === undefined) {
|
|
70
|
+
context.io.out(`${names.given}: ${fullName} exists and is empty; pushing`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new StepFailed(`${fullName} already exists and its main is ${short(sha)}, not this app's ` +
|
|
74
|
+
`${short(head)}: hf new will not push over a repository it did not create. Rename ` +
|
|
75
|
+
`it, or give the app another name.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// `set-url` rather than `add`, because a rerun finds the remote its predecessor added; the
|
|
79
|
+
// URL carries no credentials, so rewriting it is safe to repeat.
|
|
80
|
+
const url = `https://github.com/${owner}/${repo}.git`;
|
|
81
|
+
const remote = await context.exec("git", ["remote", "get-url", "origin"], {
|
|
82
|
+
cwd: context.dir,
|
|
83
|
+
capture: true,
|
|
84
|
+
});
|
|
85
|
+
await mustRun(context, "git", [
|
|
86
|
+
"remote",
|
|
87
|
+
remote.code === 0 ? "set-url" : "add",
|
|
88
|
+
"origin",
|
|
89
|
+
url,
|
|
90
|
+
]);
|
|
91
|
+
if (pushNeeded) {
|
|
92
|
+
await mustRun(context, "git", ["push", "--set-upstream", "origin", "main"], {
|
|
93
|
+
env: gitAuthEnv(required.HF_GITHUB_TOKEN),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
await checkAppsInstalled(context, github, githubAppSlugs(context.config), fullName);
|
|
97
|
+
await context.state.patch({ repo: fullName });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Every `HF_GITHUB_APP_SLUGS` entry installed on the repository, or which one is not.
|
|
102
|
+
*
|
|
103
|
+
* Coolify cannot deploy from a repository its GitHub App cannot see, and that failure otherwise
|
|
104
|
+
* surfaces as a deployment that clones nothing — so it is asserted here, by name, with the URL
|
|
105
|
+
* that fixes it.
|
|
106
|
+
*
|
|
107
|
+
* Unless the token may not ask at all, which is the usual case: then this degrades to a warning
|
|
108
|
+
* and a checklist line, because the repository has already been created and pushed and there is no
|
|
109
|
+
* second way to read a personal account's installations (organizations have
|
|
110
|
+
* `GET /orgs/{org}/installations`; personal accounts have nothing).
|
|
111
|
+
*/
|
|
112
|
+
async function checkAppsInstalled(context, github, slugs, fullName) {
|
|
113
|
+
let installations;
|
|
114
|
+
try {
|
|
115
|
+
installations = await allInstallations(github);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!(error instanceof ProviderError) || !CANNOT_LIST.has(error.status))
|
|
119
|
+
throw error;
|
|
120
|
+
const note = unverifiedNote(slugs, fullName, error.status);
|
|
121
|
+
context.io.out(`WARNING: ${context.names.given}: ${note}`);
|
|
122
|
+
context.checklist.push(note);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const slug of slugs) {
|
|
126
|
+
const installation = installations.find((candidate) => candidate.app_slug === slug);
|
|
127
|
+
if (installation === undefined) {
|
|
128
|
+
throw new StepFailed(`the GitHub App ${slug} is not installed for this token: install it on ${fullName} at ` +
|
|
129
|
+
`https://github.com/apps/${slug}/installations/new and re-run hf new`);
|
|
130
|
+
}
|
|
131
|
+
if (!(await installationReaches(github, installation.id, fullName))) {
|
|
132
|
+
throw new StepFailed(`the GitHub App ${slug} is installed but does not reach ${fullName}: add the repository ` +
|
|
133
|
+
`to it at https://github.com/apps/${slug}/installations/new and re-run hf new`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** The one thing left to the operator when the installations could not be listed. */
|
|
138
|
+
function unverifiedNote(slugs, fullName, status) {
|
|
139
|
+
const apps = slugs
|
|
140
|
+
.map((slug) => `${slug} (https://github.com/apps/${slug}/installations/new)`)
|
|
141
|
+
.join(", ");
|
|
142
|
+
return (`the GitHub App installations on ${fullName} could not be verified with this token — ` +
|
|
143
|
+
`listing them needs a GitHub App user-to-server token and GitHub answered HTTP ` +
|
|
144
|
+
`${String(status)}. Check by hand that each of these is installed on the repository, or on ` +
|
|
145
|
+
`All repositories: ${apps}. Coolify's first deploy clones an empty repository if its app ` +
|
|
146
|
+
`cannot see this one.`);
|
|
147
|
+
}
|
|
148
|
+
async function allInstallations(github) {
|
|
149
|
+
const found = [];
|
|
150
|
+
for (let page = 1;; page += 1) {
|
|
151
|
+
const { total_count, installations } = await github.listInstallations({
|
|
152
|
+
per_page: PER_PAGE,
|
|
153
|
+
page,
|
|
154
|
+
});
|
|
155
|
+
found.push(...installations);
|
|
156
|
+
if (installations.length === 0 || found.length >= total_count)
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function installationReaches(github, installationId, fullName) {
|
|
161
|
+
for (let page = 1, seen = 0;; page += 1) {
|
|
162
|
+
const listed = await github.listInstallationRepositories(installationId, {
|
|
163
|
+
per_page: PER_PAGE,
|
|
164
|
+
page,
|
|
165
|
+
});
|
|
166
|
+
// `all` is an installation with no repository selection to check: everything the account has,
|
|
167
|
+
// now and later, which the paged list can only under-report.
|
|
168
|
+
if (listed.repository_selection === "all")
|
|
169
|
+
return true;
|
|
170
|
+
if (listed.repositories.some((candidate) => candidate.full_name === fullName))
|
|
171
|
+
return true;
|
|
172
|
+
seen += listed.repositories.length;
|
|
173
|
+
if (listed.repositories.length === 0 || seen >= listed.total_count)
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** The repository, or `undefined` for the 404 that covers both absent and invisible. */
|
|
178
|
+
async function getRepository(github, owner, repo) {
|
|
179
|
+
try {
|
|
180
|
+
return await github.getRepository(owner, repo);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof ProviderError && error.status === 404)
|
|
184
|
+
return undefined;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** `main`'s sha, or `undefined` for the 409 GitHub answers about a repository with no commits. */
|
|
189
|
+
async function mainSha(github, owner, repo) {
|
|
190
|
+
try {
|
|
191
|
+
return (await github.getReference(owner, repo, "heads/main")).object.sha;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof ProviderError && error.status === 409)
|
|
195
|
+
return undefined;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* The app's Sentry project, and the DSN the deployment reports to.
|
|
5
|
+
*
|
|
6
|
+
* The keys endpoint is both the lookup and the answer: a 200 means the project is there and hands
|
|
7
|
+
* back its DSN in the same request, so a cold run against an existing project neither creates a
|
|
8
|
+
* second one nor needs a list of every project the token can see. Only a 404 creates.
|
|
9
|
+
*
|
|
10
|
+
* The DSN reaches the app through the Coolify env PATCH; it is a credential, so it is recorded in
|
|
11
|
+
* the state and never printed.
|
|
12
|
+
*/
|
|
13
|
+
export declare const sentryStep: Step<CloudStepContext>;
|