@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.
- 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 +74 -0
- package/dist/cloud-steps/coolify.js +300 -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 +12 -0
- package/dist/cloud-steps/langfuse.js +35 -0
- package/dist/cloud-steps/repo.d.ts +20 -0
- package/dist/cloud-steps/repo.js +163 -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 +53 -0
- package/dist/config.js +155 -0
- package/dist/database.d.ts +65 -0
- package/dist/database.js +142 -0
- package/dist/doctor.d.ts +71 -0
- package/dist/doctor.js +310 -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 +126 -0
- package/dist/new-cloud.js +210 -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 +257 -0
- package/dist/runner.d.ts +65 -0
- package/dist/runner.js +199 -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,12 @@
|
|
|
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
|
+
export declare const langfuseStep: Step<CloudStepContext>;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* The app's Langfuse project and a key pair for it.
|
|
7
|
+
*
|
|
8
|
+
* The project is found by name, so a cold run reuses the one it made before rather than filling
|
|
9
|
+
* the organization with duplicates. The key pair is **not** reused: Langfuse returns a secret key
|
|
10
|
+
* once, at creation, and the only copy is the state file this run may not have — so a new key is
|
|
11
|
+
* created and the old ones keep working, which costs an unused key and never an app that cannot
|
|
12
|
+
* authenticate.
|
|
13
|
+
*/
|
|
14
|
+
export const langfuseStep = {
|
|
15
|
+
name: "langfuse",
|
|
16
|
+
run: async (context) => {
|
|
17
|
+
const { names } = context;
|
|
18
|
+
const required = requireOperatorConfig(context.config, ["HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY"], { env: context.env });
|
|
19
|
+
const langfuse = new LangfuseClient({
|
|
20
|
+
url: required.HF_LANGFUSE_URL,
|
|
21
|
+
orgKey: required.HF_LANGFUSE_ORG_KEY,
|
|
22
|
+
fetch: context.fetch,
|
|
23
|
+
});
|
|
24
|
+
const { data } = await langfuse.listProjects();
|
|
25
|
+
const existing = data.find((project) => project.name === names.appName);
|
|
26
|
+
const projectId = existing?.id ??
|
|
27
|
+
(await langfuse.createProject({ name: names.appName, retention: RETENTION_DAYS })).id;
|
|
28
|
+
context.io.out(`${names.given}: ${existing === undefined ? "created" : "adopting"} the Langfuse project ` +
|
|
29
|
+
names.appName);
|
|
30
|
+
const key = await langfuse.createApiKey(projectId, { note: `hf new ${names.given}` });
|
|
31
|
+
await context.state.patch({
|
|
32
|
+
langfuse: { publicKey: key.publicKey, secretKey: key.secretKey },
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -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,163 @@
|
|
|
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
|
+
* The token reaches `git` through the child's environment alone.
|
|
9
|
+
*
|
|
10
|
+
* Not in argv, where `ps` reads it; not in the remote URL, which `git remote add` writes into
|
|
11
|
+
* `.git/config` and every later `git push` from the operator's shell would then use; and not in
|
|
12
|
+
* anything a step prints. `GIT_CONFIG_COUNT` is how git takes configuration from the environment
|
|
13
|
+
* without a file, so the header outlives neither the child nor this step.
|
|
14
|
+
*/
|
|
15
|
+
export function gitAuthEnv(token) {
|
|
16
|
+
return {
|
|
17
|
+
GIT_CONFIG_COUNT: "1",
|
|
18
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
19
|
+
// GitHub's documented form for a token over HTTPS git; `Bearer` is the API's, not git's.
|
|
20
|
+
GIT_CONFIG_VALUE_0: "Authorization: Basic " + Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The app's private GitHub repository, its first push, and both GitHub Apps on it.
|
|
25
|
+
*
|
|
26
|
+
* The cold-run question is not "is there a repository called this" but "is there a repository
|
|
27
|
+
* holding *this* app": a name someone else took answers 200 just as well, so an existing one is
|
|
28
|
+
* adopted only when its `main` is the commit the install step made. Anything else is refused with
|
|
29
|
+
* the name in the message — pushing over it is not recoverable.
|
|
30
|
+
*/
|
|
31
|
+
export const repoStep = {
|
|
32
|
+
name: "repo",
|
|
33
|
+
run: async (context) => {
|
|
34
|
+
const { names } = context;
|
|
35
|
+
const required = requireOperatorConfig(context.config, ["HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS"], { env: context.env });
|
|
36
|
+
const owner = required.HF_GITHUB_OWNER;
|
|
37
|
+
const repo = names.given;
|
|
38
|
+
const fullName = `${owner}/${repo}`;
|
|
39
|
+
const head = await gitHead(context);
|
|
40
|
+
if (head === undefined) {
|
|
41
|
+
throw new StepFailed(`${context.dir} has no commit to push: the install step has not run`);
|
|
42
|
+
}
|
|
43
|
+
const github = new GithubClient({ token: required.HF_GITHUB_TOKEN, fetch: context.fetch });
|
|
44
|
+
const existing = await getRepository(github, owner, repo);
|
|
45
|
+
let pushNeeded = true;
|
|
46
|
+
if (existing === undefined) {
|
|
47
|
+
const user = await github.getUser(owner);
|
|
48
|
+
const body = { name: repo, private: true };
|
|
49
|
+
if (user.type === "Organization")
|
|
50
|
+
await github.createOrgRepository(owner, body);
|
|
51
|
+
else
|
|
52
|
+
await github.createUserRepository(body);
|
|
53
|
+
context.io.out(`${names.given}: created the private repository ${fullName}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const sha = await mainSha(github, owner, repo);
|
|
57
|
+
if (sha === head) {
|
|
58
|
+
pushNeeded = false;
|
|
59
|
+
context.io.out(`${names.given}: adopting ${fullName}, whose main is ${short(head)}`);
|
|
60
|
+
}
|
|
61
|
+
else if (sha === undefined) {
|
|
62
|
+
context.io.out(`${names.given}: ${fullName} exists and is empty; pushing`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
throw new StepFailed(`${fullName} already exists and its main is ${short(sha)}, not this app's ` +
|
|
66
|
+
`${short(head)}: hf new will not push over a repository it did not create. Rename ` +
|
|
67
|
+
`it, or give the app another name.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// `set-url` rather than `add`, because a rerun finds the remote its predecessor added; the
|
|
71
|
+
// URL carries no credentials, so rewriting it is safe to repeat.
|
|
72
|
+
const url = `https://github.com/${owner}/${repo}.git`;
|
|
73
|
+
const remote = await context.exec("git", ["remote", "get-url", "origin"], {
|
|
74
|
+
cwd: context.dir,
|
|
75
|
+
capture: true,
|
|
76
|
+
});
|
|
77
|
+
await mustRun(context, "git", [
|
|
78
|
+
"remote",
|
|
79
|
+
remote.code === 0 ? "set-url" : "add",
|
|
80
|
+
"origin",
|
|
81
|
+
url,
|
|
82
|
+
]);
|
|
83
|
+
if (pushNeeded) {
|
|
84
|
+
await mustRun(context, "git", ["push", "--set-upstream", "origin", "main"], {
|
|
85
|
+
env: gitAuthEnv(required.HF_GITHUB_TOKEN),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
await assertAppsInstalled(github, githubAppSlugs(context.config), fullName);
|
|
89
|
+
await context.state.patch({ repo: fullName });
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Every `HF_GITHUB_APP_SLUGS` entry installed on the repository, or which one is not.
|
|
94
|
+
*
|
|
95
|
+
* Coolify cannot deploy from a repository its GitHub App cannot see, and that failure otherwise
|
|
96
|
+
* surfaces as a deployment that clones nothing — so it is asserted here, by name, with the URL
|
|
97
|
+
* that fixes it.
|
|
98
|
+
*/
|
|
99
|
+
async function assertAppsInstalled(github, slugs, fullName) {
|
|
100
|
+
const installations = await allInstallations(github);
|
|
101
|
+
for (const slug of slugs) {
|
|
102
|
+
const installation = installations.find((candidate) => candidate.app_slug === slug);
|
|
103
|
+
if (installation === undefined) {
|
|
104
|
+
throw new StepFailed(`the GitHub App ${slug} is not installed for this token: install it on ${fullName} at ` +
|
|
105
|
+
`https://github.com/apps/${slug}/installations/new and re-run hf new`);
|
|
106
|
+
}
|
|
107
|
+
if (!(await installationReaches(github, installation.id, fullName))) {
|
|
108
|
+
throw new StepFailed(`the GitHub App ${slug} is installed but does not reach ${fullName}: add the repository ` +
|
|
109
|
+
`to it at https://github.com/apps/${slug}/installations/new and re-run hf new`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function allInstallations(github) {
|
|
114
|
+
const found = [];
|
|
115
|
+
for (let page = 1;; page += 1) {
|
|
116
|
+
const { total_count, installations } = await github.listInstallations({
|
|
117
|
+
per_page: PER_PAGE,
|
|
118
|
+
page,
|
|
119
|
+
});
|
|
120
|
+
found.push(...installations);
|
|
121
|
+
if (installations.length === 0 || found.length >= total_count)
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function installationReaches(github, installationId, fullName) {
|
|
126
|
+
for (let page = 1, seen = 0;; page += 1) {
|
|
127
|
+
const listed = await github.listInstallationRepositories(installationId, {
|
|
128
|
+
per_page: PER_PAGE,
|
|
129
|
+
page,
|
|
130
|
+
});
|
|
131
|
+
// `all` is an installation with no repository selection to check: everything the account has,
|
|
132
|
+
// now and later, which the paged list can only under-report.
|
|
133
|
+
if (listed.repository_selection === "all")
|
|
134
|
+
return true;
|
|
135
|
+
if (listed.repositories.some((candidate) => candidate.full_name === fullName))
|
|
136
|
+
return true;
|
|
137
|
+
seen += listed.repositories.length;
|
|
138
|
+
if (listed.repositories.length === 0 || seen >= listed.total_count)
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The repository, or `undefined` for the 404 that covers both absent and invisible. */
|
|
143
|
+
async function getRepository(github, owner, repo) {
|
|
144
|
+
try {
|
|
145
|
+
return await github.getRepository(owner, repo);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof ProviderError && error.status === 404)
|
|
149
|
+
return undefined;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** `main`'s sha, or `undefined` for the 409 GitHub answers about a repository with no commits. */
|
|
154
|
+
async function mainSha(github, owner, repo) {
|
|
155
|
+
try {
|
|
156
|
+
return (await github.getReference(owner, repo, "heads/main")).object.sha;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error instanceof ProviderError && error.status === 409)
|
|
160
|
+
return undefined;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { requireOperatorConfig } from "../config.js";
|
|
2
|
+
import { ProviderError } from "../providers/http.js";
|
|
3
|
+
import { SentryClient } from "../providers/sentry.js";
|
|
4
|
+
import { StepFailed } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* The app's Sentry project, and the DSN the deployment reports to.
|
|
7
|
+
*
|
|
8
|
+
* The keys endpoint is both the lookup and the answer: a 200 means the project is there and hands
|
|
9
|
+
* back its DSN in the same request, so a cold run against an existing project neither creates a
|
|
10
|
+
* second one nor needs a list of every project the token can see. Only a 404 creates.
|
|
11
|
+
*
|
|
12
|
+
* The DSN reaches the app through the Coolify env PATCH; it is a credential, so it is recorded in
|
|
13
|
+
* the state and never printed.
|
|
14
|
+
*/
|
|
15
|
+
export const sentryStep = {
|
|
16
|
+
name: "sentry",
|
|
17
|
+
run: async (context) => {
|
|
18
|
+
const { names } = context;
|
|
19
|
+
const required = requireOperatorConfig(context.config, ["HF_SENTRY_TOKEN", "HF_SENTRY_ORG"], {
|
|
20
|
+
env: context.env,
|
|
21
|
+
});
|
|
22
|
+
const org = required.HF_SENTRY_ORG;
|
|
23
|
+
const sentry = new SentryClient({ token: required.HF_SENTRY_TOKEN, fetch: context.fetch });
|
|
24
|
+
let keys = await listKeys(sentry, org, names.appName);
|
|
25
|
+
if (keys === undefined) {
|
|
26
|
+
await sentry.createProject(org, {
|
|
27
|
+
name: names.appName,
|
|
28
|
+
slug: names.appName,
|
|
29
|
+
platform: "node",
|
|
30
|
+
});
|
|
31
|
+
keys = await sentry.listProjectKeys(org, names.appName);
|
|
32
|
+
context.io.out(`${names.given}: created the Sentry project ${org}/${names.appName}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
context.io.out(`${names.given}: adopting the Sentry project ${org}/${names.appName}`);
|
|
36
|
+
}
|
|
37
|
+
const dsn = keys[0]?.dsn.public;
|
|
38
|
+
if (dsn === undefined) {
|
|
39
|
+
throw new StepFailed(`the Sentry project ${org}/${names.appName} has no client key: create one in Sentry and ` +
|
|
40
|
+
"re-run hf new");
|
|
41
|
+
}
|
|
42
|
+
await context.state.patch({ sentryDsn: dsn });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
/** The project's keys, or `undefined` when Sentry says there is no such project. */
|
|
46
|
+
async function listKeys(sentry, org, project) {
|
|
47
|
+
try {
|
|
48
|
+
return await sentry.listProjectKeys(org, project);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof ProviderError && error.status === 404)
|
|
52
|
+
return undefined;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Step } from "../new-cloud.js";
|
|
2
|
+
import { type CloudStepContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where the fetch lands before it becomes the app.
|
|
5
|
+
*
|
|
6
|
+
* Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
|
|
7
|
+
* copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
|
|
8
|
+
*/
|
|
9
|
+
export declare function templateTempDir(dir: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The app's files: giget's fetch of the template, substituted, renamed into place.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is ever written to the target directory except by that rename, so a crash — mid-fetch,
|
|
14
|
+
* mid-substitution — leaves the target absent and the next run free to start over rather than an
|
|
15
|
+
* app-shaped directory the operator has to judge. The leftover temp directory is what that next
|
|
16
|
+
* run removes first.
|
|
17
|
+
*
|
|
18
|
+
* No `.env` is written, unlike `hf new --local`: in the cloud every value lives in Coolify's
|
|
19
|
+
* environment, and a `.env` in the app directory would only be a second copy of the app's secrets
|
|
20
|
+
* on the laptop that ran `hf new`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const templateStep: Step<CloudStepContext>;
|