@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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class MissingEnv extends Error {
|
|
2
|
+
names;
|
|
3
|
+
constructor(names, dir) {
|
|
4
|
+
super(`${names.join(", ")} unset: set ${names.length === 1 ? "it" : "them"} in ${dir}/.env`);
|
|
5
|
+
this.name = "MissingEnv";
|
|
6
|
+
this.names = names;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Reads one var out of `.env`-under-`process.env`, or names the file the user has to edit. */
|
|
10
|
+
export function requireEnv(app, name) {
|
|
11
|
+
const value = app.env[name];
|
|
12
|
+
if (value === undefined || value === "")
|
|
13
|
+
throw new MissingEnv([name], app.dir);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface LocalRoleOptions {
|
|
2
|
+
databaseName: string;
|
|
3
|
+
applicationRole: string;
|
|
4
|
+
applicationPassword: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LocalRoleResult {
|
|
7
|
+
applicationRole: string;
|
|
8
|
+
created: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates the application role for a **local** app, on the migrator's own connection.
|
|
12
|
+
*
|
|
13
|
+
* `@hyperfixation/db`'s `provisionRoles` is the cloud path and is not usable here: it derives
|
|
14
|
+
* both role names from the app name and records the application role's default privileges
|
|
15
|
+
* `FOR ROLE hf_<app>_migrator`. Locally the migrator is the compose superuser `postgres` — that
|
|
16
|
+
* is what track B's `.env.example` puts in `MIGRATOR_DATABASE_URL` — so privileges recorded for
|
|
17
|
+
* a role that creates nothing would leave the application role unable to read the tables the
|
|
18
|
+
* migrator just made. The grant here is recorded for whoever is connected, which is the role
|
|
19
|
+
* that will own every `hf_*` object on this machine.
|
|
20
|
+
*
|
|
21
|
+
* The connection therefore has to be able to create a role, which the compose superuser can and
|
|
22
|
+
* a deployed migrator role cannot — `hf migrate --skip-roles` is the cloud path, where `hf new`
|
|
23
|
+
* created both roles before the first deploy.
|
|
24
|
+
*
|
|
25
|
+
* Idempotent: an existing role is re-`ALTER`ed to the password `.env` declares, and the grants
|
|
26
|
+
* on already-existing objects are re-issued, so running it after the first `hf migrate` is a
|
|
27
|
+
* no-op rather than a permissions gap.
|
|
28
|
+
*/
|
|
29
|
+
export declare function provisionLocalRoles(migratorConnectionString: string, options: LocalRoleOptions): Promise<LocalRoleResult>;
|
|
30
|
+
/** The role and password a `DATABASE_URL` carries — what the application role has to become. */
|
|
31
|
+
export declare function credentialsOf(connectionString: string): {
|
|
32
|
+
user: string;
|
|
33
|
+
password: string;
|
|
34
|
+
};
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { quoteIdent } from "@hyperfixation/db";
|
|
2
|
+
import { Client } from "pg";
|
|
3
|
+
/**
|
|
4
|
+
* Creates the application role for a **local** app, on the migrator's own connection.
|
|
5
|
+
*
|
|
6
|
+
* `@hyperfixation/db`'s `provisionRoles` is the cloud path and is not usable here: it derives
|
|
7
|
+
* both role names from the app name and records the application role's default privileges
|
|
8
|
+
* `FOR ROLE hf_<app>_migrator`. Locally the migrator is the compose superuser `postgres` — that
|
|
9
|
+
* is what track B's `.env.example` puts in `MIGRATOR_DATABASE_URL` — so privileges recorded for
|
|
10
|
+
* a role that creates nothing would leave the application role unable to read the tables the
|
|
11
|
+
* migrator just made. The grant here is recorded for whoever is connected, which is the role
|
|
12
|
+
* that will own every `hf_*` object on this machine.
|
|
13
|
+
*
|
|
14
|
+
* The connection therefore has to be able to create a role, which the compose superuser can and
|
|
15
|
+
* a deployed migrator role cannot — `hf migrate --skip-roles` is the cloud path, where `hf new`
|
|
16
|
+
* created both roles before the first deploy.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: an existing role is re-`ALTER`ed to the password `.env` declares, and the grants
|
|
19
|
+
* on already-existing objects are re-issued, so running it after the first `hf migrate` is a
|
|
20
|
+
* no-op rather than a permissions gap.
|
|
21
|
+
*/
|
|
22
|
+
export async function provisionLocalRoles(migratorConnectionString, options) {
|
|
23
|
+
const role = quoteIdent(options.applicationRole);
|
|
24
|
+
const client = new Client({ connectionString: migratorConnectionString });
|
|
25
|
+
await client.connect();
|
|
26
|
+
try {
|
|
27
|
+
const { rows } = await client.query("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1) AS exists", [options.applicationRole]);
|
|
28
|
+
const created = rows[0]?.exists !== true;
|
|
29
|
+
await client.query(`${created ? "CREATE" : "ALTER"} ROLE ${role} LOGIN PASSWORD ` +
|
|
30
|
+
`${quoteLiteral(options.applicationPassword)} CONNECTION LIMIT 25`);
|
|
31
|
+
await client.query(`GRANT CONNECT ON DATABASE ${quoteIdent(options.databaseName)} TO ${role}`);
|
|
32
|
+
await client.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
|
33
|
+
await client.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA public ` +
|
|
34
|
+
`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role}`);
|
|
35
|
+
await client.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role}`);
|
|
36
|
+
await client.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${role}`);
|
|
37
|
+
await client.query(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${role}`);
|
|
38
|
+
return { applicationRole: options.applicationRole, created };
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await client.end();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The role and password a `DATABASE_URL` carries — what the application role has to become. */
|
|
45
|
+
export function credentialsOf(connectionString) {
|
|
46
|
+
const url = new URL(connectionString);
|
|
47
|
+
return { user: decodeURIComponent(url.username), password: decodeURIComponent(url.password) };
|
|
48
|
+
}
|
|
49
|
+
function quoteLiteral(value) {
|
|
50
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
51
|
+
}
|
package/dist/spawn.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type StdioOptions } from "node:child_process";
|
|
2
|
+
export declare class CommandFailed extends Error {
|
|
3
|
+
readonly exitCode: number | null;
|
|
4
|
+
readonly signal: NodeJS.Signals | null;
|
|
5
|
+
constructor(command: string, exitCode: number | null, signal: NodeJS.Signals | null);
|
|
6
|
+
}
|
|
7
|
+
export interface RunOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
stdio?: StdioOptions;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs a child to completion and throws on a non-zero exit.
|
|
14
|
+
*
|
|
15
|
+
* `stdio: "inherit"` by default: every command this spawns — the migrator, `turbo gen`, `next
|
|
16
|
+
* dev` — is one whose output the user is meant to read, and buffering it would turn a prompt
|
|
17
|
+
* into a hang. `SIGINT` is deliberately not forwarded; the child shares the terminal's process
|
|
18
|
+
* group and gets it from the terminal at the same moment the CLI does.
|
|
19
|
+
*/
|
|
20
|
+
export declare function run(command: string, args: readonly string[], options: RunOptions): Promise<void>;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export class CommandFailed extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
signal;
|
|
5
|
+
constructor(command, exitCode, signal) {
|
|
6
|
+
super(`${command} exited ${signal === null ? `with code ${String(exitCode)}` : `on ${signal}`}`);
|
|
7
|
+
this.name = "CommandFailed";
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.signal = signal;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs a child to completion and throws on a non-zero exit.
|
|
14
|
+
*
|
|
15
|
+
* `stdio: "inherit"` by default: every command this spawns — the migrator, `turbo gen`, `next
|
|
16
|
+
* dev` — is one whose output the user is meant to read, and buffering it would turn a prompt
|
|
17
|
+
* into a hang. `SIGINT` is deliberately not forwarded; the child shares the terminal's process
|
|
18
|
+
* group and gets it from the terminal at the same moment the CLI does.
|
|
19
|
+
*/
|
|
20
|
+
export async function run(command, args, options) {
|
|
21
|
+
const child = spawn(command, [...args], {
|
|
22
|
+
cwd: options.cwd,
|
|
23
|
+
env: options.env ?? process.env,
|
|
24
|
+
stdio: options.stdio ?? "inherit",
|
|
25
|
+
});
|
|
26
|
+
const [code, signal] = await new Promise((resolve, reject) => {
|
|
27
|
+
child.once("error", reject);
|
|
28
|
+
child.once("exit", (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
|
|
29
|
+
});
|
|
30
|
+
// A child killed by the terminal's own SIGINT is the user stopping `hf dev`, not a failure.
|
|
31
|
+
if (code === 0 || signal === "SIGINT" || signal === "SIGTERM")
|
|
32
|
+
return;
|
|
33
|
+
throw new CommandFailed([command, ...args].join(" "), code, signal);
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ResolvedApp } from "./app.js";
|
|
2
|
+
export type StatusTokenKind = "read" | "write";
|
|
3
|
+
/** One column already carries a hash and `--rotate` was not passed to authorize replacing it. */
|
|
4
|
+
export declare class StatusTokenAlreadySet extends Error {
|
|
5
|
+
readonly kind: StatusTokenKind;
|
|
6
|
+
constructor(kind: StatusTokenKind);
|
|
7
|
+
}
|
|
8
|
+
export interface StatusTokenAppOptions {
|
|
9
|
+
dir?: string;
|
|
10
|
+
/** Which token(s) to (re)generate. Both when omitted. */
|
|
11
|
+
kinds?: readonly StatusTokenKind[];
|
|
12
|
+
/** Overwrites a hash that is already set; otherwise a set column is refused. */
|
|
13
|
+
rotate?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* True when `kinds` was narrowed by an explicit `--read`/`--write`, rather than defaulted to
|
|
16
|
+
* both. An explicit ask for a kind that is already set is refused the same as before, since
|
|
17
|
+
* the operator named it on purpose; the *default* two-kind run instead skips whichever kind
|
|
18
|
+
* is already set and only fills the gap — otherwise "run it to provision the token nobody set
|
|
19
|
+
* up yet" would also require `--rotate`, and `--rotate` would take out the other, live token.
|
|
20
|
+
*/
|
|
21
|
+
explicit?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface StatusTokenAppResult {
|
|
24
|
+
app: ResolvedApp;
|
|
25
|
+
/** The plaintext of each token generated this run — the only time it is ever available. */
|
|
26
|
+
tokens: Partial<Record<StatusTokenKind, string>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `hf status-token` — provisions `hf_app_state.read_token_hash`/`write_token_hash`, the two
|
|
30
|
+
* secrets `/api/status` is authorized against (`statusTokenMatches` in `@hyperfixation/core`
|
|
31
|
+
* refuses every request when a hash is unset, so an app cannot serve status until this has run
|
|
32
|
+
* at least once).
|
|
33
|
+
*
|
|
34
|
+
* Connects as the **application** role, like `hf bootstrap`: this only ever updates the
|
|
35
|
+
* singleton row an app already owns, never anything a migrator-level grant is needed for.
|
|
36
|
+
*
|
|
37
|
+
* Read and write tokens are independent secrets — the write token is strictly the more
|
|
38
|
+
* privileged of the two (`createStatusHandler` accepts it on `GET` as well), so rotating one
|
|
39
|
+
* does not have to rotate the other. Each plaintext token is printed exactly once, by the
|
|
40
|
+
* caller; nothing here ever stores or logs it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function statusTokenApp(options?: StatusTokenAppOptions): Promise<StatusTokenAppResult>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { hashStatusToken } from "@hyperfixation/core";
|
|
3
|
+
import { AppStateMissing } from "@hyperfixation/db";
|
|
4
|
+
import { Pool } from "pg";
|
|
5
|
+
import { resolveApp } from "./app.js";
|
|
6
|
+
import { requireEnv } from "./require-env.js";
|
|
7
|
+
const COLUMN = {
|
|
8
|
+
read: "read_token_hash",
|
|
9
|
+
write: "write_token_hash",
|
|
10
|
+
};
|
|
11
|
+
const SELECT_HASHES_STATEMENT = "SELECT read_token_hash, write_token_hash FROM hf_app_state WHERE id = 1 FOR UPDATE";
|
|
12
|
+
const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
|
|
13
|
+
"VALUES (NULL, 'app.status_token_provisioned', 'hf_app_state', '1', $1::jsonb)";
|
|
14
|
+
/** One column already carries a hash and `--rotate` was not passed to authorize replacing it. */
|
|
15
|
+
export class StatusTokenAlreadySet extends Error {
|
|
16
|
+
kind;
|
|
17
|
+
constructor(kind) {
|
|
18
|
+
super(`the ${kind} token is already set; pass --rotate to replace it`);
|
|
19
|
+
this.name = "StatusTokenAlreadySet";
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `hf status-token` — provisions `hf_app_state.read_token_hash`/`write_token_hash`, the two
|
|
25
|
+
* secrets `/api/status` is authorized against (`statusTokenMatches` in `@hyperfixation/core`
|
|
26
|
+
* refuses every request when a hash is unset, so an app cannot serve status until this has run
|
|
27
|
+
* at least once).
|
|
28
|
+
*
|
|
29
|
+
* Connects as the **application** role, like `hf bootstrap`: this only ever updates the
|
|
30
|
+
* singleton row an app already owns, never anything a migrator-level grant is needed for.
|
|
31
|
+
*
|
|
32
|
+
* Read and write tokens are independent secrets — the write token is strictly the more
|
|
33
|
+
* privileged of the two (`createStatusHandler` accepts it on `GET` as well), so rotating one
|
|
34
|
+
* does not have to rotate the other. Each plaintext token is printed exactly once, by the
|
|
35
|
+
* caller; nothing here ever stores or logs it.
|
|
36
|
+
*/
|
|
37
|
+
export async function statusTokenApp(options = {}) {
|
|
38
|
+
const app = await resolveApp(options.dir);
|
|
39
|
+
const databaseUrl = requireEnv(app, "DATABASE_URL");
|
|
40
|
+
const kinds = options.kinds ?? ["read", "write"];
|
|
41
|
+
const pool = new Pool({ connectionString: databaseUrl, max: 1 });
|
|
42
|
+
try {
|
|
43
|
+
const client = await pool.connect();
|
|
44
|
+
try {
|
|
45
|
+
await client.query("BEGIN");
|
|
46
|
+
const tokens = await rotateTokens(client, kinds, options.rotate ?? false, options.explicit ?? false);
|
|
47
|
+
await client.query("COMMIT");
|
|
48
|
+
return { app, tokens };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await client.query("ROLLBACK");
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
client.release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await pool.end();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function rotateTokens(client, kinds, allowRotate, explicit) {
|
|
63
|
+
const { rows } = await client.query(SELECT_HASHES_STATEMENT);
|
|
64
|
+
const existing = rows[0];
|
|
65
|
+
if (existing === undefined)
|
|
66
|
+
throw new AppStateMissing("hf status-token");
|
|
67
|
+
const toGenerate = [];
|
|
68
|
+
for (const kind of kinds) {
|
|
69
|
+
const alreadySet = existing[COLUMN[kind]] !== null;
|
|
70
|
+
if (!alreadySet || allowRotate) {
|
|
71
|
+
toGenerate.push(kind);
|
|
72
|
+
}
|
|
73
|
+
else if (explicit) {
|
|
74
|
+
throw new StatusTokenAlreadySet(kind);
|
|
75
|
+
}
|
|
76
|
+
// Default two-kind run, already set, no --rotate: silently left alone.
|
|
77
|
+
}
|
|
78
|
+
const tokens = {};
|
|
79
|
+
for (const kind of toGenerate) {
|
|
80
|
+
const token = randomBytes(32).toString("base64url");
|
|
81
|
+
tokens[kind] = token;
|
|
82
|
+
await client.query(`UPDATE hf_app_state SET ${COLUMN[kind]} = $1 WHERE id = 1`, [
|
|
83
|
+
hashStatusToken(token),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
if (toGenerate.length > 0) {
|
|
87
|
+
await client.query(AUDIT_STATEMENT, [JSON.stringify({ kinds: toGenerate })]);
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Points `hf new` at a template checkout; what a CI job or a second checkout sets. */
|
|
2
|
+
export declare const TEMPLATE_DIR_ENV = "HF_TEMPLATE_DIR";
|
|
3
|
+
/**
|
|
4
|
+
* Where `hf new --local` looks when `--from` is not given: the environment, then the sibling
|
|
5
|
+
* checkout beside the working directory, then the sibling beside this package's own repo.
|
|
6
|
+
*
|
|
7
|
+
* The last one is what makes `hf new demo-app --local` work when `hf` is run from inside
|
|
8
|
+
* hyperfixation-core, which is the Phase 1 development layout the plan describes:
|
|
9
|
+
*
|
|
10
|
+
* Code/
|
|
11
|
+
* hyperfixation/ (this repo)
|
|
12
|
+
* hyperfixation-template/
|
|
13
|
+
*/
|
|
14
|
+
export declare function findTemplateSource(cwd?: string): Promise<string | undefined>;
|
|
15
|
+
/** The same search, but a miss is the error the user has to act on rather than `undefined`. */
|
|
16
|
+
export declare function requireTemplateSource(cwd?: string): Promise<string>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { TEMPLATE_MARKER, TemplateError } from "./new.js";
|
|
5
|
+
/** Points `hf new` at a template checkout; what a CI job or a second checkout sets. */
|
|
6
|
+
export const TEMPLATE_DIR_ENV = "HF_TEMPLATE_DIR";
|
|
7
|
+
/**
|
|
8
|
+
* Where `hf new --local` looks when `--from` is not given: the environment, then the sibling
|
|
9
|
+
* checkout beside the working directory, then the sibling beside this package's own repo.
|
|
10
|
+
*
|
|
11
|
+
* The last one is what makes `hf new demo-app --local` work when `hf` is run from inside
|
|
12
|
+
* hyperfixation-core, which is the Phase 1 development layout the plan describes:
|
|
13
|
+
*
|
|
14
|
+
* Code/
|
|
15
|
+
* hyperfixation/ (this repo)
|
|
16
|
+
* hyperfixation-template/
|
|
17
|
+
*/
|
|
18
|
+
export async function findTemplateSource(cwd = process.cwd()) {
|
|
19
|
+
const fromEnv = process.env[TEMPLATE_DIR_ENV];
|
|
20
|
+
const candidates = [
|
|
21
|
+
...(fromEnv === undefined || fromEnv === "" ? [] : [path.resolve(fromEnv)]),
|
|
22
|
+
path.resolve(cwd, "..", "hyperfixation-template"),
|
|
23
|
+
path.resolve(fileURLToPath(new URL("../../..", import.meta.url)), "..", "hyperfixation-template"),
|
|
24
|
+
];
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (await isTemplate(candidate))
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The same search, but a miss is the error the user has to act on rather than `undefined`. */
|
|
32
|
+
export async function requireTemplateSource(cwd) {
|
|
33
|
+
const found = await findTemplateSource(cwd);
|
|
34
|
+
if (found === undefined) {
|
|
35
|
+
throw new TemplateError(`no hyperfixation-template checkout found: pass --from <dir> or set ${TEMPLATE_DIR_ENV}`);
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
async function isTemplate(dir) {
|
|
40
|
+
try {
|
|
41
|
+
return (await stat(path.join(dir, TEMPLATE_MARKER))).isFile();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/up.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ResolvedApp } from "./app.js";
|
|
2
|
+
import { type StatusTokenKind } from "./status-token.js";
|
|
3
|
+
/**
|
|
4
|
+
* What `hf up` seeds `hf_app_state.budget_usd` with when `HF_BOOTSTRAP_BUDGET_USD` isn't set.
|
|
5
|
+
* Local only: `hf bootstrap` itself still refuses to run without an explicit budget, so a
|
|
6
|
+
* deployed app never starts under a cap nobody chose.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEV_BUDGET_USD = "10";
|
|
9
|
+
export interface UpOptions {
|
|
10
|
+
dir?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface UpResult {
|
|
13
|
+
app: ResolvedApp;
|
|
14
|
+
installedDependencies: boolean;
|
|
15
|
+
composeStarted: boolean;
|
|
16
|
+
/** False when an admin already existed and `hf bootstrap` was skipped rather than rerun. */
|
|
17
|
+
bootstrapped: boolean;
|
|
18
|
+
/** True when this run seeded `DEV_BUDGET_USD` because the app's `.env` sets no budget. */
|
|
19
|
+
budgetDefaulted: boolean;
|
|
20
|
+
/** Which token kind(s) were generated this run; empty when both were already set. */
|
|
21
|
+
tokensProvisioned: readonly StatusTokenKind[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `hf up` — the whole local QA loop in one idempotent command: install, infra, migrate,
|
|
25
|
+
* bootstrap, status tokens, then `hf dev` in the foreground.
|
|
26
|
+
*
|
|
27
|
+
* Every step but the last is safe to rerun. `hf bootstrap` is the one step whose own contract is
|
|
28
|
+
* "exactly once" (`BootstrapRefused` — an app gets one bootstrap admin, on purpose), so this
|
|
29
|
+
* catches that specific refusal rather than asking `bootstrapApp` to change what it means; every
|
|
30
|
+
* other step already no-ops on its own (`ensureCompose`'s `--wait`, `statusTokenApp`'s default
|
|
31
|
+
* two-kind run leaving a set column alone). `hf dev` itself is the caller's to run afterward,
|
|
32
|
+
* since it blocks in the foreground and this function is meant to return a result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function upApp(options?: UpOptions): Promise<UpResult>;
|
|
35
|
+
/** True when `node_modules` isn't there yet — `pnpm install` has never run for this app. */
|
|
36
|
+
export declare function needsInstall(dir: string): Promise<boolean>;
|
|
37
|
+
/** True when the app's `.env` names no budget, so `hf up` will supply `DEV_BUDGET_USD`. */
|
|
38
|
+
export declare function usesDefaultBudget(app: ResolvedApp): boolean;
|
|
39
|
+
/** Runs `hf bootstrap`; swallows `BootstrapRefused` since the app already has its one admin. */
|
|
40
|
+
export declare function bootstrapIfNeeded(app: ResolvedApp): Promise<boolean>;
|
package/dist/up.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BootstrapRefused } from "@hyperfixation/auth";
|
|
4
|
+
import { resolveApp } from "./app.js";
|
|
5
|
+
import { bootstrapApp } from "./bootstrap.js";
|
|
6
|
+
import { ensureCompose } from "./dev.js";
|
|
7
|
+
import { migrateApp } from "./migrate.js";
|
|
8
|
+
import { run } from "./spawn.js";
|
|
9
|
+
import { statusTokenApp } from "./status-token.js";
|
|
10
|
+
/**
|
|
11
|
+
* What `hf up` seeds `hf_app_state.budget_usd` with when `HF_BOOTSTRAP_BUDGET_USD` isn't set.
|
|
12
|
+
* Local only: `hf bootstrap` itself still refuses to run without an explicit budget, so a
|
|
13
|
+
* deployed app never starts under a cap nobody chose.
|
|
14
|
+
*/
|
|
15
|
+
export const DEV_BUDGET_USD = "10";
|
|
16
|
+
const BUDGET_ENV = "HF_BOOTSTRAP_BUDGET_USD";
|
|
17
|
+
/**
|
|
18
|
+
* `hf up` — the whole local QA loop in one idempotent command: install, infra, migrate,
|
|
19
|
+
* bootstrap, status tokens, then `hf dev` in the foreground.
|
|
20
|
+
*
|
|
21
|
+
* Every step but the last is safe to rerun. `hf bootstrap` is the one step whose own contract is
|
|
22
|
+
* "exactly once" (`BootstrapRefused` — an app gets one bootstrap admin, on purpose), so this
|
|
23
|
+
* catches that specific refusal rather than asking `bootstrapApp` to change what it means; every
|
|
24
|
+
* other step already no-ops on its own (`ensureCompose`'s `--wait`, `statusTokenApp`'s default
|
|
25
|
+
* two-kind run leaving a set column alone). `hf dev` itself is the caller's to run afterward,
|
|
26
|
+
* since it blocks in the foreground and this function is meant to return a result.
|
|
27
|
+
*/
|
|
28
|
+
export async function upApp(options = {}) {
|
|
29
|
+
const app = await resolveApp(options.dir);
|
|
30
|
+
const installedDependencies = await needsInstall(app.dir);
|
|
31
|
+
if (installedDependencies)
|
|
32
|
+
await run("pnpm", ["install"], { cwd: app.dir });
|
|
33
|
+
const composeStarted = await ensureCompose(app);
|
|
34
|
+
await migrateApp({ dir: app.dir });
|
|
35
|
+
const defaultsBudget = usesDefaultBudget(app);
|
|
36
|
+
const bootstrapped = await bootstrapIfNeeded(app);
|
|
37
|
+
const { tokens } = await statusTokenApp({ dir: app.dir });
|
|
38
|
+
const tokensProvisioned = Object.keys(tokens);
|
|
39
|
+
return {
|
|
40
|
+
app,
|
|
41
|
+
installedDependencies,
|
|
42
|
+
composeStarted,
|
|
43
|
+
bootstrapped,
|
|
44
|
+
budgetDefaulted: bootstrapped && defaultsBudget,
|
|
45
|
+
tokensProvisioned,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** True when `node_modules` isn't there yet — `pnpm install` has never run for this app. */
|
|
49
|
+
export async function needsInstall(dir) {
|
|
50
|
+
try {
|
|
51
|
+
return !(await stat(path.join(dir, "node_modules"))).isDirectory();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** True when the app's `.env` names no budget, so `hf up` will supply `DEV_BUDGET_USD`. */
|
|
58
|
+
export function usesDefaultBudget(app) {
|
|
59
|
+
const value = app.env[BUDGET_ENV];
|
|
60
|
+
return value === undefined || value === "";
|
|
61
|
+
}
|
|
62
|
+
/** Runs `hf bootstrap`; swallows `BootstrapRefused` since the app already has its one admin. */
|
|
63
|
+
export async function bootstrapIfNeeded(app) {
|
|
64
|
+
try {
|
|
65
|
+
await bootstrapApp({
|
|
66
|
+
dir: app.dir,
|
|
67
|
+
budgetUsd: usesDefaultBudget(app) ? DEV_BUDGET_USD : undefined,
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof BootstrapRefused)
|
|
73
|
+
return false;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperfixation/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "The hf binary and its Turborepo generator templates",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/grahamlutz/hyperfixation-core.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"hf": "./dist/bin.js"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!dist/**/*.test.*",
|
|
29
|
+
"!dist/test-support/**"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@hyperfixation/auth": "0.1.0",
|
|
33
|
+
"@hyperfixation/core": "0.1.0",
|
|
34
|
+
"@hyperfixation/db": "0.1.0",
|
|
35
|
+
"pg": "^8.23.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@hyperfixation/eslint-config": "0.1.0",
|
|
39
|
+
"@hyperfixation/testing": "0.1.0",
|
|
40
|
+
"@microsoft/api-extractor": "^7.59.1",
|
|
41
|
+
"@types/pg": "^8.23.1",
|
|
42
|
+
"eslint": "^10.10.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"lint": "eslint src",
|
|
48
|
+
"api-extractor": "api-extractor run",
|
|
49
|
+
"api-extractor:update": "api-extractor run --local",
|
|
50
|
+
"test": "vitest run --passWithNoTests"
|
|
51
|
+
}
|
|
52
|
+
}
|