@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.
Files changed (75) hide show
  1. package/dist/app.d.ts +15 -2
  2. package/dist/app.js +4 -2
  3. package/dist/backup-source.d.ts +47 -0
  4. package/dist/backup-source.js +107 -0
  5. package/dist/bootstrap.d.ts +2 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/checklist.d.ts +25 -0
  8. package/dist/checklist.js +32 -0
  9. package/dist/cli.d.ts +2 -2
  10. package/dist/cli.js +95 -2
  11. package/dist/cloud-steps/backup.d.ts +17 -0
  12. package/dist/cloud-steps/backup.js +40 -0
  13. package/dist/cloud-steps/context.d.ts +120 -0
  14. package/dist/cloud-steps/context.js +88 -0
  15. package/dist/cloud-steps/coolify.d.ts +84 -0
  16. package/dist/cloud-steps/coolify.js +316 -0
  17. package/dist/cloud-steps/database.d.ts +12 -0
  18. package/dist/cloud-steps/database.js +25 -0
  19. package/dist/cloud-steps/deploy.d.ts +18 -0
  20. package/dist/cloud-steps/deploy.js +110 -0
  21. package/dist/cloud-steps/dns.d.ts +11 -0
  22. package/dist/cloud-steps/dns.js +53 -0
  23. package/dist/cloud-steps/index.d.ts +21 -0
  24. package/dist/cloud-steps/index.js +30 -0
  25. package/dist/cloud-steps/install.d.ts +12 -0
  26. package/dist/cloud-steps/install.js +53 -0
  27. package/dist/cloud-steps/langfuse.d.ts +17 -0
  28. package/dist/cloud-steps/langfuse.js +71 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +198 -0
  31. package/dist/cloud-steps/sentry.d.ts +13 -0
  32. package/dist/cloud-steps/sentry.js +55 -0
  33. package/dist/cloud-steps/template.d.ts +22 -0
  34. package/dist/cloud-steps/template.js +68 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +192 -0
  37. package/dist/database.d.ts +95 -0
  38. package/dist/database.js +226 -0
  39. package/dist/doctor.d.ts +72 -0
  40. package/dist/doctor.js +368 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/migrate.d.ts +11 -0
  44. package/dist/migrate.js +26 -2
  45. package/dist/new-cloud.d.ts +135 -0
  46. package/dist/new-cloud.js +219 -0
  47. package/dist/new.d.ts +2 -0
  48. package/dist/new.js +2 -1
  49. package/dist/providers/cloudflare.d.ts +49 -0
  50. package/dist/providers/cloudflare.js +27 -0
  51. package/dist/providers/coolify.d.ts +148 -0
  52. package/dist/providers/coolify.js +87 -0
  53. package/dist/providers/github.d.ts +117 -0
  54. package/dist/providers/github.js +98 -0
  55. package/dist/providers/http.d.ts +41 -0
  56. package/dist/providers/http.js +56 -0
  57. package/dist/providers/langfuse.d.ts +41 -0
  58. package/dist/providers/langfuse.js +29 -0
  59. package/dist/providers/sentry.d.ts +31 -0
  60. package/dist/providers/sentry.js +27 -0
  61. package/dist/provision-database.d.ts +42 -0
  62. package/dist/provision-database.js +107 -0
  63. package/dist/restore-check.d.ts +91 -0
  64. package/dist/restore-check.js +262 -0
  65. package/dist/runner.d.ts +72 -0
  66. package/dist/runner.js +221 -0
  67. package/dist/secret-file.d.ts +30 -0
  68. package/dist/secret-file.js +69 -0
  69. package/dist/state.d.ts +124 -0
  70. package/dist/state.js +217 -0
  71. package/dist/status-token.d.ts +2 -0
  72. package/dist/status-token.js +1 -1
  73. package/dist/template-source.d.ts +23 -0
  74. package/dist/template-source.js +23 -0
  75. package/package.json +10 -7
package/dist/runner.js ADDED
@@ -0,0 +1,221 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer, connect as connectTcp } from "node:net";
3
+ export class RunnerError extends Error {
4
+ constructor(message, options) {
5
+ super(message, options);
6
+ this.name = "RunnerError";
7
+ }
8
+ }
9
+ /**
10
+ * What `HF_SSH_HOST` may be: `[user@]host`, and nothing that `ssh` would read as an option.
11
+ *
12
+ * A destination beginning `-o` is `ssh`'s own `-oProxyCommand=…`, which runs whatever it says
13
+ * on *this* machine. The config file is the operator's, but it is also the one place an env
14
+ * override reaches, so the destination is checked rather than trusted.
15
+ */
16
+ const SSH_HOST = /^[A-Za-z0-9._-]+(@[A-Za-z0-9._-]+)?$/;
17
+ /**
18
+ * Options every `ssh` invocation carries.
19
+ *
20
+ * `BatchMode=yes` so a missing key fails instead of prompting a non-interactive run for a
21
+ * password; `ControlMaster=no` with `ControlPath=none` so a multiplexed session left over from
22
+ * the operator's own `ssh` cannot silently carry a tunnel that outlives this process, and so a
23
+ * broken master socket cannot wedge provisioning.
24
+ */
25
+ const SSH_OPTIONS = [
26
+ "-o",
27
+ "BatchMode=yes",
28
+ "-o",
29
+ "ControlMaster=no",
30
+ "-o",
31
+ "ControlPath=none",
32
+ ];
33
+ /** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
34
+ export function sshExecArgv(host, command) {
35
+ assertHost(host);
36
+ // `ssh` hands the remote end one string and the login shell splits it, so the array has to be
37
+ // re-quoted for that shell; nothing else in this file ever builds a shell word.
38
+ return ["-T", ...SSH_OPTIONS, host, shellQuote(command)];
39
+ }
40
+ export function sshTunnelArgv(host, localPort, remotePort, remoteHost = TUNNEL_LOOPBACK) {
41
+ assertHost(host);
42
+ assertTunnelHost(remoteHost);
43
+ return [
44
+ "-N",
45
+ "-T",
46
+ ...SSH_OPTIONS,
47
+ "-L",
48
+ `${String(localPort)}:${remoteHost}:${String(remotePort)}`,
49
+ host,
50
+ ];
51
+ }
52
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
53
+ export const TUNNEL_LOOPBACK = "127.0.0.1";
54
+ /** Single-quotes one word for a POSIX remote shell. */
55
+ export function shellQuote(command) {
56
+ return command.map((word) => `'${word.replaceAll("'", `'\\''`)}'`).join(" ");
57
+ }
58
+ export const DEFAULT_TUNNEL_READY_TIMEOUT_MS = 10_000;
59
+ /** A `Runner` that reaches the box over `ssh`. */
60
+ export function createSshRunner(options) {
61
+ const ssh = options.sshPath ?? "ssh";
62
+ assertHost(options.host);
63
+ return {
64
+ exec: async (command, execOptions) => await spawnCollecting(ssh, sshExecArgv(options.host, command), execOptions),
65
+ tunnel: async (remotePort, remoteHost = TUNNEL_LOOPBACK) => {
66
+ const localPort = await freeLocalPort();
67
+ const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort, remoteHost), {
68
+ stdio: ["ignore", "ignore", "pipe"],
69
+ });
70
+ let stderr = "";
71
+ child.stderr?.on("data", (chunk) => {
72
+ stderr += chunk.toString("utf8");
73
+ });
74
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
75
+ try {
76
+ await waitForPort(localPort, options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS, child);
77
+ }
78
+ catch (cause) {
79
+ child.kill("SIGTERM");
80
+ await exited;
81
+ throw new RunnerError(`ssh -L ${String(localPort)}:${remoteHost}:${String(remotePort)} never became ready` +
82
+ (stderr === "" ? "" : `: ${stderr.trim()}`), { cause });
83
+ }
84
+ return {
85
+ localPort,
86
+ close: async () => {
87
+ child.kill("SIGTERM");
88
+ await exited;
89
+ },
90
+ };
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * A `Runner` that runs on this machine and records what it was asked to run.
96
+ *
97
+ * `tunnel()` forwards nothing — it names a port the test already has — so provisioning can be
98
+ * exercised end to end against the test cluster without an `ssh` anywhere in the suite.
99
+ */
100
+ export function createLocalRunner(options = {}) {
101
+ const commands = [];
102
+ const tunnels = [];
103
+ return {
104
+ get commands() {
105
+ return commands;
106
+ },
107
+ get tunnels() {
108
+ return tunnels;
109
+ },
110
+ exec: async (command, execOptions) => {
111
+ commands.push([...command]);
112
+ const [bin, ...args] = command;
113
+ if (bin === undefined)
114
+ throw new RunnerError("exec was given an empty command");
115
+ return await spawnCollecting(bin, args, execOptions);
116
+ },
117
+ tunnel: async (remotePort) => {
118
+ tunnels.push(remotePort);
119
+ const localPort = options.tunnelPort;
120
+ if (localPort === undefined) {
121
+ throw new RunnerError("this local Runner was not given a tunnelPort");
122
+ }
123
+ return { localPort, close: async () => undefined };
124
+ },
125
+ };
126
+ }
127
+ async function spawnCollecting(bin, args, options = {}) {
128
+ const child = spawn(bin, [...args], { stdio: ["pipe", "pipe", "pipe"] });
129
+ let stdout = "";
130
+ let stderr = "";
131
+ child.stdout?.on("data", (chunk) => {
132
+ stdout += chunk.toString("utf8");
133
+ });
134
+ child.stderr?.on("data", (chunk) => {
135
+ stderr += chunk.toString("utf8");
136
+ });
137
+ const closed = new Promise((resolve, reject) => {
138
+ // A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
139
+ // EPIPE; the exit code already says what happened. Any other stdin error is a real failure.
140
+ child.stdin?.on("error", (error) => {
141
+ if (error.code === "EPIPE")
142
+ return;
143
+ child.kill();
144
+ reject(error);
145
+ });
146
+ child.once("error", reject);
147
+ child.once("close", (exitCode) => resolve(exitCode));
148
+ });
149
+ child.stdin?.end(options.input ?? "");
150
+ const code = await closed;
151
+ return { code, stdout, stderr };
152
+ }
153
+ /**
154
+ * A port nothing is listening on, by binding one and letting go.
155
+ *
156
+ * Inherently a race — something else on the machine may take it in between — so the ready check
157
+ * below is what actually decides whether the forward came up.
158
+ */
159
+ async function freeLocalPort() {
160
+ return await new Promise((resolve, reject) => {
161
+ const server = createServer();
162
+ server.once("error", reject);
163
+ server.listen(0, "127.0.0.1", () => {
164
+ const address = server.address();
165
+ if (address === null || typeof address === "string") {
166
+ server.close();
167
+ reject(new RunnerError("could not take a local port for the tunnel"));
168
+ return;
169
+ }
170
+ const { port } = address;
171
+ server.close(() => resolve(port));
172
+ });
173
+ });
174
+ }
175
+ async function waitForPort(port, timeoutMs, child) {
176
+ const deadline = Date.now() + timeoutMs;
177
+ for (;;) {
178
+ if (child.exitCode !== null || child.signalCode !== null) {
179
+ throw new RunnerError(`ssh exited before the forward on ${String(port)} was ready`);
180
+ }
181
+ if (await canConnect(port))
182
+ return;
183
+ if (Date.now() >= deadline) {
184
+ throw new RunnerError(`nothing accepted on 127.0.0.1:${String(port)} within ${String(timeoutMs)}ms`);
185
+ }
186
+ await new Promise((resolve) => setTimeout(resolve, 100));
187
+ }
188
+ }
189
+ async function canConnect(port) {
190
+ return await new Promise((resolve) => {
191
+ let socket;
192
+ const done = (ok) => {
193
+ socket.removeAllListeners();
194
+ socket.destroy();
195
+ resolve(ok);
196
+ };
197
+ socket = connectTcp({ port, host: "127.0.0.1" });
198
+ socket.setTimeout(1_000);
199
+ socket.once("connect", () => done(true));
200
+ socket.once("error", () => done(false));
201
+ socket.once("timeout", () => done(false));
202
+ });
203
+ }
204
+ /**
205
+ * The far-side end of a `-L` forward, which is a bare address or hostname and nothing else.
206
+ *
207
+ * It arrives from `docker inspect` on the box rather than from the operator, and `-L` takes its
208
+ * three fields colon-separated, so a value carrying a colon or a space would silently become a
209
+ * different forward than the one asked for.
210
+ */
211
+ function assertTunnelHost(remoteHost) {
212
+ if (!TUNNEL_HOST.test(remoteHost)) {
213
+ throw new RunnerError(`a tunnel's remote host must match ${TUNNEL_HOST.source}, got ${JSON.stringify(remoteHost)}`);
214
+ }
215
+ }
216
+ const TUNNEL_HOST = /^[A-Za-z0-9._-]+$/;
217
+ function assertHost(host) {
218
+ if (!SSH_HOST.test(host)) {
219
+ throw new RunnerError(`HF_SSH_HOST must match ${SSH_HOST.source}, got ${JSON.stringify(host)}`);
220
+ }
221
+ }
@@ -0,0 +1,30 @@
1
+ /** Owner read/write and nothing else — the only mode the operator's files are read at. */
2
+ export declare const SECRET_MODE = 384;
3
+ /**
4
+ * A file that holds secrets was readable or writable by someone other than its owner.
5
+ *
6
+ * Thrown *after* the file has been tightened to 0600: the operator's next run works, and this
7
+ * run stops loudly enough that they can decide whether anything in it needs rotating. Nothing
8
+ * of the file's contents reaches the message.
9
+ */
10
+ export declare class InsecureFileMode extends Error {
11
+ readonly file: string;
12
+ /** The permission bits found, before they were tightened. */
13
+ readonly found: number;
14
+ constructor(file: string, found: number);
15
+ }
16
+ /**
17
+ * Reads a file that holds secrets, or `undefined` when it does not exist.
18
+ *
19
+ * Refuses a file any other user can reach, having first tightened it — see `InsecureFileMode`.
20
+ */
21
+ export declare function readSecretFile(file: string): Promise<string | undefined>;
22
+ /**
23
+ * Writes a file that holds secrets, at 0600, atomically.
24
+ *
25
+ * Temp file then `rename`, because the state cache is written between provisioning steps: a
26
+ * crash partway through a write would otherwise leave a half-written JSON file that the next
27
+ * run refuses, and the passwords and tokens it held are not recoverable from anywhere else.
28
+ * The temp file is created 0600 too, so the secrets are never briefly world-readable.
29
+ */
30
+ export declare function writeSecretFile(file: string, contents: string): Promise<void>;
@@ -0,0 +1,69 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** Owner read/write and nothing else — the only mode the operator's files are read at. */
5
+ export const SECRET_MODE = 0o600;
6
+ /** The mode a secret directory is created at, so a new file cannot be listed by anyone else. */
7
+ const SECRET_DIR_MODE = 0o700;
8
+ /**
9
+ * A file that holds secrets was readable or writable by someone other than its owner.
10
+ *
11
+ * Thrown *after* the file has been tightened to 0600: the operator's next run works, and this
12
+ * run stops loudly enough that they can decide whether anything in it needs rotating. Nothing
13
+ * of the file's contents reaches the message.
14
+ */
15
+ export class InsecureFileMode extends Error {
16
+ file;
17
+ /** The permission bits found, before they were tightened. */
18
+ found;
19
+ constructor(file, found) {
20
+ super(`${file} was mode ${found.toString(8).padStart(4, "0")}, not 0600: other users on this ` +
21
+ `machine could read it. It has been tightened to 0600 — rerun, and rotate anything it ` +
22
+ `holds that may have been read.`);
23
+ this.name = "InsecureFileMode";
24
+ this.file = file;
25
+ this.found = found;
26
+ }
27
+ }
28
+ /**
29
+ * Reads a file that holds secrets, or `undefined` when it does not exist.
30
+ *
31
+ * Refuses a file any other user can reach, having first tightened it — see `InsecureFileMode`.
32
+ */
33
+ export async function readSecretFile(file) {
34
+ let mode;
35
+ try {
36
+ mode = (await stat(file)).mode & 0o777;
37
+ }
38
+ catch (cause) {
39
+ if (cause.code === "ENOENT")
40
+ return undefined;
41
+ throw cause;
42
+ }
43
+ if ((mode & 0o077) !== 0) {
44
+ await chmod(file, SECRET_MODE);
45
+ throw new InsecureFileMode(file, mode);
46
+ }
47
+ return await readFile(file, "utf8");
48
+ }
49
+ /**
50
+ * Writes a file that holds secrets, at 0600, atomically.
51
+ *
52
+ * Temp file then `rename`, because the state cache is written between provisioning steps: a
53
+ * crash partway through a write would otherwise leave a half-written JSON file that the next
54
+ * run refuses, and the passwords and tokens it held are not recoverable from anywhere else.
55
+ * The temp file is created 0600 too, so the secrets are never briefly world-readable.
56
+ */
57
+ export async function writeSecretFile(file, contents) {
58
+ await mkdir(path.dirname(file), { recursive: true, mode: SECRET_DIR_MODE });
59
+ const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
60
+ try {
61
+ await writeFile(temp, contents, { mode: SECRET_MODE });
62
+ await chmod(temp, SECRET_MODE);
63
+ await rename(temp, file);
64
+ }
65
+ catch (cause) {
66
+ await rm(temp, { force: true });
67
+ throw cause;
68
+ }
69
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The ten steps of a cloud `hf new`, in the order it runs them. A step is recorded only once
3
+ * everything it created is in the state file, so a rerun resumes at the first unrecorded step
4
+ * rather than re-creating what the previous run already paid for.
5
+ *
6
+ * `database` sits immediately before `coolify` because it is the one step that *rotates*: a cold
7
+ * run gives the roles new passwords, and a deployed app keeps the old ones until the envs are
8
+ * PATCHed and the app redeployed. Every fallible external create — the repo, the backup, Sentry,
9
+ * Langfuse, the DNS record — therefore happens first, so a failure in one of them cannot leave a
10
+ * live app holding credentials nothing will replace.
11
+ */
12
+ export declare const STEPS: readonly ["template", "install", "repo", "backup", "sentry", "langfuse", "dns", "database", "coolify", "deploy"];
13
+ export type StepName = (typeof STEPS)[number];
14
+ export interface StepRecord {
15
+ /** ISO 8601, when the step finished. */
16
+ doneAt: string;
17
+ }
18
+ export interface CoolifyState {
19
+ projectUuid?: string;
20
+ appUuid?: string;
21
+ /**
22
+ * `secretsHash` of the secrets the last bulk env PATCH carried.
23
+ *
24
+ * The `coolify` step is "done" only while this matches what the state holds now: a cold run
25
+ * that rotated the role passwords leaves the deployed app authenticating with the old ones, and
26
+ * a recorded `steps.coolify` would otherwise make a rerun skip the one PATCH that fixes it.
27
+ */
28
+ envsSecretsHash?: string;
29
+ }
30
+ /** The three role passwords a cold run generates. Nothing else on the laptop has a copy. */
31
+ export interface DatabaseState {
32
+ migratorPassword?: string;
33
+ applicationPassword?: string;
34
+ readonlyPassword?: string;
35
+ }
36
+ export interface LangfuseState {
37
+ publicKey?: string;
38
+ secretKey?: string;
39
+ }
40
+ export interface StatusTokenState {
41
+ read?: string;
42
+ write?: string;
43
+ }
44
+ /**
45
+ * Everything one cloud app's provisioning produced, so that a rerun, `hf doctor` and
46
+ * `hf restore-check` do not have to ask five APIs what already exists.
47
+ *
48
+ * Every field is optional but `steps`: the file is written between steps, and each step fills
49
+ * in its own part.
50
+ */
51
+ export interface AppState {
52
+ steps: Partial<Record<StepName, StepRecord>>;
53
+ /** `owner/name` of the app's GitHub repository. */
54
+ repo?: string;
55
+ coolify?: CoolifyState;
56
+ database?: DatabaseState;
57
+ sentryDsn?: string;
58
+ langfuse?: LangfuseState;
59
+ statusTokens?: StatusTokenState;
60
+ /**
61
+ * `BETTER_AUTH_SECRET` — generated once, for this app, and kept only here: it is the one
62
+ * `REQUIRED_ENV` secret no provider hands back, so nothing else can be asked for it again.
63
+ * Never printed; it reaches the app through the Coolify env PATCH alone.
64
+ */
65
+ betterAuthSecret?: string;
66
+ lastRestoreCheckAt?: string;
67
+ lastDeployedSha?: string;
68
+ }
69
+ /** 32 random bytes, the length `better-auth` documents, in the form an env var can carry. */
70
+ export declare function generateBetterAuthSecret(): string;
71
+ /**
72
+ * A fingerprint of every secret the Coolify envs carry, so a rotation is detectable without
73
+ * keeping a second copy of the secrets themselves.
74
+ *
75
+ * Only the values matter, not the state around them: `coolify.envsSecretsHash` is compared
76
+ * against this, and a step that rotated a password must come out different.
77
+ */
78
+ export declare function secretsHash(state: AppState): string;
79
+ /**
80
+ * The state file exists but does not parse, or does not have the shape above.
81
+ *
82
+ * Never repaired and never overwritten: the file is the only copy of three database passwords,
83
+ * two status tokens and a Langfuse secret key, so silently starting a fresh one would strand a
84
+ * deployed app whose roles nothing can log in as any more. The operator is told where the file
85
+ * is and decides.
86
+ */
87
+ export declare class AppStateInvalid extends Error {
88
+ readonly file: string;
89
+ constructor(file: string, problem: string);
90
+ }
91
+ export interface AppStateStore {
92
+ readonly file: string;
93
+ /** The state as last written. Replaced, never mutated in place, by `patch` and `markDone`. */
94
+ readonly state: AppState;
95
+ isDone(step: StepName): boolean;
96
+ markDone(step: StepName): Promise<void>;
97
+ /**
98
+ * Forgets a step, so the next run repeats it.
99
+ *
100
+ * What a rotation needs: the secrets the `coolify` step PATCHed and the `deploy` that published
101
+ * them are no longer true of the app, and `patch` merges rather than removes.
102
+ */
103
+ clearDone(step: StepName): Promise<void>;
104
+ /**
105
+ * Merges `changes` in and writes the file. Object-valued keys (`coolify`, `database`,
106
+ * `langfuse`, `statusTokens`) merge field by field, so a step can record the one uuid it
107
+ * learned without carrying the rest.
108
+ */
109
+ patch(changes: Partial<AppState>): Promise<void>;
110
+ }
111
+ /** `~/.config/hf/state`, one `<name>.json` per app. */
112
+ export declare function stateDir(env?: NodeJS.ProcessEnv): string;
113
+ export declare function stateFile(name: string, env?: NodeJS.ProcessEnv): string;
114
+ export interface OpenAppStateOptions {
115
+ /** The directory holding `<name>.json`. Defaults to `stateDir()`. */
116
+ dir?: string;
117
+ env?: NodeJS.ProcessEnv;
118
+ }
119
+ /**
120
+ * Opens one app's state cache, or starts an empty one when the file does not exist yet.
121
+ *
122
+ * The file must be mode 0600; a looser one is tightened and then refused (`InsecureFileMode`).
123
+ */
124
+ export declare function openAppState(name: string, options?: OpenAppStateOptions): Promise<AppStateStore>;
package/dist/state.js ADDED
@@ -0,0 +1,217 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import { configHome } from "./config.js";
4
+ import { readSecretFile, writeSecretFile } from "./secret-file.js";
5
+ /**
6
+ * The ten steps of a cloud `hf new`, in the order it runs them. A step is recorded only once
7
+ * everything it created is in the state file, so a rerun resumes at the first unrecorded step
8
+ * rather than re-creating what the previous run already paid for.
9
+ *
10
+ * `database` sits immediately before `coolify` because it is the one step that *rotates*: a cold
11
+ * run gives the roles new passwords, and a deployed app keeps the old ones until the envs are
12
+ * PATCHed and the app redeployed. Every fallible external create — the repo, the backup, Sentry,
13
+ * Langfuse, the DNS record — therefore happens first, so a failure in one of them cannot leave a
14
+ * live app holding credentials nothing will replace.
15
+ */
16
+ export const STEPS = [
17
+ "template",
18
+ "install",
19
+ "repo",
20
+ "backup",
21
+ "sentry",
22
+ "langfuse",
23
+ "dns",
24
+ "database",
25
+ "coolify",
26
+ "deploy",
27
+ ];
28
+ /** 32 random bytes, the length `better-auth` documents, in the form an env var can carry. */
29
+ export function generateBetterAuthSecret() {
30
+ return randomBytes(32).toString("base64url");
31
+ }
32
+ /**
33
+ * A fingerprint of every secret the Coolify envs carry, so a rotation is detectable without
34
+ * keeping a second copy of the secrets themselves.
35
+ *
36
+ * Only the values matter, not the state around them: `coolify.envsSecretsHash` is compared
37
+ * against this, and a step that rotated a password must come out different.
38
+ */
39
+ export function secretsHash(state) {
40
+ const database = state.database ?? {};
41
+ const material = [
42
+ database.migratorPassword,
43
+ database.applicationPassword,
44
+ database.readonlyPassword,
45
+ state.betterAuthSecret,
46
+ state.statusTokens?.read,
47
+ state.statusTokens?.write,
48
+ ];
49
+ return createHash("sha256").update(JSON.stringify(material)).digest("hex");
50
+ }
51
+ /**
52
+ * The state file exists but does not parse, or does not have the shape above.
53
+ *
54
+ * Never repaired and never overwritten: the file is the only copy of three database passwords,
55
+ * two status tokens and a Langfuse secret key, so silently starting a fresh one would strand a
56
+ * deployed app whose roles nothing can log in as any more. The operator is told where the file
57
+ * is and decides.
58
+ */
59
+ export class AppStateInvalid extends Error {
60
+ file;
61
+ constructor(file, problem) {
62
+ super(`${file} is not a usable hf state file: ${problem}. It is the only copy of this app's ` +
63
+ `passwords and tokens, so nothing was overwritten — inspect it, or move it aside to ` +
64
+ `start over.`);
65
+ this.name = "AppStateInvalid";
66
+ this.file = file;
67
+ }
68
+ }
69
+ /** `~/.config/hf/state`, one `<name>.json` per app. */
70
+ export function stateDir(env = process.env) {
71
+ return path.join(configHome(env), "state");
72
+ }
73
+ export function stateFile(name, env = process.env) {
74
+ return path.join(stateDir(env), `${name}.json`);
75
+ }
76
+ /**
77
+ * Opens one app's state cache, or starts an empty one when the file does not exist yet.
78
+ *
79
+ * The file must be mode 0600; a looser one is tightened and then refused (`InsecureFileMode`).
80
+ */
81
+ export async function openAppState(name, options = {}) {
82
+ const env = options.env ?? process.env;
83
+ const file = options.dir === undefined ? stateFile(name, env) : path.join(options.dir, `${name}.json`);
84
+ const contents = await readSecretFile(file);
85
+ let state = contents === undefined ? { steps: {} } : parseAppState(contents, file);
86
+ const write = async (next) => {
87
+ await writeSecretFile(file, `${JSON.stringify(next, null, 2)}\n`);
88
+ state = next;
89
+ };
90
+ return {
91
+ file,
92
+ get state() {
93
+ return state;
94
+ },
95
+ isDone: (step) => state.steps[step] !== undefined,
96
+ markDone: async (step) => {
97
+ await write({
98
+ ...state,
99
+ steps: { ...state.steps, [step]: { doneAt: new Date().toISOString() } },
100
+ });
101
+ },
102
+ clearDone: async (step) => {
103
+ if (state.steps[step] === undefined)
104
+ return;
105
+ const steps = { ...state.steps };
106
+ delete steps[step];
107
+ await write({ ...state, steps });
108
+ },
109
+ patch: async (changes) => {
110
+ await write(merge(state, changes));
111
+ },
112
+ };
113
+ }
114
+ function merge(state, changes) {
115
+ return {
116
+ ...state,
117
+ ...changes,
118
+ steps: { ...state.steps, ...changes.steps },
119
+ ...mergeObject(state, changes, "coolify"),
120
+ ...mergeObject(state, changes, "database"),
121
+ ...mergeObject(state, changes, "langfuse"),
122
+ ...mergeObject(state, changes, "statusTokens"),
123
+ };
124
+ }
125
+ function mergeObject(state, changes, key) {
126
+ const change = changes[key];
127
+ if (change === undefined)
128
+ return {};
129
+ return { [key]: { ...state[key], ...change } };
130
+ }
131
+ /**
132
+ * The schema, hand-written because the whole contract is nine keys and a step map, and because
133
+ * an unknown key has to be an error rather than something a permissive parser drops on the next
134
+ * write.
135
+ */
136
+ function parseAppState(contents, file) {
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(contents);
140
+ }
141
+ catch {
142
+ throw new AppStateInvalid(file, "it is not valid JSON");
143
+ }
144
+ const root = asObject(parsed, file, "the top level");
145
+ const state = { steps: {} };
146
+ for (const [key, value] of Object.entries(root)) {
147
+ switch (key) {
148
+ case "steps":
149
+ state.steps = parseSteps(value, file);
150
+ break;
151
+ case "repo":
152
+ case "sentryDsn":
153
+ case "betterAuthSecret":
154
+ case "lastRestoreCheckAt":
155
+ case "lastDeployedSha":
156
+ state[key] = asString(value, file, key);
157
+ break;
158
+ case "coolify":
159
+ state.coolify = parseFields(value, file, key, [
160
+ "projectUuid",
161
+ "appUuid",
162
+ "envsSecretsHash",
163
+ ]);
164
+ break;
165
+ case "database":
166
+ state.database = parseFields(value, file, key, [
167
+ "migratorPassword",
168
+ "applicationPassword",
169
+ "readonlyPassword",
170
+ ]);
171
+ break;
172
+ case "langfuse":
173
+ state.langfuse = parseFields(value, file, key, ["publicKey", "secretKey"]);
174
+ break;
175
+ case "statusTokens":
176
+ state.statusTokens = parseFields(value, file, key, ["read", "write"]);
177
+ break;
178
+ default:
179
+ throw new AppStateInvalid(file, `${JSON.stringify(key)} is not an hf state key`);
180
+ }
181
+ }
182
+ if (root.steps === undefined)
183
+ throw new AppStateInvalid(file, "it has no steps");
184
+ return state;
185
+ }
186
+ function parseSteps(value, file) {
187
+ const steps = {};
188
+ for (const [name, record] of Object.entries(asObject(value, file, "steps"))) {
189
+ if (!STEPS.includes(name)) {
190
+ throw new AppStateInvalid(file, `steps.${name} is not a step of hf new`);
191
+ }
192
+ const fields = asObject(record, file, `steps.${name}`);
193
+ steps[name] = { doneAt: asString(fields.doneAt, file, `steps.${name}.doneAt`) };
194
+ }
195
+ return steps;
196
+ }
197
+ function parseFields(value, file, key, fields) {
198
+ const out = {};
199
+ for (const [field, entry] of Object.entries(asObject(value, file, key))) {
200
+ if (!fields.includes(field)) {
201
+ throw new AppStateInvalid(file, `${key}.${field} is not an hf state key`);
202
+ }
203
+ out[field] = asString(entry, file, `${key}.${field}`);
204
+ }
205
+ return out;
206
+ }
207
+ function asObject(value, file, what) {
208
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
209
+ throw new AppStateInvalid(file, `${what} is not an object`);
210
+ }
211
+ return value;
212
+ }
213
+ function asString(value, file, what) {
214
+ if (typeof value !== "string")
215
+ throw new AppStateInvalid(file, `${what} is not a string`);
216
+ return value;
217
+ }
@@ -19,6 +19,8 @@ export interface StatusTokenAppOptions {
19
19
  * up yet" would also require `--rotate`, and `--rotate` would take out the other, live token.
20
20
  */
21
21
  explicit?: boolean;
22
+ /** Connection URLs in place of a `.env`; see `ResolveAppOptions`. */
23
+ env?: Record<string, string>;
22
24
  }
23
25
  export interface StatusTokenAppResult {
24
26
  app: ResolvedApp;
@@ -35,7 +35,7 @@ export class StatusTokenAlreadySet extends Error {
35
35
  * caller; nothing here ever stores or logs it.
36
36
  */
37
37
  export async function statusTokenApp(options = {}) {
38
- const app = await resolveApp(options.dir);
38
+ const app = await resolveApp(options.dir, { env: options.env });
39
39
  const databaseUrl = requireEnv(app, "DATABASE_URL");
40
40
  const kinds = options.kinds ?? ["read", "write"];
41
41
  const pool = new Pool({ connectionString: databaseUrl, max: 1 });