@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 +21 -0
- package/dist/app.d.ts +26 -0
- package/dist/app.js +57 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/bootstrap.d.ts +32 -0
- package/dist/bootstrap.js +47 -0
- package/dist/check.d.ts +26 -0
- package/dist/check.js +113 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +264 -0
- package/dist/dev.d.ts +43 -0
- package/dist/dev.js +63 -0
- package/dist/env-file.d.ts +10 -0
- package/dist/env-file.js +44 -0
- package/dist/gen.d.ts +29 -0
- package/dist/gen.js +44 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/migrate.d.ts +25 -0
- package/dist/migrate.js +37 -0
- package/dist/names.d.ts +35 -0
- package/dist/names.js +44 -0
- package/dist/new.d.ts +57 -0
- package/dist/new.js +151 -0
- package/dist/probe.d.ts +19 -0
- package/dist/probe.js +41 -0
- package/dist/require-env.d.ts +7 -0
- package/dist/require-env.js +15 -0
- package/dist/roles.d.ts +34 -0
- package/dist/roles.js +51 -0
- package/dist/spawn.d.ts +20 -0
- package/dist/spawn.js +34 -0
- package/dist/status-token.d.ts +42 -0
- package/dist/status-token.js +90 -0
- package/dist/template-source.d.ts +16 -0
- package/dist/template-source.js +46 -0
- package/dist/up.d.ts +40 -0
- package/dist/up.js +76 -0
- package/package.json +52 -0
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
package/dist/bin.js
ADDED
|
@@ -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
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -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>;
|