@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/dist/dev.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveApp } from "./app.js";
|
|
4
|
+
import { run } from "./spawn.js";
|
|
5
|
+
/** Postgres and mailpit, and nothing else; the app itself runs on the host under `hf dev`. */
|
|
6
|
+
export const DEV_COMPOSE_FILE = "docker-compose.yml";
|
|
7
|
+
/**
|
|
8
|
+
* The version a local process runs under.
|
|
9
|
+
*
|
|
10
|
+
* `startWorker()` refuses an `HF_BUILD_SHA` shorter than seven characters and DBOS treats it as
|
|
11
|
+
* the `applicationVersion`, which is the thing a redeploy changes. A timestamp is what makes a
|
|
12
|
+
* restart of `hf dev` look like a redeploy — the run model's bump path is then exercised by an
|
|
13
|
+
* ordinary edit-and-restart loop rather than only by the redeploy suite — and the `dev-` prefix
|
|
14
|
+
* is what guarantees it can never collide with a deployed commit sha.
|
|
15
|
+
*/
|
|
16
|
+
export function devBuildSha(now = Date.now()) {
|
|
17
|
+
return `dev-${String(now)}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `hf dev` — the dev infrastructure, then the app's own `dev` script under a version the worker
|
|
21
|
+
* will accept.
|
|
22
|
+
*
|
|
23
|
+
* It runs `pnpm dev` rather than reimplementing it: `next dev` is the app's to configure, and
|
|
24
|
+
* the one thing the app's script cannot do for itself is invent a build sha, because a value
|
|
25
|
+
* committed to `.env` would be the same "version" across every restart and a redeploy that
|
|
26
|
+
* changed nothing is not a redeploy.
|
|
27
|
+
*/
|
|
28
|
+
export async function dev(options = {}) {
|
|
29
|
+
const app = await resolveApp(options.dir);
|
|
30
|
+
const buildSha = options.buildSha ?? devBuildSha();
|
|
31
|
+
if (options.skipCompose !== true)
|
|
32
|
+
await ensureCompose(app);
|
|
33
|
+
if (options.composeOnly === true)
|
|
34
|
+
return { app, buildSha };
|
|
35
|
+
await run("pnpm", ["dev"], {
|
|
36
|
+
cwd: app.dir,
|
|
37
|
+
env: { ...app.env, HF_PROCESS: "web", HF_BUILD_SHA: buildSha },
|
|
38
|
+
});
|
|
39
|
+
return { app, buildSha };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `docker compose up`, if the app has a compose file — shared with `hf up`, which brings the
|
|
43
|
+
* same infrastructure up ahead of `hf migrate` rather than duplicating this check.
|
|
44
|
+
*
|
|
45
|
+
* Returns whether compose was actually started, so a caller can report it distinctly from "there
|
|
46
|
+
* was nothing to bring up."
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureCompose(app) {
|
|
49
|
+
if (!(await isFile(path.join(app.dir, DEV_COMPOSE_FILE))))
|
|
50
|
+
return false;
|
|
51
|
+
await run("docker", ["compose", "-f", DEV_COMPOSE_FILE, "up", "-d", "--wait"], {
|
|
52
|
+
cwd: app.dir,
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
async function isFile(target) {
|
|
57
|
+
try {
|
|
58
|
+
return (await stat(target)).isFile();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough dotenv for the template's own `.env`: `NAME=value`, `#` comments, and optional
|
|
3
|
+
* surrounding quotes. No interpolation and no `export` — the template writes neither, and a
|
|
4
|
+
* parser that guesses at shell semantics would disagree with compose, which reads the same file
|
|
5
|
+
* with rules of its own.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseEnvFile(contents: string): Record<string, string>;
|
|
8
|
+
export declare function readEnvFile(file: string): Promise<Record<string, string>>;
|
|
9
|
+
/** The names `.env.example` declares, in file order — the app's own env contract. */
|
|
10
|
+
export declare function declaredNames(contents: string): string[];
|
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Just enough dotenv for the template's own `.env`: `NAME=value`, `#` comments, and optional
|
|
4
|
+
* surrounding quotes. No interpolation and no `export` — the template writes neither, and a
|
|
5
|
+
* parser that guesses at shell semantics would disagree with compose, which reads the same file
|
|
6
|
+
* with rules of its own.
|
|
7
|
+
*/
|
|
8
|
+
export function parseEnvFile(contents) {
|
|
9
|
+
const env = {};
|
|
10
|
+
for (const line of contents.split("\n")) {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
13
|
+
continue;
|
|
14
|
+
const eq = trimmed.indexOf("=");
|
|
15
|
+
if (eq <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
const name = trimmed.slice(0, eq).trim();
|
|
18
|
+
const raw = trimmed.slice(eq + 1).trim();
|
|
19
|
+
env[name] = unquote(raw);
|
|
20
|
+
}
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
export async function readEnvFile(file) {
|
|
24
|
+
try {
|
|
25
|
+
return parseEnvFile(await readFile(file, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
if (cause.code === "ENOENT")
|
|
29
|
+
return {};
|
|
30
|
+
throw cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The names `.env.example` declares, in file order — the app's own env contract. */
|
|
34
|
+
export function declaredNames(contents) {
|
|
35
|
+
return Object.keys(parseEnvFile(contents));
|
|
36
|
+
}
|
|
37
|
+
function unquote(value) {
|
|
38
|
+
if (value.length >= 2 && (value.startsWith('"') || value.startsWith("'"))) {
|
|
39
|
+
const quote = value[0];
|
|
40
|
+
if (value.endsWith(quote))
|
|
41
|
+
return value.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
package/dist/gen.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ResolvedApp } from "./app.js";
|
|
2
|
+
/** Track B's generators: `flow` and `record`, each of which also writes the registration. */
|
|
3
|
+
export declare const GENERATOR_CONFIG: string;
|
|
4
|
+
/** `@turbo/gen` declares its bin as `gen`; that is the name pnpm links into the app. */
|
|
5
|
+
export declare const GENERATOR_BIN = "gen";
|
|
6
|
+
export declare class NoGenerators extends Error {
|
|
7
|
+
constructor(dir: string);
|
|
8
|
+
}
|
|
9
|
+
export interface GenerateOptions {
|
|
10
|
+
dir?: string;
|
|
11
|
+
/** Generator name and any `--flag value` pairs, passed through to `turbo gen`. */
|
|
12
|
+
args?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `hf gen` — the app's own Turborepo generators, run through the app's `turbo`.
|
|
16
|
+
*
|
|
17
|
+
* It adds nothing to the generators but the ability to run them from anywhere inside the app,
|
|
18
|
+
* and deliberately holds no templates of its own: a generator's whole value is that it writes
|
|
19
|
+
* the *registration* as well as the file, and only the app's `src/hyperfixation.ts` can be
|
|
20
|
+
* appended to. A copy of the templates in this package would be a second one to keep in step
|
|
21
|
+
* with the registry shape.
|
|
22
|
+
*
|
|
23
|
+
* `@turbo/gen`'s own bin, not `turbo gen`. `turbo gen` re-fetches `@turbo/gen` through
|
|
24
|
+
* `pnpm dlx` even when the app already depends on it, and that second copy is installed outside
|
|
25
|
+
* the app's `pnpm-workspace.yaml` — so it is refused by pnpm 12 for `esbuild`'s build script,
|
|
26
|
+
* which the app's own `allowBuilds` had already permitted. The installed bin is the same
|
|
27
|
+
* generator with none of that.
|
|
28
|
+
*/
|
|
29
|
+
export declare function generate(options?: GenerateOptions): Promise<ResolvedApp>;
|
package/dist/gen.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveApp } from "./app.js";
|
|
4
|
+
import { run } from "./spawn.js";
|
|
5
|
+
/** Track B's generators: `flow` and `record`, each of which also writes the registration. */
|
|
6
|
+
export const GENERATOR_CONFIG = path.join("turbo", "generators", "config.ts");
|
|
7
|
+
/** `@turbo/gen` declares its bin as `gen`; that is the name pnpm links into the app. */
|
|
8
|
+
export const GENERATOR_BIN = "gen";
|
|
9
|
+
export class NoGenerators extends Error {
|
|
10
|
+
constructor(dir) {
|
|
11
|
+
super(`${dir} has no ${GENERATOR_CONFIG}; hf gen has nothing to run`);
|
|
12
|
+
this.name = "NoGenerators";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `hf gen` — the app's own Turborepo generators, run through the app's `turbo`.
|
|
17
|
+
*
|
|
18
|
+
* It adds nothing to the generators but the ability to run them from anywhere inside the app,
|
|
19
|
+
* and deliberately holds no templates of its own: a generator's whole value is that it writes
|
|
20
|
+
* the *registration* as well as the file, and only the app's `src/hyperfixation.ts` can be
|
|
21
|
+
* appended to. A copy of the templates in this package would be a second one to keep in step
|
|
22
|
+
* with the registry shape.
|
|
23
|
+
*
|
|
24
|
+
* `@turbo/gen`'s own bin, not `turbo gen`. `turbo gen` re-fetches `@turbo/gen` through
|
|
25
|
+
* `pnpm dlx` even when the app already depends on it, and that second copy is installed outside
|
|
26
|
+
* the app's `pnpm-workspace.yaml` — so it is refused by pnpm 12 for `esbuild`'s build script,
|
|
27
|
+
* which the app's own `allowBuilds` had already permitted. The installed bin is the same
|
|
28
|
+
* generator with none of that.
|
|
29
|
+
*/
|
|
30
|
+
export async function generate(options = {}) {
|
|
31
|
+
const app = await resolveApp(options.dir);
|
|
32
|
+
if (!(await isFile(path.join(app.dir, GENERATOR_CONFIG))))
|
|
33
|
+
throw new NoGenerators(app.dir);
|
|
34
|
+
await run("pnpm", ["exec", GENERATOR_BIN, "run", ...(options.args ?? [])], { cwd: app.dir });
|
|
35
|
+
return app;
|
|
36
|
+
}
|
|
37
|
+
async function isFile(target) {
|
|
38
|
+
try {
|
|
39
|
+
return (await stat(target)).isFile();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { main, COMMANDS, USAGE, type Command, type Io } from "./cli.js";
|
|
2
|
+
export { newApp, placeholders, substitute, TemplateError, TEMPLATE_MARKER, EXCLUDED_ENTRIES, type NewAppOptions, type NewAppResult, } from "./new.js";
|
|
3
|
+
export { findTemplateSource, requireTemplateSource, TEMPLATE_DIR_ENV, } from "./template-source.js";
|
|
4
|
+
export { deriveNames, APP_ID, GIVEN_NAME, InvalidAppName, type AppNames } from "./names.js";
|
|
5
|
+
export { resolveApp, NotAnApp, type ResolvedApp } from "./app.js";
|
|
6
|
+
export { declaredNames, parseEnvFile, readEnvFile } from "./env-file.js";
|
|
7
|
+
export { MissingEnv, requireEnv } from "./require-env.js";
|
|
8
|
+
export { credentialsOf, provisionLocalRoles, type LocalRoleOptions, type LocalRoleResult, } from "./roles.js";
|
|
9
|
+
export { migrateApp, MIGRATE_ENTRY, type MigrateAppOptions, type MigrateAppResult, } from "./migrate.js";
|
|
10
|
+
export { bootstrapApp, type BootstrapAppOptions, type BootstrapAppResult } from "./bootstrap.js";
|
|
11
|
+
export { statusTokenApp, StatusTokenAlreadySet, type StatusTokenAppOptions, type StatusTokenAppResult, type StatusTokenKind, } from "./status-token.js";
|
|
12
|
+
export { checkApp, type CheckAppResult, type CheckFinding } from "./check.js";
|
|
13
|
+
export { probeApp, type AppRegistry } from "./probe.js";
|
|
14
|
+
export { generate, GENERATOR_BIN, GENERATOR_CONFIG, NoGenerators, type GenerateOptions, } from "./gen.js";
|
|
15
|
+
export { dev, devBuildSha, DEV_COMPOSE_FILE, type DevOptions, type DevResult } from "./dev.js";
|
|
16
|
+
export { run, CommandFailed, type RunOptions } from "./spawn.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { main, COMMANDS, USAGE } from "./cli.js";
|
|
2
|
+
export { newApp, placeholders, substitute, TemplateError, TEMPLATE_MARKER, EXCLUDED_ENTRIES, } from "./new.js";
|
|
3
|
+
export { findTemplateSource, requireTemplateSource, TEMPLATE_DIR_ENV, } from "./template-source.js";
|
|
4
|
+
export { deriveNames, APP_ID, GIVEN_NAME, InvalidAppName } from "./names.js";
|
|
5
|
+
export { resolveApp, NotAnApp } from "./app.js";
|
|
6
|
+
export { declaredNames, parseEnvFile, readEnvFile } from "./env-file.js";
|
|
7
|
+
export { MissingEnv, requireEnv } from "./require-env.js";
|
|
8
|
+
export { credentialsOf, provisionLocalRoles, } from "./roles.js";
|
|
9
|
+
export { migrateApp, MIGRATE_ENTRY, } from "./migrate.js";
|
|
10
|
+
export { bootstrapApp } from "./bootstrap.js";
|
|
11
|
+
export { statusTokenApp, StatusTokenAlreadySet, } from "./status-token.js";
|
|
12
|
+
export { checkApp } from "./check.js";
|
|
13
|
+
export { probeApp } from "./probe.js";
|
|
14
|
+
export { generate, GENERATOR_BIN, GENERATOR_CONFIG, NoGenerators, } from "./gen.js";
|
|
15
|
+
export { dev, devBuildSha, DEV_COMPOSE_FILE } from "./dev.js";
|
|
16
|
+
export { run, CommandFailed } from "./spawn.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ResolvedApp } from "./app.js";
|
|
2
|
+
import { type LocalRoleResult } from "./roles.js";
|
|
3
|
+
/** The entrypoint track B's template ships; the same file the deployed `migrate` service runs. */
|
|
4
|
+
export declare const MIGRATE_ENTRY = "migrate.ts";
|
|
5
|
+
export interface MigrateAppOptions {
|
|
6
|
+
dir?: string;
|
|
7
|
+
/** Skips role provisioning — the cloud path, where `hf new` created the roles. */
|
|
8
|
+
skipRoles?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface MigrateAppResult {
|
|
11
|
+
app: ResolvedApp;
|
|
12
|
+
roles: LocalRoleResult | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `hf migrate` — the application role, then the app's own migrator entrypoint.
|
|
16
|
+
*
|
|
17
|
+
* The second half is a child process running `migrate.ts` rather than a direct call to
|
|
18
|
+
* `migrate()` from `@hyperfixation/db/migrator`, for one reason: the record tables and the app
|
|
19
|
+
* migrations directory come from the app's registry, and `migrate.ts` is what reads them. It is
|
|
20
|
+
* also the file the deployed one-shot `migrate` service runs, so `hf migrate` on a laptop and a
|
|
21
|
+
* deploy cannot drift apart — which they would the moment the CLI grew its own argument list.
|
|
22
|
+
*
|
|
23
|
+
* The first half is the CLI's own, and only local: a container never creates a role.
|
|
24
|
+
*/
|
|
25
|
+
export declare function migrateApp(options?: MigrateAppOptions): Promise<MigrateAppResult>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveApp } from "./app.js";
|
|
3
|
+
import { requireEnv } from "./require-env.js";
|
|
4
|
+
import { credentialsOf, provisionLocalRoles } from "./roles.js";
|
|
5
|
+
import { run } from "./spawn.js";
|
|
6
|
+
/** The entrypoint track B's template ships; the same file the deployed `migrate` service runs. */
|
|
7
|
+
export const MIGRATE_ENTRY = "migrate.ts";
|
|
8
|
+
/**
|
|
9
|
+
* `hf migrate` — the application role, then the app's own migrator entrypoint.
|
|
10
|
+
*
|
|
11
|
+
* The second half is a child process running `migrate.ts` rather than a direct call to
|
|
12
|
+
* `migrate()` from `@hyperfixation/db/migrator`, for one reason: the record tables and the app
|
|
13
|
+
* migrations directory come from the app's registry, and `migrate.ts` is what reads them. It is
|
|
14
|
+
* also the file the deployed one-shot `migrate` service runs, so `hf migrate` on a laptop and a
|
|
15
|
+
* deploy cannot drift apart — which they would the moment the CLI grew its own argument list.
|
|
16
|
+
*
|
|
17
|
+
* The first half is the CLI's own, and only local: a container never creates a role.
|
|
18
|
+
*/
|
|
19
|
+
export async function migrateApp(options = {}) {
|
|
20
|
+
const app = await resolveApp(options.dir);
|
|
21
|
+
const databaseUrl = requireEnv(app, "DATABASE_URL");
|
|
22
|
+
const migratorUrl = requireEnv(app, "MIGRATOR_DATABASE_URL");
|
|
23
|
+
let roles;
|
|
24
|
+
if (options.skipRoles !== true) {
|
|
25
|
+
const credentials = credentialsOf(databaseUrl);
|
|
26
|
+
roles = await provisionLocalRoles(migratorUrl, {
|
|
27
|
+
databaseName: app.names.databaseName,
|
|
28
|
+
applicationRole: credentials.user,
|
|
29
|
+
applicationPassword: credentials.password,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
await run(process.execPath, ["--import", "tsx", path.join(app.dir, MIGRATE_ENTRY)], {
|
|
33
|
+
cwd: app.dir,
|
|
34
|
+
env: { ...app.env, HF_PROCESS: "migrate" },
|
|
35
|
+
});
|
|
36
|
+
return { app, roles };
|
|
37
|
+
}
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a user may type. Hyphens are allowed here and nowhere downstream: `demo-app` is a
|
|
3
|
+
* directory and an npm package name, and the plan's own exit bar spells the example with one.
|
|
4
|
+
*/
|
|
5
|
+
export declare const GIVEN_NAME: RegExp;
|
|
6
|
+
/**
|
|
7
|
+
* The plan's rule, applied to every derived identifier — the database, both roles, and the
|
|
8
|
+
* name `defineApp` carries into the advisory lock and DBOS. `@hyperfixation/db`'s own
|
|
9
|
+
* `assertAppName` is this same pattern, so an app name that fails here fails `migrate()` later
|
|
10
|
+
* anyway; refusing at `hf new` is the only point at which the directory does not yet exist.
|
|
11
|
+
*/
|
|
12
|
+
export declare const APP_ID: RegExp;
|
|
13
|
+
export declare class InvalidAppName extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export interface AppNames {
|
|
17
|
+
/** Exactly what was typed; the directory `hf new` creates. */
|
|
18
|
+
given: string;
|
|
19
|
+
/** `__APP_NAME__`: the given name with `-` replaced by `_`. */
|
|
20
|
+
appName: string;
|
|
21
|
+
/** `__DB_NAME__`. */
|
|
22
|
+
databaseName: string;
|
|
23
|
+
/** `hf_<appName>` — the same string as the database name, as `roleNames()` derives it. */
|
|
24
|
+
applicationRole: string;
|
|
25
|
+
migratorRole: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The substitution `hf new` performs, and the only place the two placeholders are defined.
|
|
29
|
+
*
|
|
30
|
+
* `__APP_NAME__` is the *underscored* form rather than what was typed, because it is what
|
|
31
|
+
* `src/hyperfixation.ts` hands `defineApp` and what the template's `.env.example` builds the
|
|
32
|
+
* application role out of — both of which reach `roleNames()`, which refuses a hyphen. The
|
|
33
|
+
* directory keeps the typed name; nothing inside the app does.
|
|
34
|
+
*/
|
|
35
|
+
export declare function deriveNames(given: string): AppNames;
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a user may type. Hyphens are allowed here and nowhere downstream: `demo-app` is a
|
|
3
|
+
* directory and an npm package name, and the plan's own exit bar spells the example with one.
|
|
4
|
+
*/
|
|
5
|
+
export const GIVEN_NAME = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
6
|
+
/**
|
|
7
|
+
* The plan's rule, applied to every derived identifier — the database, both roles, and the
|
|
8
|
+
* name `defineApp` carries into the advisory lock and DBOS. `@hyperfixation/db`'s own
|
|
9
|
+
* `assertAppName` is this same pattern, so an app name that fails here fails `migrate()` later
|
|
10
|
+
* anyway; refusing at `hf new` is the only point at which the directory does not yet exist.
|
|
11
|
+
*/
|
|
12
|
+
export const APP_ID = /^[a-z][a-z0-9_]{0,62}$/;
|
|
13
|
+
export class InvalidAppName extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "InvalidAppName";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The substitution `hf new` performs, and the only place the two placeholders are defined.
|
|
21
|
+
*
|
|
22
|
+
* `__APP_NAME__` is the *underscored* form rather than what was typed, because it is what
|
|
23
|
+
* `src/hyperfixation.ts` hands `defineApp` and what the template's `.env.example` builds the
|
|
24
|
+
* application role out of — both of which reach `roleNames()`, which refuses a hyphen. The
|
|
25
|
+
* directory keeps the typed name; nothing inside the app does.
|
|
26
|
+
*/
|
|
27
|
+
export function deriveNames(given) {
|
|
28
|
+
if (!GIVEN_NAME.test(given)) {
|
|
29
|
+
throw new InvalidAppName(`app name must match ${GIVEN_NAME.source}, got ${JSON.stringify(given)}`);
|
|
30
|
+
}
|
|
31
|
+
const appName = given.replaceAll("-", "_");
|
|
32
|
+
const databaseName = `hf_${appName}`;
|
|
33
|
+
if (!APP_ID.test(databaseName)) {
|
|
34
|
+
throw new InvalidAppName(`database name must match ${APP_ID.source}, but ${JSON.stringify(given)} derives ` +
|
|
35
|
+
`${JSON.stringify(databaseName)} — an app name may be at most 60 characters`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
given,
|
|
39
|
+
appName,
|
|
40
|
+
databaseName,
|
|
41
|
+
applicationRole: databaseName,
|
|
42
|
+
migratorRole: `${databaseName}_migrator`,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/new.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type AppNames } from "./names.js";
|
|
2
|
+
/**
|
|
3
|
+
* The file that marks a directory as a copy source. `hf new`'s first act on the source is to
|
|
4
|
+
* assert it exists; its last act on the copy is to delete it, so an app is never a template
|
|
5
|
+
* twice.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TEMPLATE_MARKER = ".hyperfixation-template";
|
|
8
|
+
/**
|
|
9
|
+
* Not copied. Build output and `node_modules` belong to the source checkout, `.git` would make
|
|
10
|
+
* the new app a clone of the template's history rather than a repository of its own, and `.env`
|
|
11
|
+
* is the one file in the tree that may hold a secret.
|
|
12
|
+
*/
|
|
13
|
+
export declare const EXCLUDED_ENTRIES: readonly string[];
|
|
14
|
+
export declare class TemplateError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
export interface NewAppOptions {
|
|
18
|
+
/** The name as typed; becomes the directory and, underscored, both placeholders. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** The template checkout to copy. */
|
|
21
|
+
from: string;
|
|
22
|
+
/** Where the app directory is created. Defaults to the current working directory. */
|
|
23
|
+
into?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Phase 1 is local only. Passing `false` is refused rather than ignored, because the cloud
|
|
26
|
+
* `hf new` provisions GitHub, Postgres, Coolify and Cloudflare and a half-provisioned app is
|
|
27
|
+
* worse than none.
|
|
28
|
+
*/
|
|
29
|
+
local: boolean;
|
|
30
|
+
/** The bootstrap admin's address, written to `.env` as `HF_BOOTSTRAP_EMAIL`. Skips the prompt. */
|
|
31
|
+
email?: string;
|
|
32
|
+
/** Overrides the real interactive prompt; for tests and other callers with their own stdin. */
|
|
33
|
+
promptEmail?: () => Promise<string>;
|
|
34
|
+
}
|
|
35
|
+
export interface NewAppResult extends AppNames {
|
|
36
|
+
/** Absolute path of the created app. */
|
|
37
|
+
dir: string;
|
|
38
|
+
/** Files whose contents a placeholder substitution changed. */
|
|
39
|
+
substituted: readonly string[];
|
|
40
|
+
/** True when `.env` was written from `.env.example`. */
|
|
41
|
+
wroteEnv: boolean;
|
|
42
|
+
/** True when `HF_BOOTSTRAP_EMAIL` was written to `.env`, from `--email` or the prompt. */
|
|
43
|
+
wroteBootstrapEmail: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Copies the template checkout and substitutes the two placeholders across it.
|
|
47
|
+
*
|
|
48
|
+
* This is giget's semantics against a local directory rather than giget itself: Phase 1's
|
|
49
|
+
* source is the sibling `hyperfixation-template` checkout, which giget's providers do not
|
|
50
|
+
* address at all (it resolves `gh:`/`gitlab:`/tarball URLs), and `--local` is the only mode
|
|
51
|
+
* that exists until Phase 3. The remote fetch is that phase's to add, beside the provisioning
|
|
52
|
+
* steps that are the rest of a cloud `hf new`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function newApp(options: NewAppOptions): Promise<NewAppResult>;
|
|
55
|
+
/** The placeholder map. Exported because `hf check` reports a leftover placeholder by name. */
|
|
56
|
+
export declare function placeholders(names: AppNames): Record<string, string>;
|
|
57
|
+
export declare function substitute(contents: string, names: AppNames): string;
|
package/dist/new.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { cp } from "node:fs/promises";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { deriveNames } from "./names.js";
|
|
6
|
+
/**
|
|
7
|
+
* The file that marks a directory as a copy source. `hf new`'s first act on the source is to
|
|
8
|
+
* assert it exists; its last act on the copy is to delete it, so an app is never a template
|
|
9
|
+
* twice.
|
|
10
|
+
*/
|
|
11
|
+
export const TEMPLATE_MARKER = ".hyperfixation-template";
|
|
12
|
+
/**
|
|
13
|
+
* Not copied. Build output and `node_modules` belong to the source checkout, `.git` would make
|
|
14
|
+
* the new app a clone of the template's history rather than a repository of its own, and `.env`
|
|
15
|
+
* is the one file in the tree that may hold a secret.
|
|
16
|
+
*/
|
|
17
|
+
export const EXCLUDED_ENTRIES = [
|
|
18
|
+
".git",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".next",
|
|
21
|
+
".turbo",
|
|
22
|
+
"dist",
|
|
23
|
+
"out",
|
|
24
|
+
".env",
|
|
25
|
+
"tsconfig.tsbuildinfo",
|
|
26
|
+
"next-env.d.ts",
|
|
27
|
+
];
|
|
28
|
+
/** Extensions copied byte for byte; everything else is read as UTF-8 and substituted. */
|
|
29
|
+
const BINARY_EXTENSIONS = new Set([
|
|
30
|
+
".png",
|
|
31
|
+
".jpg",
|
|
32
|
+
".jpeg",
|
|
33
|
+
".gif",
|
|
34
|
+
".ico",
|
|
35
|
+
".webp",
|
|
36
|
+
".woff",
|
|
37
|
+
".woff2",
|
|
38
|
+
".ttf",
|
|
39
|
+
".otf",
|
|
40
|
+
".pdf",
|
|
41
|
+
".zip",
|
|
42
|
+
]);
|
|
43
|
+
export class TemplateError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "TemplateError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Copies the template checkout and substitutes the two placeholders across it.
|
|
51
|
+
*
|
|
52
|
+
* This is giget's semantics against a local directory rather than giget itself: Phase 1's
|
|
53
|
+
* source is the sibling `hyperfixation-template` checkout, which giget's providers do not
|
|
54
|
+
* address at all (it resolves `gh:`/`gitlab:`/tarball URLs), and `--local` is the only mode
|
|
55
|
+
* that exists until Phase 3. The remote fetch is that phase's to add, beside the provisioning
|
|
56
|
+
* steps that are the rest of a cloud `hf new`.
|
|
57
|
+
*/
|
|
58
|
+
export async function newApp(options) {
|
|
59
|
+
if (!options.local) {
|
|
60
|
+
throw new TemplateError("hf new needs --local: provisioning GitHub, Postgres, Coolify and Cloudflare is Phase 3");
|
|
61
|
+
}
|
|
62
|
+
const names = deriveNames(options.name);
|
|
63
|
+
const source = path.resolve(options.from);
|
|
64
|
+
await assertTemplateSource(source);
|
|
65
|
+
const dir = path.resolve(options.into ?? process.cwd(), names.given);
|
|
66
|
+
if (await exists(dir)) {
|
|
67
|
+
throw new TemplateError(`${dir} already exists; hf new will not write into it`);
|
|
68
|
+
}
|
|
69
|
+
await cp(source, dir, {
|
|
70
|
+
recursive: true,
|
|
71
|
+
filter: (src) => !EXCLUDED_ENTRIES.includes(path.basename(src)),
|
|
72
|
+
});
|
|
73
|
+
const substituted = await substituteTree(dir, names);
|
|
74
|
+
await rm(path.join(dir, TEMPLATE_MARKER));
|
|
75
|
+
const example = path.join(dir, ".env.example");
|
|
76
|
+
const wroteEnv = await exists(example);
|
|
77
|
+
let wroteBootstrapEmail = false;
|
|
78
|
+
if (wroteEnv) {
|
|
79
|
+
let contents = await readFile(example, "utf8");
|
|
80
|
+
const email = options.email ?? (await (options.promptEmail ?? promptForBootstrapEmail)());
|
|
81
|
+
if (email !== "") {
|
|
82
|
+
contents = `${contents.trimEnd()}\nHF_BOOTSTRAP_EMAIL=${email}\n`;
|
|
83
|
+
wroteBootstrapEmail = true;
|
|
84
|
+
}
|
|
85
|
+
await writeFile(path.join(dir, ".env"), contents);
|
|
86
|
+
}
|
|
87
|
+
return { ...names, dir, substituted, wroteEnv, wroteBootstrapEmail };
|
|
88
|
+
}
|
|
89
|
+
/** The one-time prompt: the address `hf up` later hands `hf bootstrap` via `.env`. */
|
|
90
|
+
async function promptForBootstrapEmail() {
|
|
91
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
92
|
+
try {
|
|
93
|
+
return (await rl.question("bootstrap admin email (blank to skip): ")).trim();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rl.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The placeholder map. Exported because `hf check` reports a leftover placeholder by name. */
|
|
100
|
+
export function placeholders(names) {
|
|
101
|
+
return { __APP_NAME__: names.appName, __DB_NAME__: names.databaseName };
|
|
102
|
+
}
|
|
103
|
+
export function substitute(contents, names) {
|
|
104
|
+
let out = contents;
|
|
105
|
+
for (const [token, value] of Object.entries(placeholders(names))) {
|
|
106
|
+
out = out.replaceAll(token, value);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async function assertTemplateSource(source) {
|
|
111
|
+
if (!(await exists(source))) {
|
|
112
|
+
throw new TemplateError(`template source ${source} does not exist`);
|
|
113
|
+
}
|
|
114
|
+
if (!(await exists(path.join(source, TEMPLATE_MARKER)))) {
|
|
115
|
+
throw new TemplateError(`${source} has no ${TEMPLATE_MARKER}: it is not a hyperfixation template checkout`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function substituteTree(dir, names) {
|
|
119
|
+
const changed = [];
|
|
120
|
+
for (const file of await walk(dir)) {
|
|
121
|
+
if (BINARY_EXTENSIONS.has(path.extname(file)))
|
|
122
|
+
continue;
|
|
123
|
+
const before = await readFile(file, "utf8");
|
|
124
|
+
const after = substitute(before, names);
|
|
125
|
+
if (after === before)
|
|
126
|
+
continue;
|
|
127
|
+
await writeFile(file, after);
|
|
128
|
+
changed.push(path.relative(dir, file));
|
|
129
|
+
}
|
|
130
|
+
return changed.sort();
|
|
131
|
+
}
|
|
132
|
+
async function walk(dir) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
135
|
+
const full = path.join(dir, entry.name);
|
|
136
|
+
if (entry.isDirectory())
|
|
137
|
+
out.push(...(await walk(full)));
|
|
138
|
+
else if (entry.isFile())
|
|
139
|
+
out.push(full);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
async function exists(target) {
|
|
144
|
+
try {
|
|
145
|
+
await stat(target);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RecordTable } from "@hyperfixation/db";
|
|
2
|
+
import type { ResolvedApp } from "./app.js";
|
|
3
|
+
export interface AppRegistry {
|
|
4
|
+
appName: string;
|
|
5
|
+
recordTables: readonly RecordTable[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reads the app's registry by importing it in the app's own process.
|
|
9
|
+
*
|
|
10
|
+
* E001–E003 are checks *about the registered record tables*, so without this `hf check` would
|
|
11
|
+
* run them over an empty list and report green on an app whose tables are wrong. There is no
|
|
12
|
+
* way to learn the list statically: `src/hyperfixation.ts` is TypeScript that resolves
|
|
13
|
+
* `@hyperfixation/*` through the app's own `node_modules`, which is why the probe is a child
|
|
14
|
+
* under the app's `tsx` and not an import here.
|
|
15
|
+
*
|
|
16
|
+
* A failure is `undefined`, not a throw — an app whose dependencies are not installed yet is a
|
|
17
|
+
* finding `hf check` reports, not a reason for it to stop.
|
|
18
|
+
*/
|
|
19
|
+
export declare function probeApp(app: ResolvedApp): Promise<AppRegistry | undefined>;
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** Prefixes the one line of the probe's stdout that is ours; the app may log at import. */
|
|
5
|
+
const PROBE_MARKER = "hf-probe:";
|
|
6
|
+
const PROBE_SOURCE = `const m = await import("./src/hyperfixation.ts");\n` +
|
|
7
|
+
`console.log(${JSON.stringify(PROBE_MARKER)} + JSON.stringify(` +
|
|
8
|
+
`{ appName: m.app.name, recordTables: m.recordTables ?? [] }));\n`;
|
|
9
|
+
/**
|
|
10
|
+
* Reads the app's registry by importing it in the app's own process.
|
|
11
|
+
*
|
|
12
|
+
* E001–E003 are checks *about the registered record tables*, so without this `hf check` would
|
|
13
|
+
* run them over an empty list and report green on an app whose tables are wrong. There is no
|
|
14
|
+
* way to learn the list statically: `src/hyperfixation.ts` is TypeScript that resolves
|
|
15
|
+
* `@hyperfixation/*` through the app's own `node_modules`, which is why the probe is a child
|
|
16
|
+
* under the app's `tsx` and not an import here.
|
|
17
|
+
*
|
|
18
|
+
* A failure is `undefined`, not a throw — an app whose dependencies are not installed yet is a
|
|
19
|
+
* finding `hf check` reports, not a reason for it to stop.
|
|
20
|
+
*/
|
|
21
|
+
export async function probeApp(app) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFileAsync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", PROBE_SOURCE], {
|
|
24
|
+
cwd: app.dir,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
env: {
|
|
27
|
+
...app.env,
|
|
28
|
+
HF_PROCESS: "migrate",
|
|
29
|
+
// `defineApp()` reads it; the probe never launches DBOS, so any accepted value does.
|
|
30
|
+
HF_BUILD_SHA: app.env.HF_BUILD_SHA ?? "dev-probe",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const line = stdout.split("\n").find((it) => it.startsWith(PROBE_MARKER));
|
|
34
|
+
if (line === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
return JSON.parse(line.slice(PROBE_MARKER.length));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResolvedApp } from "./app.js";
|
|
2
|
+
export declare class MissingEnv extends Error {
|
|
3
|
+
readonly names: readonly string[];
|
|
4
|
+
constructor(names: readonly string[], dir: string);
|
|
5
|
+
}
|
|
6
|
+
/** Reads one var out of `.env`-under-`process.env`, or names the file the user has to edit. */
|
|
7
|
+
export declare function requireEnv(app: ResolvedApp, name: string): string;
|