@hyperfixation/cli 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Graham Lutz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/app.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type AppNames } from "./names.js";
2
+ export declare class NotAnApp extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface ResolvedApp {
6
+ dir: string;
7
+ /** `package.json`'s `name`, which is what `hf new` substituted for `__APP_NAME__`. */
8
+ appName: string;
9
+ names: AppNames;
10
+ /** The app's own migrations, which `hf migrate` and E005 both read. */
11
+ migrationsDir: string;
12
+ /** `.env` under `process.env`, the precedence every dotenv loader uses. */
13
+ env: Record<string, string | undefined>;
14
+ /** `.env` alone, so `hf check` can say a name is missing from the file and not from a shell. */
15
+ envFile: Record<string, string>;
16
+ /** The names `.env.example` declares; the app's env contract, kept equal to `REQUIRED_ENV`. */
17
+ declared: readonly string[];
18
+ }
19
+ /**
20
+ * Finds the app around `dir` and reads everything the other commands need from it.
21
+ *
22
+ * The app name comes from `package.json` rather than from a file `hf new` leaves behind: the
23
+ * template's `package.json` carries `__APP_NAME__`, so after substitution it already *is* the
24
+ * record, and a second copy of the name is a second thing that can disagree.
25
+ */
26
+ export declare function resolveApp(dir?: string): Promise<ResolvedApp>;
package/dist/app.js ADDED
@@ -0,0 +1,57 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { deriveNames } from "./names.js";
4
+ import { readEnvFile } from "./env-file.js";
5
+ /** What makes a directory an app rather than any package: the registry `defineApp` lives in. */
6
+ const APP_ENTRY = path.join("src", "hyperfixation.ts");
7
+ export class NotAnApp extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "NotAnApp";
11
+ }
12
+ }
13
+ /**
14
+ * Finds the app around `dir` and reads everything the other commands need from it.
15
+ *
16
+ * The app name comes from `package.json` rather than from a file `hf new` leaves behind: the
17
+ * template's `package.json` carries `__APP_NAME__`, so after substitution it already *is* the
18
+ * record, and a second copy of the name is a second thing that can disagree.
19
+ */
20
+ export async function resolveApp(dir = process.cwd()) {
21
+ const root = await findAppRoot(path.resolve(dir));
22
+ const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
23
+ if (typeof manifest.name !== "string") {
24
+ throw new NotAnApp(`${root}/package.json has no name`);
25
+ }
26
+ const envFile = await readEnvFile(path.join(root, ".env"));
27
+ const example = await readEnvFile(path.join(root, ".env.example"));
28
+ return {
29
+ dir: root,
30
+ appName: manifest.name,
31
+ names: deriveNames(manifest.name),
32
+ migrationsDir: path.join(root, "drizzle"),
33
+ env: { ...envFile, ...process.env },
34
+ envFile,
35
+ declared: Object.keys(example),
36
+ };
37
+ }
38
+ async function findAppRoot(from) {
39
+ let dir = from;
40
+ for (;;) {
41
+ if (await isFile(path.join(dir, APP_ENTRY)))
42
+ return dir;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) {
45
+ throw new NotAnApp(`no hyperfixation app at or above ${from}: ${APP_ENTRY} is what marks one`);
46
+ }
47
+ dir = parent;
48
+ }
49
+ }
50
+ async function isFile(target) {
51
+ try {
52
+ return (await stat(target)).isFile();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli.js";
3
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,32 @@
1
+ import { type BootstrapResult } from "@hyperfixation/auth";
2
+ import { type ResolvedApp } from "./app.js";
3
+ export interface BootstrapAppOptions {
4
+ dir?: string;
5
+ /** Overrides `HF_BOOTSTRAP_EMAIL`; one of the two has to be set. */
6
+ email?: string;
7
+ name?: string;
8
+ /**
9
+ * Overrides `HF_BOOTSTRAP_BUDGET_USD`; one of the two has to be set. Not part of
10
+ * `REQUIRED_ENV` — like `HF_BOOTSTRAP_EMAIL`, it is a one-shot bootstrap input, not something
11
+ * the deployed `web`/`worker`/`migrate` containers carry, so a flag is `.env.example`'s only
12
+ * alternative for a local run.
13
+ */
14
+ budgetUsd?: string;
15
+ }
16
+ export interface BootstrapAppResult extends BootstrapResult {
17
+ app: ResolvedApp;
18
+ }
19
+ /**
20
+ * `hf bootstrap` — the app's first admin, granted from the box and never from a request.
21
+ *
22
+ * It connects as the **application** role, not the migrator: the row it writes is an ordinary
23
+ * `hf_user` row and the audit line an ordinary `hf_audit` row, and running the one grant that
24
+ * has no admin behind it with owner privileges would be the only reason this command ever
25
+ * needed them. `bootstrapAdmin` does the refusing; this only decides which address to offer it.
26
+ *
27
+ * It also seeds the `hf_app_state` singleton (`ON CONFLICT (id) DO NOTHING`), independently of
28
+ * the admin grant: `AppStateMissing` documents this command as the only thing that seeds it, and
29
+ * a redeploy that reruns `hf bootstrap` against an already-admin'd app must still be able to
30
+ * seed it if it hasn't been yet — `budget_usd` has no default, so nothing else ever will.
31
+ */
32
+ export declare function bootstrapApp(options?: BootstrapAppOptions): Promise<BootstrapAppResult>;
@@ -0,0 +1,47 @@
1
+ import { bootstrapAdmin, BOOTSTRAP_EMAIL_ENV } from "@hyperfixation/auth";
2
+ import { Pool } from "pg";
3
+ import { resolveApp } from "./app.js";
4
+ import { MissingEnv, requireEnv } from "./require-env.js";
5
+ const BOOTSTRAP_BUDGET_ENV = "HF_BOOTSTRAP_BUDGET_USD";
6
+ /**
7
+ * `hf bootstrap` — the app's first admin, granted from the box and never from a request.
8
+ *
9
+ * It connects as the **application** role, not the migrator: the row it writes is an ordinary
10
+ * `hf_user` row and the audit line an ordinary `hf_audit` row, and running the one grant that
11
+ * has no admin behind it with owner privileges would be the only reason this command ever
12
+ * needed them. `bootstrapAdmin` does the refusing; this only decides which address to offer it.
13
+ *
14
+ * It also seeds the `hf_app_state` singleton (`ON CONFLICT (id) DO NOTHING`), independently of
15
+ * the admin grant: `AppStateMissing` documents this command as the only thing that seeds it, and
16
+ * a redeploy that reruns `hf bootstrap` against an already-admin'd app must still be able to
17
+ * seed it if it hasn't been yet — `budget_usd` has no default, so nothing else ever will.
18
+ */
19
+ export async function bootstrapApp(options = {}) {
20
+ const app = await resolveApp(options.dir);
21
+ const databaseUrl = requireEnv(app, "DATABASE_URL");
22
+ const budgetUsd = options.budgetUsd ?? requireEnv(app, BOOTSTRAP_BUDGET_ENV);
23
+ if (!Number.isFinite(Number(budgetUsd)) || Number(budgetUsd) <= 0) {
24
+ throw new Error(`${BOOTSTRAP_BUDGET_ENV} must be a positive number, got ${budgetUsd}`);
25
+ }
26
+ const designated = app.env[BOOTSTRAP_EMAIL_ENV];
27
+ const email = options.email ?? designated;
28
+ if (email === undefined || email === "") {
29
+ throw new MissingEnv([BOOTSTRAP_EMAIL_ENV], app.dir);
30
+ }
31
+ const pool = new Pool({ connectionString: databaseUrl, max: 1 });
32
+ try {
33
+ await pool.query("INSERT INTO hf_app_state (id, budget_usd) VALUES (1, $1) ON CONFLICT (id) DO NOTHING", [budgetUsd]);
34
+ const result = await bootstrapAdmin(pool, {
35
+ email,
36
+ name: options.name,
37
+ // Explicitly, and from the app's `.env` rather than this process's environment: a
38
+ // designation the deploy made is the app's, and `--email` on its own must not become one
39
+ // — that would turn the first-user branch's refusal into a promotion of whoever was typed.
40
+ designatedEmail: designated === undefined || designated === "" ? null : designated,
41
+ });
42
+ return { ...result, app };
43
+ }
44
+ finally {
45
+ await pool.end();
46
+ }
47
+ }
@@ -0,0 +1,26 @@
1
+ import { type RecordTable } from "@hyperfixation/db";
2
+ import { type ResolvedApp } from "./app.js";
3
+ export interface CheckFinding {
4
+ /** `env`, `migrations`, `registry`, or a boot-check code. */
5
+ code: string;
6
+ message: string;
7
+ }
8
+ export interface CheckAppResult {
9
+ app: ResolvedApp;
10
+ findings: readonly CheckFinding[];
11
+ /** Undefined when the registry could not be read; E001–E003 then checked nothing. */
12
+ recordTables: readonly RecordTable[] | undefined;
13
+ ok: boolean;
14
+ }
15
+ /**
16
+ * `hf check` — the three things that are wrong about a deploy before any request reaches it:
17
+ * an env var the app declares and the environment does not carry, a migration in the tree that
18
+ * is not in the database, and E001–E006 against the role the app actually connects as.
19
+ *
20
+ * It reports every finding rather than throwing on the first. A missing var and a pending
21
+ * migration are usually the same mistake — an incomplete deploy — and fixing them one error
22
+ * message at a time is three round trips through a build.
23
+ */
24
+ export declare function checkApp(options?: {
25
+ dir?: string;
26
+ }): Promise<CheckAppResult>;
package/dist/check.js ADDED
@@ -0,0 +1,113 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BootCheckFailure, runBootChecks, } from "@hyperfixation/db";
4
+ import { CORE_MIGRATIONS_DIR, CORE_MIGRATIONS_TABLE } from "@hyperfixation/db/migrator";
5
+ import { Client } from "pg";
6
+ import { resolveApp } from "./app.js";
7
+ import { probeApp } from "./probe.js";
8
+ /** Where drizzle records the app's own migrations; core's are in `CORE_MIGRATIONS_TABLE`. */
9
+ const APP_MIGRATIONS_TABLE = "__drizzle_migrations";
10
+ /**
11
+ * `hf check` — the three things that are wrong about a deploy before any request reaches it:
12
+ * an env var the app declares and the environment does not carry, a migration in the tree that
13
+ * is not in the database, and E001–E006 against the role the app actually connects as.
14
+ *
15
+ * It reports every finding rather than throwing on the first. A missing var and a pending
16
+ * migration are usually the same mistake — an incomplete deploy — and fixing them one error
17
+ * message at a time is three round trips through a build.
18
+ */
19
+ export async function checkApp(options = {}) {
20
+ const app = await resolveApp(options.dir);
21
+ const findings = [];
22
+ findings.push(...missingEnv(app));
23
+ const databaseUrl = app.env.DATABASE_URL;
24
+ const migratorUrl = app.env.MIGRATOR_DATABASE_URL;
25
+ const registry = await probeApp(app);
26
+ if (registry === undefined) {
27
+ findings.push({
28
+ code: "registry",
29
+ message: `could not read ${app.appName}'s registry from src/hyperfixation.ts; ` +
30
+ "E001-E003 checked no record tables",
31
+ });
32
+ }
33
+ if (migratorUrl !== undefined && migratorUrl !== "") {
34
+ findings.push(...(await pendingMigrations(app, migratorUrl)));
35
+ }
36
+ if (databaseUrl !== undefined && databaseUrl !== "") {
37
+ try {
38
+ await runBootChecks({
39
+ databaseUrl,
40
+ recordTables: registry?.recordTables ?? [],
41
+ appMigrationsDir: app.migrationsDir,
42
+ });
43
+ }
44
+ catch (error) {
45
+ findings.push(error instanceof BootCheckFailure
46
+ ? { code: error.code, message: error.message }
47
+ : { code: "boot", message: error.message });
48
+ }
49
+ }
50
+ return { app, findings, recordTables: registry?.recordTables, ok: findings.length === 0 };
51
+ }
52
+ /**
53
+ * The app's env contract is `.env.example`, not a list this package keeps: track B's
54
+ * `compose-envs.test.ts` already pins that file, both compose blocks and `REQUIRED_ENV` to each
55
+ * other, so reading it here means an app that adds a var of its own is checked for it too.
56
+ *
57
+ * Absent, not empty. `.env.example` ships `SENTRY_DSN`, the three Langfuse vars and both
58
+ * provider keys empty on purpose — `instrumentation.ts` and `startWorker()` register neither
59
+ * when unset — so an empty value is a declared local state and only a name that is not there
60
+ * at all is a gap.
61
+ */
62
+ function missingEnv(app) {
63
+ const missing = app.declared.filter((name) => !(name in app.env));
64
+ return missing.length === 0
65
+ ? []
66
+ : [{ code: "env", message: `unset: ${missing.join(", ")}` }];
67
+ }
68
+ async function pendingMigrations(app, migratorUrl) {
69
+ const client = new Client({ connectionString: migratorUrl });
70
+ await client.connect();
71
+ try {
72
+ const findings = [];
73
+ const sets = [
74
+ { what: "core", dir: CORE_MIGRATIONS_DIR, table: CORE_MIGRATIONS_TABLE },
75
+ { what: "app", dir: app.migrationsDir, table: APP_MIGRATIONS_TABLE },
76
+ ];
77
+ for (const set of sets) {
78
+ const inTree = await journalLength(set.dir);
79
+ const applied = await appliedCount(client, set.table);
80
+ if (inTree > applied) {
81
+ findings.push({
82
+ code: "migrations",
83
+ message: `${inTree - applied} ${set.what} migration(s) pending; run hf migrate`,
84
+ });
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+ finally {
90
+ await client.end();
91
+ }
92
+ }
93
+ async function journalLength(dir) {
94
+ try {
95
+ const journal = JSON.parse(await readFile(path.join(dir, "meta", "_journal.json"), "utf8"));
96
+ return journal.entries?.length ?? 0;
97
+ }
98
+ catch {
99
+ return 0;
100
+ }
101
+ }
102
+ /**
103
+ * A missing table is "none applied", which is the state of a database nobody has migrated.
104
+ * Two round trips rather than one `CASE`: Postgres parses the whole statement before it
105
+ * evaluates any of it, so a branch naming a relation that does not exist still fails.
106
+ */
107
+ async function appliedCount(client, table) {
108
+ const { rows: present } = await client.query("SELECT to_regclass($1)::text AS oid", [`drizzle.${table}`]);
109
+ if (present[0]?.oid === null)
110
+ return 0;
111
+ const { rows } = await client.query(`SELECT count(*)::int AS count FROM drizzle.${table}`);
112
+ return rows[0]?.count ?? 0;
113
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up"];
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";
4
+ export interface Io {
5
+ out(line: string): void;
6
+ err(line: string): void;
7
+ }
8
+ /**
9
+ * Parses argv and runs one command, returning the process's exit code.
10
+ *
11
+ * Every failure is caught here and printed as one line: these commands fail for reasons the
12
+ * user can act on — a name that is not an identifier, an unset var, a template checkout that is
13
+ * not there — and a stack trace in front of that sentence buries it.
14
+ */
15
+ export declare function main(argv: readonly string[], io?: Io): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,264 @@
1
+ import { parseArgs } from "node:util";
2
+ import { bootstrapApp } from "./bootstrap.js";
3
+ import { checkApp } from "./check.js";
4
+ import { dev, devBuildSha } from "./dev.js";
5
+ import { generate } from "./gen.js";
6
+ import { migrateApp } from "./migrate.js";
7
+ import { newApp } from "./new.js";
8
+ import { statusTokenApp } from "./status-token.js";
9
+ import { requireTemplateSource } from "./template-source.js";
10
+ import { DEV_BUDGET_USD, upApp } from "./up.js";
11
+ export const COMMANDS = [
12
+ "new",
13
+ "migrate",
14
+ "bootstrap",
15
+ "status-token",
16
+ "check",
17
+ "gen",
18
+ "dev",
19
+ "up",
20
+ ];
21
+ export const USAGE = `hf — the hyperfixation CLI
22
+
23
+ hf new <name> --local copy the template into ./<name>, substitute its placeholders, and
24
+ prompt for the bootstrap admin's email
25
+ --from <dir> template checkout (default: the sibling hyperfixation-template)
26
+ --into <dir> where to create <name> (default: the working directory)
27
+ --email <address> the bootstrap admin's address; skips the prompt
28
+
29
+ hf up install, infra, migrate, bootstrap, status tokens, then hf dev —
30
+ the whole local loop after hf new, safe to rerun; seeds a $10
31
+ budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env
32
+
33
+ hf migrate create the application role, then run the app's migrate.ts
34
+ --skip-roles the cloud path, where the roles already exist
35
+
36
+ hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state
37
+ --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL
38
+ --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD
39
+
40
+ hf status-token provision /api/status's read and write tokens
41
+ --read only the read token; refuses if it is already set
42
+ --write only the write token; refuses if it is already set
43
+ --rotate replace a token that is already set
44
+ (no flags: fills in whichever of the two is unset)
45
+
46
+ hf check declared env, pending migrations, and E001-E006
47
+
48
+ hf gen [generator] the app's turbo generators
49
+
50
+ hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>
51
+ --no-compose leave the dev infrastructure alone
52
+ --compose-only bring the infrastructure up and stop
53
+
54
+ Every command but \`new\` runs against the app at or above the working directory, or --dir.
55
+ `;
56
+ const consoleIo = {
57
+ out: (line) => console.log(line),
58
+ err: (line) => console.error(line),
59
+ };
60
+ /**
61
+ * Parses argv and runs one command, returning the process's exit code.
62
+ *
63
+ * Every failure is caught here and printed as one line: these commands fail for reasons the
64
+ * user can act on — a name that is not an identifier, an unset var, a template checkout that is
65
+ * not there — and a stack trace in front of that sentence buries it.
66
+ */
67
+ export async function main(argv, io = consoleIo) {
68
+ const [command, ...rest] = argv;
69
+ if (command === undefined || command === "--help" || command === "-h") {
70
+ io.out(USAGE);
71
+ return command === undefined ? 1 : 0;
72
+ }
73
+ if (!COMMANDS.includes(command)) {
74
+ io.err(`unknown command ${JSON.stringify(command)}`);
75
+ io.err(USAGE);
76
+ return 1;
77
+ }
78
+ try {
79
+ return await dispatch(command, rest, io);
80
+ }
81
+ catch (error) {
82
+ io.err(`hf ${command}: ${error.message}`);
83
+ return 1;
84
+ }
85
+ }
86
+ async function dispatch(command, argv, io) {
87
+ switch (command) {
88
+ case "new":
89
+ return await commandNew(argv, io);
90
+ case "migrate":
91
+ return await commandMigrate(argv, io);
92
+ case "bootstrap":
93
+ return await commandBootstrap(argv, io);
94
+ case "status-token":
95
+ return await commandStatusToken(argv, io);
96
+ case "check":
97
+ return await commandCheck(argv, io);
98
+ case "gen":
99
+ return await commandGen(argv);
100
+ case "dev":
101
+ return await commandDev(argv, io);
102
+ case "up":
103
+ return await commandUp(argv, io);
104
+ }
105
+ }
106
+ async function commandNew(argv, io) {
107
+ const { values, positionals } = parseArgs({
108
+ args: [...argv],
109
+ options: {
110
+ local: { type: "boolean", default: false },
111
+ from: { type: "string" },
112
+ into: { type: "string" },
113
+ email: { type: "string" },
114
+ },
115
+ allowPositionals: true,
116
+ });
117
+ const name = positionals[0];
118
+ if (name === undefined) {
119
+ io.err("hf new needs a name: hf new <name> --local");
120
+ return 1;
121
+ }
122
+ const from = values.from ?? (await requireTemplateSource());
123
+ const result = await newApp({
124
+ name,
125
+ from,
126
+ into: values.into,
127
+ local: values.local,
128
+ email: values.email,
129
+ });
130
+ io.out(`created ${result.dir} from ${from}`);
131
+ io.out(` app ${result.appName}, database ${result.databaseName}`);
132
+ io.out(` ${result.substituted.length} file(s) substituted` +
133
+ (result.wroteEnv ? ", .env written from .env.example" : "") +
134
+ (result.wroteBootstrapEmail ? ", HF_BOOTSTRAP_EMAIL set" : ""));
135
+ io.out("");
136
+ io.out(`next: cd ${result.given} && hf up`);
137
+ return 0;
138
+ }
139
+ async function commandMigrate(argv, io) {
140
+ const { values } = parseArgs({
141
+ args: [...argv],
142
+ options: { dir: { type: "string" }, "skip-roles": { type: "boolean", default: false } },
143
+ });
144
+ const result = await migrateApp({ dir: values.dir, skipRoles: values["skip-roles"] });
145
+ if (result.roles !== undefined) {
146
+ io.out(`${result.roles.created ? "created" : "updated"} application role ${result.roles.applicationRole}`);
147
+ }
148
+ io.out(`migrated ${result.app.appName}`);
149
+ return 0;
150
+ }
151
+ async function commandBootstrap(argv, io) {
152
+ const { values } = parseArgs({
153
+ args: [...argv],
154
+ options: {
155
+ dir: { type: "string" },
156
+ email: { type: "string" },
157
+ name: { type: "string" },
158
+ "budget-usd": { type: "string" },
159
+ },
160
+ });
161
+ const result = await bootstrapApp({
162
+ dir: values.dir,
163
+ email: values.email,
164
+ name: values.name,
165
+ budgetUsd: values["budget-usd"],
166
+ });
167
+ io.out(`${result.created ? "created" : "promoted"} ${result.email} as ${result.app.appName}'s admin`);
168
+ return 0;
169
+ }
170
+ async function commandStatusToken(argv, io) {
171
+ const { values } = parseArgs({
172
+ args: [...argv],
173
+ options: {
174
+ dir: { type: "string" },
175
+ read: { type: "boolean", default: false },
176
+ write: { type: "boolean", default: false },
177
+ rotate: { type: "boolean", default: false },
178
+ },
179
+ });
180
+ const explicit = values.read || values.write;
181
+ const kinds = explicit
182
+ ? [...(values.read ? ["read"] : []), ...(values.write ? ["write"] : [])]
183
+ : ["read", "write"];
184
+ const result = await statusTokenApp({
185
+ dir: values.dir,
186
+ kinds,
187
+ rotate: values.rotate,
188
+ explicit,
189
+ });
190
+ const generated = kinds.filter((kind) => result.tokens[kind] !== undefined);
191
+ if (generated.length === 0) {
192
+ io.out(`${result.app.appName}: every requested token is already set; nothing to do`);
193
+ return 0;
194
+ }
195
+ io.out(`${result.app.appName}: status token(s) provisioned — shown once, not stored:`);
196
+ for (const kind of generated)
197
+ io.out(` ${kind}: ${result.tokens[kind]}`);
198
+ for (const kind of kinds) {
199
+ if (!generated.includes(kind))
200
+ io.out(` ${kind}: already set, left alone`);
201
+ }
202
+ return 0;
203
+ }
204
+ async function commandCheck(argv, io) {
205
+ const { values } = parseArgs({ args: [...argv], options: { dir: { type: "string" } } });
206
+ const result = await checkApp({ dir: values.dir });
207
+ if (result.ok) {
208
+ io.out(`${result.app.appName}: env, migrations and E001-E006 all clear`);
209
+ return 0;
210
+ }
211
+ for (const finding of result.findings)
212
+ io.err(`${finding.code}: ${finding.message}`);
213
+ return 1;
214
+ }
215
+ async function commandGen(argv) {
216
+ // No `parseArgs`: everything after `hf gen` is the generator's, including flags this CLI
217
+ // happens to share a name with.
218
+ const dirFlag = argv.indexOf("--dir");
219
+ const dir = dirFlag === -1 ? undefined : argv[dirFlag + 1];
220
+ const rest = dirFlag === -1 ? argv : [...argv.slice(0, dirFlag), ...argv.slice(dirFlag + 2)];
221
+ await generate({ dir, args: rest });
222
+ return 0;
223
+ }
224
+ async function commandDev(argv, io) {
225
+ const { values } = parseArgs({
226
+ args: [...argv],
227
+ options: {
228
+ dir: { type: "string" },
229
+ "no-compose": { type: "boolean", default: false },
230
+ "compose-only": { type: "boolean", default: false },
231
+ },
232
+ });
233
+ // Printed before the child starts, not after it exits: `pnpm dev` runs until interrupted,
234
+ // and the version is what the user needs in front of them while it does.
235
+ const buildSha = devBuildSha();
236
+ io.out(`HF_BUILD_SHA=${buildSha}`);
237
+ await dev({
238
+ dir: values.dir,
239
+ skipCompose: values["no-compose"],
240
+ composeOnly: values["compose-only"],
241
+ buildSha,
242
+ });
243
+ return 0;
244
+ }
245
+ async function commandUp(argv, io) {
246
+ const { values } = parseArgs({ args: [...argv], options: { dir: { type: "string" } } });
247
+ const result = await upApp({ dir: values.dir });
248
+ io.out(result.installedDependencies ? "installed dependencies" : "dependencies already installed");
249
+ io.out(result.composeStarted ? "brought up the dev infrastructure" : "no docker-compose.yml to bring up");
250
+ io.out(`migrated ${result.app.appName}`);
251
+ io.out(result.bootstrapped ? "bootstrapped the admin" : "admin already bootstrapped; left alone");
252
+ if (result.budgetDefaulted) {
253
+ io.out(`HF_BOOTSTRAP_BUDGET_USD unset: seeded the monthly LLM budget at $${DEV_BUDGET_USD} (dev default)`);
254
+ }
255
+ io.out(result.tokensProvisioned.length > 0
256
+ ? `provisioned status token(s): ${result.tokensProvisioned.join(", ")}`
257
+ : "status tokens already provisioned");
258
+ // Printed before the child starts, not after it exits: `pnpm dev` runs until interrupted,
259
+ // and the version is what the user needs in front of them while it does.
260
+ const buildSha = devBuildSha();
261
+ io.out(`HF_BUILD_SHA=${buildSha}`);
262
+ await dev({ dir: result.app.dir, skipCompose: true, buildSha });
263
+ return 0;
264
+ }
package/dist/dev.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type ResolvedApp } from "./app.js";
2
+ /** Postgres and mailpit, and nothing else; the app itself runs on the host under `hf dev`. */
3
+ export declare const DEV_COMPOSE_FILE = "docker-compose.yml";
4
+ /**
5
+ * The version a local process runs under.
6
+ *
7
+ * `startWorker()` refuses an `HF_BUILD_SHA` shorter than seven characters and DBOS treats it as
8
+ * the `applicationVersion`, which is the thing a redeploy changes. A timestamp is what makes a
9
+ * restart of `hf dev` look like a redeploy — the run model's bump path is then exercised by an
10
+ * ordinary edit-and-restart loop rather than only by the redeploy suite — and the `dev-` prefix
11
+ * is what guarantees it can never collide with a deployed commit sha.
12
+ */
13
+ export declare function devBuildSha(now?: number): string;
14
+ export interface DevOptions {
15
+ dir?: string;
16
+ /** Skips `docker compose up`; for a database that is already running elsewhere. */
17
+ skipCompose?: boolean;
18
+ /** Runs `docker compose up` and `hf migrate`'s prerequisites, then stops. */
19
+ composeOnly?: boolean;
20
+ buildSha?: string;
21
+ }
22
+ export interface DevResult {
23
+ app: ResolvedApp;
24
+ buildSha: string;
25
+ }
26
+ /**
27
+ * `hf dev` — the dev infrastructure, then the app's own `dev` script under a version the worker
28
+ * will accept.
29
+ *
30
+ * It runs `pnpm dev` rather than reimplementing it: `next dev` is the app's to configure, and
31
+ * the one thing the app's script cannot do for itself is invent a build sha, because a value
32
+ * committed to `.env` would be the same "version" across every restart and a redeploy that
33
+ * changed nothing is not a redeploy.
34
+ */
35
+ export declare function dev(options?: DevOptions): Promise<DevResult>;
36
+ /**
37
+ * `docker compose up`, if the app has a compose file — shared with `hf up`, which brings the
38
+ * same infrastructure up ahead of `hf migrate` rather than duplicating this check.
39
+ *
40
+ * Returns whether compose was actually started, so a caller can report it distinctly from "there
41
+ * was nothing to bring up."
42
+ */
43
+ export declare function ensureCompose(app: ResolvedApp): Promise<boolean>;