@hyperfixation/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/app.d.ts +15 -2
  2. package/dist/app.js +4 -2
  3. package/dist/backup-source.d.ts +47 -0
  4. package/dist/backup-source.js +107 -0
  5. package/dist/bootstrap.d.ts +2 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/checklist.d.ts +25 -0
  8. package/dist/checklist.js +32 -0
  9. package/dist/cli.d.ts +2 -2
  10. package/dist/cli.js +95 -2
  11. package/dist/cloud-steps/backup.d.ts +17 -0
  12. package/dist/cloud-steps/backup.js +40 -0
  13. package/dist/cloud-steps/context.d.ts +120 -0
  14. package/dist/cloud-steps/context.js +88 -0
  15. package/dist/cloud-steps/coolify.d.ts +74 -0
  16. package/dist/cloud-steps/coolify.js +300 -0
  17. package/dist/cloud-steps/database.d.ts +12 -0
  18. package/dist/cloud-steps/database.js +25 -0
  19. package/dist/cloud-steps/deploy.d.ts +18 -0
  20. package/dist/cloud-steps/deploy.js +110 -0
  21. package/dist/cloud-steps/dns.d.ts +11 -0
  22. package/dist/cloud-steps/dns.js +53 -0
  23. package/dist/cloud-steps/index.d.ts +21 -0
  24. package/dist/cloud-steps/index.js +30 -0
  25. package/dist/cloud-steps/install.d.ts +12 -0
  26. package/dist/cloud-steps/install.js +53 -0
  27. package/dist/cloud-steps/langfuse.d.ts +12 -0
  28. package/dist/cloud-steps/langfuse.js +35 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +163 -0
  31. package/dist/cloud-steps/sentry.d.ts +13 -0
  32. package/dist/cloud-steps/sentry.js +55 -0
  33. package/dist/cloud-steps/template.d.ts +22 -0
  34. package/dist/cloud-steps/template.js +68 -0
  35. package/dist/config.d.ts +53 -0
  36. package/dist/config.js +155 -0
  37. package/dist/database.d.ts +65 -0
  38. package/dist/database.js +142 -0
  39. package/dist/doctor.d.ts +71 -0
  40. package/dist/doctor.js +310 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/migrate.d.ts +11 -0
  44. package/dist/migrate.js +26 -2
  45. package/dist/new-cloud.d.ts +126 -0
  46. package/dist/new-cloud.js +210 -0
  47. package/dist/new.d.ts +2 -0
  48. package/dist/new.js +2 -1
  49. package/dist/providers/cloudflare.d.ts +49 -0
  50. package/dist/providers/cloudflare.js +27 -0
  51. package/dist/providers/coolify.d.ts +148 -0
  52. package/dist/providers/coolify.js +87 -0
  53. package/dist/providers/github.d.ts +117 -0
  54. package/dist/providers/github.js +98 -0
  55. package/dist/providers/http.d.ts +41 -0
  56. package/dist/providers/http.js +56 -0
  57. package/dist/providers/langfuse.d.ts +41 -0
  58. package/dist/providers/langfuse.js +29 -0
  59. package/dist/providers/sentry.d.ts +31 -0
  60. package/dist/providers/sentry.js +27 -0
  61. package/dist/provision-database.d.ts +42 -0
  62. package/dist/provision-database.js +107 -0
  63. package/dist/restore-check.d.ts +91 -0
  64. package/dist/restore-check.js +257 -0
  65. package/dist/runner.d.ts +65 -0
  66. package/dist/runner.js +199 -0
  67. package/dist/secret-file.d.ts +30 -0
  68. package/dist/secret-file.js +69 -0
  69. package/dist/state.d.ts +124 -0
  70. package/dist/state.js +217 -0
  71. package/dist/status-token.d.ts +2 -0
  72. package/dist/status-token.js +1 -1
  73. package/dist/template-source.d.ts +23 -0
  74. package/dist/template-source.js +23 -0
  75. package/package.json +10 -7
package/dist/app.d.ts CHANGED
@@ -9,12 +9,25 @@ export interface ResolvedApp {
9
9
  names: AppNames;
10
10
  /** The app's own migrations, which `hf migrate` and E005 both read. */
11
11
  migrationsDir: string;
12
- /** `.env` under `process.env`, the precedence every dotenv loader uses. */
12
+ /** `.env` under `process.env` under the caller's overlay; the precedence dotenv loaders use. */
13
13
  env: Record<string, string | undefined>;
14
14
  /** `.env` alone, so `hf check` can say a name is missing from the file and not from a shell. */
15
15
  envFile: Record<string, string>;
16
16
  /** The names `.env.example` declares; the app's env contract, kept equal to `REQUIRED_ENV`. */
17
17
  declared: readonly string[];
18
+ /** The overlay this app was resolved with, so a command can hand the same one to a child. */
19
+ envOverlay: Record<string, string>;
20
+ }
21
+ export interface ResolveAppOptions {
22
+ /**
23
+ * Names → values laid over `.env` **and** over `process.env`.
24
+ *
25
+ * The cloud path has no `.env` to read: `hf new` runs `migrate`, `bootstrap` and
26
+ * `status-token` against a freshly provisioned app through the E2 tunnel, holding the
27
+ * connection URLs itself. Highest precedence rather than lowest, because a laptop that
28
+ * happens to export `DATABASE_URL` for its own dev app must not redirect a cloud run into it.
29
+ */
30
+ env?: Record<string, string>;
18
31
  }
19
32
  /**
20
33
  * Finds the app around `dir` and reads everything the other commands need from it.
@@ -23,4 +36,4 @@ export interface ResolvedApp {
23
36
  * template's `package.json` carries `__APP_NAME__`, so after substitution it already *is* the
24
37
  * record, and a second copy of the name is a second thing that can disagree.
25
38
  */
26
- export declare function resolveApp(dir?: string): Promise<ResolvedApp>;
39
+ export declare function resolveApp(dir?: string, options?: ResolveAppOptions): Promise<ResolvedApp>;
package/dist/app.js CHANGED
@@ -17,7 +17,7 @@ export class NotAnApp extends Error {
17
17
  * template's `package.json` carries `__APP_NAME__`, so after substitution it already *is* the
18
18
  * record, and a second copy of the name is a second thing that can disagree.
19
19
  */
20
- export async function resolveApp(dir = process.cwd()) {
20
+ export async function resolveApp(dir = process.cwd(), options = {}) {
21
21
  const root = await findAppRoot(path.resolve(dir));
22
22
  const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
23
23
  if (typeof manifest.name !== "string") {
@@ -25,14 +25,16 @@ export async function resolveApp(dir = process.cwd()) {
25
25
  }
26
26
  const envFile = await readEnvFile(path.join(root, ".env"));
27
27
  const example = await readEnvFile(path.join(root, ".env.example"));
28
+ const envOverlay = options.env ?? {};
28
29
  return {
29
30
  dir: root,
30
31
  appName: manifest.name,
31
32
  names: deriveNames(manifest.name),
32
33
  migrationsDir: path.join(root, "drizzle"),
33
- env: { ...envFile, ...process.env },
34
+ env: { ...envFile, ...process.env, ...envOverlay },
34
35
  envFile,
35
36
  declared: Object.keys(example),
37
+ envOverlay,
36
38
  };
37
39
  }
38
40
  async function findAppRoot(from) {
@@ -0,0 +1,47 @@
1
+ import type { Runner } from "./runner.js";
2
+ /** Where Coolify writes a database backup it was not told to send to S3. */
3
+ export declare const COOLIFY_BACKUP_DIR = "/data/coolify/backups";
4
+ export type BackupSourceKind = "local-directory" | "hetzner-s3";
5
+ export interface BackupDump {
6
+ /** A path `pg_restore` can read on the runner's side. */
7
+ path: string;
8
+ /** The dump's mtime — what `hf restore-check` reports an age against. */
9
+ takenAt: Date;
10
+ /** Where it was found, for the message when nothing was. */
11
+ from: string;
12
+ }
13
+ /** One place `hf restore-check` can get the newest dump of a database from. */
14
+ export interface BackupSource {
15
+ readonly kind: BackupSourceKind;
16
+ /** The newest dump of `databaseName`, or `undefined` when the source holds none. */
17
+ newest(databaseName: string): Promise<BackupDump | undefined>;
18
+ }
19
+ export declare class BackupSourceError extends Error {
20
+ readonly kind: BackupSourceKind;
21
+ constructor(kind: BackupSourceKind, message: string);
22
+ }
23
+ export interface LocalDirectoryBackupSourceOptions {
24
+ /** Where the dumps are listed and stat'ed: the box, or this machine in a test. */
25
+ runner: Runner;
26
+ /** Defaults to `COOLIFY_BACKUP_DIR`. */
27
+ directory?: string;
28
+ /** How deep under `directory` to look; Coolify nests dumps per database uuid. */
29
+ maxDepth?: number;
30
+ }
31
+ /**
32
+ * The dumps Coolify keeps on the box's own disk, newest by mtime.
33
+ *
34
+ * mtime rather than the filename's timestamp: Coolify's naming is one of the things Phase 0
35
+ * never pinned down, so the match is `*<database>*` and the ordering is the filesystem's.
36
+ */
37
+ export declare function createLocalDirectoryBackupSource(options: LocalDirectoryBackupSourceOptions): BackupSource;
38
+ /**
39
+ * The dumps in Hetzner's object storage — **not implemented**.
40
+ *
41
+ * Risk 3 of the Phase 3 order is still open: nobody has looked in `/data/coolify/backups` on the
42
+ * box, so which of the two sources is the real one is unknown, and the operator config has no
43
+ * S3 key to read (`CONFIG_KEYS` carries none). Rather than guess a bucket layout and a key name,
44
+ * this refuses with the one sentence that says what to do. The key, when it exists, belongs in
45
+ * the operator config and is never written into an app's environment.
46
+ */
47
+ export declare function createS3BackupSource(): BackupSource;
@@ -0,0 +1,107 @@
1
+ import { APP_ID } from "./names.js";
2
+ /** Where Coolify writes a database backup it was not told to send to S3. */
3
+ export const COOLIFY_BACKUP_DIR = "/data/coolify/backups";
4
+ export class BackupSourceError extends Error {
5
+ kind;
6
+ constructor(kind, message) {
7
+ super(`${kind}: ${message}`);
8
+ this.name = "BackupSourceError";
9
+ this.kind = kind;
10
+ }
11
+ }
12
+ const DEFAULT_MAX_DEPTH = 4;
13
+ /**
14
+ * The dumps Coolify keeps on the box's own disk, newest by mtime.
15
+ *
16
+ * mtime rather than the filename's timestamp: Coolify's naming is one of the things Phase 0
17
+ * never pinned down, so the match is `*<database>*` and the ordering is the filesystem's.
18
+ */
19
+ export function createLocalDirectoryBackupSource(options) {
20
+ const directory = options.directory ?? COOLIFY_BACKUP_DIR;
21
+ const { runner } = options;
22
+ return {
23
+ kind: "local-directory",
24
+ newest: async (databaseName) => {
25
+ assertGlobSafe(databaseName);
26
+ const listed = await runner.exec([
27
+ "find",
28
+ directory,
29
+ "-maxdepth",
30
+ String(options.maxDepth ?? DEFAULT_MAX_DEPTH),
31
+ "-type",
32
+ "f",
33
+ "-name",
34
+ `*${databaseName}*`,
35
+ ]);
36
+ if (listed.code !== 0) {
37
+ throw new BackupSourceError("local-directory", `could not list ${directory} on the box: ${listed.stderr.trim()}`);
38
+ }
39
+ const paths = listed.stdout.split("\n").filter((line) => line !== "");
40
+ if (paths.length === 0)
41
+ return undefined;
42
+ const mtimes = await mtimeSeconds(runner, paths);
43
+ let newest;
44
+ for (const [index, path] of paths.entries()) {
45
+ const seconds = mtimes[index];
46
+ if (seconds === undefined)
47
+ continue;
48
+ if (newest === undefined || seconds * 1000 > newest.takenAt.getTime()) {
49
+ newest = { path, takenAt: new Date(seconds * 1000), from: directory };
50
+ }
51
+ }
52
+ return newest;
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * `stat`, whichever one the far side has: GNU spells the mtime `-c %Y` and BSD `-f %m`, and the
58
+ * box is Ubuntu while the suite runs on whatever the laptop is.
59
+ */
60
+ async function mtimeSeconds(runner, paths) {
61
+ for (const flags of [
62
+ ["-c", "%Y"],
63
+ ["-f", "%m"],
64
+ ]) {
65
+ const result = await runner.exec(["stat", ...flags, "--", ...paths]);
66
+ if (result.code !== 0)
67
+ continue;
68
+ const seconds = result.stdout
69
+ .split("\n")
70
+ .filter((line) => line.trim() !== "")
71
+ .map((line) => Number(line.trim()));
72
+ if (seconds.length === paths.length && seconds.every((value) => Number.isFinite(value))) {
73
+ return seconds;
74
+ }
75
+ }
76
+ throw new BackupSourceError("local-directory", "neither GNU nor BSD stat reported an mtime");
77
+ }
78
+ /**
79
+ * The dumps in Hetzner's object storage — **not implemented**.
80
+ *
81
+ * Risk 3 of the Phase 3 order is still open: nobody has looked in `/data/coolify/backups` on the
82
+ * box, so which of the two sources is the real one is unknown, and the operator config has no
83
+ * S3 key to read (`CONFIG_KEYS` carries none). Rather than guess a bucket layout and a key name,
84
+ * this refuses with the one sentence that says what to do. The key, when it exists, belongs in
85
+ * the operator config and is never written into an app's environment.
86
+ */
87
+ export function createS3BackupSource() {
88
+ return {
89
+ kind: "hetzner-s3",
90
+ newest: async () => {
91
+ throw new BackupSourceError("hetzner-s3", "downloading a dump from Hetzner object storage is not implemented: the operator config " +
92
+ "has no S3 key yet (risk 3 of the Phase 3 order). Run without --from-s3 against " +
93
+ `${COOLIFY_BACKUP_DIR} on the box, or pass --backup-dir.`);
94
+ },
95
+ };
96
+ }
97
+ /**
98
+ * The database name reaches `find` as a `*name*` pattern, so it may not carry a glob character.
99
+ *
100
+ * `deriveNames` has already refused anything outside `APP_ID` by the time `hf restore-check`
101
+ * gets here; this is the check at the point where the string becomes a pattern.
102
+ */
103
+ function assertGlobSafe(databaseName) {
104
+ if (!APP_ID.test(databaseName)) {
105
+ throw new BackupSourceError("local-directory", `${JSON.stringify(databaseName)} is not a usable database name`);
106
+ }
107
+ }
@@ -12,6 +12,8 @@ export interface BootstrapAppOptions {
12
12
  * alternative for a local run.
13
13
  */
14
14
  budgetUsd?: string;
15
+ /** Connection URLs in place of a `.env`; see `ResolveAppOptions`. */
16
+ env?: Record<string, string>;
15
17
  }
16
18
  export interface BootstrapAppResult extends BootstrapResult {
17
19
  app: ResolvedApp;
package/dist/bootstrap.js CHANGED
@@ -17,7 +17,7 @@ const BOOTSTRAP_BUDGET_ENV = "HF_BOOTSTRAP_BUDGET_USD";
17
17
  * seed it if it hasn't been yet — `budget_usd` has no default, so nothing else ever will.
18
18
  */
19
19
  export async function bootstrapApp(options = {}) {
20
- const app = await resolveApp(options.dir);
20
+ const app = await resolveApp(options.dir, { env: options.env });
21
21
  const databaseUrl = requireEnv(app, "DATABASE_URL");
22
22
  const budgetUsd = options.budgetUsd ?? requireEnv(app, BOOTSTRAP_BUDGET_ENV);
23
23
  if (!Number.isFinite(Number(budgetUsd)) || Number(budgetUsd) <= 0) {
@@ -0,0 +1,25 @@
1
+ import type { AppNames } from "./names.js";
2
+ export interface ChecklistInput {
3
+ names: AppNames;
4
+ /** `<name>.<HF_BASE_DOMAIN>`, the host Coolify serves and the passkey relying-party origin. */
5
+ fqdn: string;
6
+ /** `owner/name` of the app's repository, as the state cache recorded it. */
7
+ repo?: string;
8
+ /** The hostname the app's containers reach Postgres by; Metabase is on the same network. */
9
+ dbHost: string;
10
+ /** Where the passwords and tokens this names actually live. */
11
+ stateFile: string;
12
+ /** Which of `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` the environment carries. */
13
+ providerKeysSent: readonly string[];
14
+ /** Lines the steps themselves appended to `context.checklist`, in the order they ran. */
15
+ fromSteps?: readonly string[];
16
+ /** The `/api/status` write token, and only on the run that minted it. */
17
+ writeToken?: string;
18
+ }
19
+ /**
20
+ * What `hf new` cannot do for the operator, printed when the ten steps are done.
21
+ *
22
+ * No secret is printed but the write token, and that only on the run that generated it: every
23
+ * other value this names is in the state file, which is the one place any of them exists.
24
+ */
25
+ export declare function checklistLines(input: ChecklistInput): string[];
@@ -0,0 +1,32 @@
1
+ import { roleNames } from "@hyperfixation/db/migrator";
2
+ /**
3
+ * What `hf new` cannot do for the operator, printed when the ten steps are done.
4
+ *
5
+ * No secret is printed but the write token, and that only on the run that generated it: every
6
+ * other value this names is in the state file, which is the one place any of them exists.
7
+ */
8
+ export function checklistLines(input) {
9
+ const { names, fqdn } = input;
10
+ const origin = `https://${fqdn}`;
11
+ const lines = [`${names.given} is deployed at ${origin}. What is left is yours:`, ""];
12
+ const add = (...parts) => {
13
+ lines.push(` - ${parts[0]}`, ...parts.slice(1).map((part) => ` ${part}`), "");
14
+ };
15
+ if (input.providerKeysSent.length === 0) {
16
+ add("The app is serving FIXTURE drafts: no ANTHROPIC_API_KEY or OPENAI_API_KEY was configured,", "so llm.run returns canned text and /api/status reports llm.mode=fixtures. Paste a key", "into Coolify's environment for this application and redeploy.");
17
+ }
18
+ add("Third-party keys go into Coolify's environment for this application — and every new name", "also has to be added to REQUIRED_ENV (src/env.ts), .env.example and all three", "environment: blocks of docker-compose.prod.yml. A name Coolify carries that compose does", "not interpolate never reaches a container, and hf new refuses the next run until they agree.");
19
+ add(`Metabase reads through ${roleNames(names.appName).readonly}:`, `postgres://${roleNames(names.appName).readonly}:<password>@${input.dbHost}:5432/${names.databaseName}`, `the password is database.readonlyPassword in ${input.stateFile}.`);
20
+ add(`Add the line "${input.repo ?? `<owner>/${names.given}`}" to downstream.txt in`, "hyperfixation-core, so the core bump opens a pull request here. (Phase 4 creates the file;", "until then this is the line it will need.)");
21
+ add("Merge a core-bump/* pull request only when its checks are green — a bump that fails CI is a", "core release this app cannot take. hf doctor lists the open ones with their status.");
22
+ add(`Enrol your passkey from exactly ${origin}, not an alias and not an IP: APP_URL is the`, "relying-party origin, and a different host enrols a credential the app will never accept.");
23
+ add(`Prove the backup restores: hf restore-check ${names.given}.`);
24
+ // Each step's own line, last: a step knows something about its half that nothing here does.
25
+ for (const line of input.fromSteps ?? [])
26
+ add(line);
27
+ if (input.writeToken !== undefined) {
28
+ add("The /api/status write token, shown once and stored nowhere but the state file:", input.writeToken);
29
+ }
30
+ // One trailing blank from the last `add`, which reads as a gap before the shell prompt.
31
+ return lines.slice(0, -1);
32
+ }
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up"];
1
+ export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up", "doctor", "restore-check"];
2
2
  export type Command = (typeof COMMANDS)[number];
3
- export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\nEvery command but `new` runs against the app at or above the working directory, or --dir.\n";
3
+ export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> provision the app in the cloud: fetch the template, push a private\n repo, register a backup, Sentry, Langfuse and DNS, create the\n database and its roles, create the Coolify application and its\n environment, deploy, and print what is left to do by hand.\n Resumable \u2014 a rerun repeats only what did not finish\n --budget-usd <amount> the app's monthly LLM budget (required; no default)\n --email <address> the bootstrap admin's address (required)\n --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf doctor [name] every deployed app in the state cache, or one: /api/status under its\n read token, the deployed version against main, E006 as the app role,\n the last restore check, and open core-bump PRs. Exits 1 on any finding\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\n hf restore-check <name> restore the newest hf_<name> dump beside the live database and\n compare row counts; exits 1 on any mismatch\n --backup-dir <dir> where the dumps are (default: Coolify's on the box)\n --from-s3 read the dump from object storage (not implemented)\n\nEvery command but `new`, `doctor` and `restore-check` runs against the app at or above the working directory, or --dir.\n";
4
4
  export interface Io {
5
5
  out(line: string): void;
6
6
  err(line: string): void;
package/dist/cli.js CHANGED
@@ -2,9 +2,12 @@ import { parseArgs } from "node:util";
2
2
  import { bootstrapApp } from "./bootstrap.js";
3
3
  import { checkApp } from "./check.js";
4
4
  import { dev, devBuildSha } from "./dev.js";
5
+ import { doctor, doctorLines } from "./doctor.js";
5
6
  import { generate } from "./gen.js";
6
7
  import { migrateApp } from "./migrate.js";
7
8
  import { newApp } from "./new.js";
9
+ import { newAppCloud } from "./new-cloud.js";
10
+ import { formatRestoreCheck, restoreCheckApp } from "./restore-check.js";
8
11
  import { statusTokenApp } from "./status-token.js";
9
12
  import { requireTemplateSource } from "./template-source.js";
10
13
  import { DEV_BUDGET_USD, upApp } from "./up.js";
@@ -17,9 +20,21 @@ export const COMMANDS = [
17
20
  "gen",
18
21
  "dev",
19
22
  "up",
23
+ "doctor",
24
+ "restore-check",
20
25
  ];
21
26
  export const USAGE = `hf — the hyperfixation CLI
22
27
 
28
+ hf new <name> provision the app in the cloud: fetch the template, push a private
29
+ repo, register a backup, Sentry, Langfuse and DNS, create the
30
+ database and its roles, create the Coolify application and its
31
+ environment, deploy, and print what is left to do by hand.
32
+ Resumable — a rerun repeats only what did not finish
33
+ --budget-usd <amount> the app's monthly LLM budget (required; no default)
34
+ --email <address> the bootstrap admin's address (required)
35
+ --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)
36
+ --into <dir> where to create <name> (default: the working directory)
37
+
23
38
  hf new <name> --local copy the template into ./<name>, substitute its placeholders, and
24
39
  prompt for the bootstrap admin's email
25
40
  --from <dir> template checkout (default: the sibling hyperfixation-template)
@@ -45,13 +60,22 @@ export const USAGE = `hf — the hyperfixation CLI
45
60
 
46
61
  hf check declared env, pending migrations, and E001-E006
47
62
 
63
+ hf doctor [name] every deployed app in the state cache, or one: /api/status under its
64
+ read token, the deployed version against main, E006 as the app role,
65
+ the last restore check, and open core-bump PRs. Exits 1 on any finding
66
+
48
67
  hf gen [generator] the app's turbo generators
49
68
 
50
69
  hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>
51
70
  --no-compose leave the dev infrastructure alone
52
71
  --compose-only bring the infrastructure up and stop
53
72
 
54
- Every command but \`new\` runs against the app at or above the working directory, or --dir.
73
+ hf restore-check <name> restore the newest hf_<name> dump beside the live database and
74
+ compare row counts; exits 1 on any mismatch
75
+ --backup-dir <dir> where the dumps are (default: Coolify's on the box)
76
+ --from-s3 read the dump from object storage (not implemented)
77
+
78
+ Every command but \`new\`, \`doctor\` and \`restore-check\` runs against the app at or above the working directory, or --dir.
55
79
  `;
56
80
  const consoleIo = {
57
81
  out: (line) => console.log(line),
@@ -101,6 +125,10 @@ async function dispatch(command, argv, io) {
101
125
  return await commandDev(argv, io);
102
126
  case "up":
103
127
  return await commandUp(argv, io);
128
+ case "doctor":
129
+ return await commandDoctor(argv, io);
130
+ case "restore-check":
131
+ return await commandRestoreCheck(argv, io);
104
132
  }
105
133
  }
106
134
  async function commandNew(argv, io) {
@@ -111,14 +139,18 @@ async function commandNew(argv, io) {
111
139
  from: { type: "string" },
112
140
  into: { type: "string" },
113
141
  email: { type: "string" },
142
+ "budget-usd": { type: "string" },
114
143
  },
115
144
  allowPositionals: true,
116
145
  });
117
146
  const name = positionals[0];
118
147
  if (name === undefined) {
119
- io.err("hf new needs a name: hf new <name> --local");
148
+ io.err("hf new needs a name: hf new <name> --budget-usd <amount> --email <address>");
120
149
  return 1;
121
150
  }
151
+ if (!values.local) {
152
+ return await commandNewCloud(name, values, io);
153
+ }
122
154
  const from = values.from ?? (await requireTemplateSource());
123
155
  const result = await newApp({
124
156
  name,
@@ -136,6 +168,37 @@ async function commandNew(argv, io) {
136
168
  io.out(`next: cd ${result.given} && hf up`);
137
169
  return 0;
138
170
  }
171
+ /**
172
+ * The cloud half: both inputs it cannot invent are refused up front.
173
+ *
174
+ * Neither has a default. `--email` designates the one admin an app is ever granted without an
175
+ * admin behind it, and a budget nobody chose is a deployed app that either cannot spend or
176
+ * cannot stop — `hf bootstrap` has refused an unset one since Phase 1, and this is the same rule
177
+ * one command earlier, where the answer costs nothing yet.
178
+ */
179
+ async function commandNewCloud(name, values, io) {
180
+ const missing = [
181
+ ...(values["budget-usd"] === undefined ? ["--budget-usd <amount>"] : []),
182
+ ...(values.email === undefined ? ["--email <address>"] : []),
183
+ ];
184
+ if (missing.length > 0) {
185
+ io.err(`hf new ${name} needs ${missing.join(" and ")}: a cloud app has no prompt and no .env to ` +
186
+ "carry either. Pass --local for Phase 1's local copy.");
187
+ return 1;
188
+ }
189
+ const result = await newAppCloud({
190
+ name,
191
+ budgetUsd: values["budget-usd"],
192
+ email: values.email,
193
+ from: values.from,
194
+ into: values.into,
195
+ io,
196
+ });
197
+ io.out("");
198
+ for (const line of result.checklist)
199
+ io.out(line);
200
+ return 0;
201
+ }
139
202
  async function commandMigrate(argv, io) {
140
203
  const { values } = parseArgs({
141
204
  args: [...argv],
@@ -212,6 +275,13 @@ async function commandCheck(argv, io) {
212
275
  io.err(`${finding.code}: ${finding.message}`);
213
276
  return 1;
214
277
  }
278
+ async function commandDoctor(argv, io) {
279
+ const { positionals } = parseArgs({ args: [...argv], allowPositionals: true });
280
+ const result = await doctor({ name: positionals[0] });
281
+ for (const line of doctorLines(result))
282
+ io.out(line);
283
+ return result.ok ? 0 : 1;
284
+ }
215
285
  async function commandGen(argv) {
216
286
  // No `parseArgs`: everything after `hf gen` is the generator's, including flags this CLI
217
287
  // happens to share a name with.
@@ -242,6 +312,29 @@ async function commandDev(argv, io) {
242
312
  });
243
313
  return 0;
244
314
  }
315
+ async function commandRestoreCheck(argv, io) {
316
+ const { values, positionals } = parseArgs({
317
+ args: [...argv],
318
+ options: {
319
+ "backup-dir": { type: "string" },
320
+ "from-s3": { type: "boolean", default: false },
321
+ },
322
+ allowPositionals: true,
323
+ });
324
+ const name = positionals[0];
325
+ if (name === undefined) {
326
+ io.err("hf restore-check needs a name: hf restore-check <name>");
327
+ return 1;
328
+ }
329
+ const result = await restoreCheckApp({
330
+ app: name,
331
+ backupDir: values["backup-dir"],
332
+ fromS3: values["from-s3"],
333
+ });
334
+ for (const line of formatRestoreCheck(result))
335
+ io.out(line);
336
+ return result.ok ? 0 : 1;
337
+ }
245
338
  async function commandUp(argv, io) {
246
339
  const { values } = parseArgs({ args: [...argv], options: { dir: { type: "string" } } });
247
340
  const result = await upApp({ dir: values.dir });
@@ -0,0 +1,17 @@
1
+ import type { Step } from "../new-cloud.js";
2
+ import type { CloudStepContext } from "./context.js";
3
+ /**
4
+ * A daily dump of the app's database, and a checklist line about the one thing the API cannot
5
+ * answer.
6
+ *
7
+ * Every other step detects its own previous work by name; this one cannot. Coolify documents the
8
+ * backups list as "Content is very complex. Will be implemented later.", so there is no response
9
+ * to read a schedule out of, and a POST is the only way to find out anything at all. So the step
10
+ * registers the schedule and says out loud that a duplicate from an earlier run is possible —
11
+ * being told to look is better than a silent second dump, and better than a step that never runs
12
+ * because it cannot prove it is needed.
13
+ *
14
+ * The state record is written by the runner once this resolves, so a failed POST leaves the step
15
+ * unrecorded and the next run registers it instead.
16
+ */
17
+ export declare const backupStep: Step<CloudStepContext>;
@@ -0,0 +1,40 @@
1
+ import { requireOperatorConfig } from "../config.js";
2
+ import { CoolifyClient } from "../providers/coolify.js";
3
+ /**
4
+ * A daily dump of the app's database, and a checklist line about the one thing the API cannot
5
+ * answer.
6
+ *
7
+ * Every other step detects its own previous work by name; this one cannot. Coolify documents the
8
+ * backups list as "Content is very complex. Will be implemented later.", so there is no response
9
+ * to read a schedule out of, and a POST is the only way to find out anything at all. So the step
10
+ * registers the schedule and says out loud that a duplicate from an earlier run is possible —
11
+ * being told to look is better than a silent second dump, and better than a step that never runs
12
+ * because it cannot prove it is needed.
13
+ *
14
+ * The state record is written by the runner once this resolves, so a failed POST leaves the step
15
+ * unrecorded and the next run registers it instead.
16
+ */
17
+ export const backupStep = {
18
+ name: "backup",
19
+ run: async (context) => {
20
+ const { names } = context;
21
+ const required = requireOperatorConfig(context.config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_COOLIFY_POSTGRES_UUID"], { env: context.env });
22
+ const coolify = new CoolifyClient({
23
+ url: required.HF_COOLIFY_URL,
24
+ token: required.HF_COOLIFY_TOKEN,
25
+ fetch: context.fetch,
26
+ });
27
+ await coolify.createDatabaseBackup(required.HF_COOLIFY_POSTGRES_UUID, {
28
+ frequency: "daily",
29
+ enabled: true,
30
+ // This app's database alone: `dump_all` would put every app on the cluster in one dump, and
31
+ // E5 restores one database at a time.
32
+ databases_to_backup: names.databaseName,
33
+ dump_all: false,
34
+ backup_now: false,
35
+ });
36
+ context.io.out(`${names.given}: registered a daily backup of ${names.databaseName}`);
37
+ context.checklist.push(`check Coolify for an existing backup schedule for ${names.databaseName} — the API cannot ` +
38
+ `list schedules, so an earlier run may have left a second one`);
39
+ },
40
+ };