@plakboek/core 0.0.0-stage → 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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/dist/body-limit-DViLUMvl.js +86 -0
  4. package/dist/bootstrap-BUV7ahx4.js +198 -0
  5. package/dist/bootstrap-DplDxN7W.js +181 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +85 -0
  8. package/dist/config.d.ts +16 -0
  9. package/dist/config.js +230 -0
  10. package/dist/db-D5ByUm9I.js +76 -0
  11. package/dist/env-DEELGUpc.js +175 -0
  12. package/dist/handlers-CkdunB39.js +583 -0
  13. package/dist/host-C_7Zxk1H.js +12 -0
  14. package/dist/index.d.ts +18 -0
  15. package/dist/index.js +12 -0
  16. package/dist/mail-B6aq49_D.js +63 -0
  17. package/dist/mail-test-C0Xm6ZtN.js +72 -0
  18. package/dist/menus-CoMJjPmu.js +113 -0
  19. package/dist/migrate-Dq-gMq6J.js +103 -0
  20. package/dist/modules-BQYzOumU.js +12 -0
  21. package/dist/output-Co4mJhFM.js +69 -0
  22. package/dist/route-modules/auth-api.d.ts +5 -0
  23. package/dist/route-modules/auth-api.js +10 -0
  24. package/dist/route-modules/health.d.ts +3 -0
  25. package/dist/route-modules/health.js +32 -0
  26. package/dist/route-modules/setup.d.ts +5 -0
  27. package/dist/route-modules/setup.js +11 -0
  28. package/dist/route-modules/test-email.d.ts +5 -0
  29. package/dist/route-modules/test-email.js +16 -0
  30. package/dist/route-modules/visitor.d.ts +4 -0
  31. package/dist/route-modules/visitor.js +38 -0
  32. package/dist/routes.d.ts +4 -0
  33. package/dist/routes.js +38 -0
  34. package/dist/runtime-DDseFOVL.js +163 -0
  35. package/dist/server.d.ts +30 -0
  36. package/dist/server.js +126 -0
  37. package/dist/types-B5BJRK3k.d.ts +102 -0
  38. package/dist/vite.d.ts +16 -0
  39. package/dist/vite.js +68 -0
  40. package/package.json +109 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Vanthuyne
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/README.md CHANGED
@@ -1,3 +1,222 @@
1
- # Temporary Holding Version
1
+ # @plakboek/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The host integration package for a Plakboek site. A scaffolded host app
4
+ composes this package into its own React Router 8 application: `cmsRoutes()`
5
+ supplies the CMS's routes, the `plakboek()` Vite plugin connects the package's
6
+ route modules to the host's configuration and site module, and `createServer()`
7
+ is the production server that serves a published page from Postgres next to the
8
+ host's own routes.
9
+
10
+ You normally do not install this by hand: `npx create-plakboek` writes a host
11
+ that already depends on it.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @plakboek/core react react-dom react-router
17
+ ```
18
+
19
+ `react`, `react-dom` and `react-router` are peer dependencies. A host that
20
+ builds with React Router also has `@react-router/dev` and `vite`; they are
21
+ optional peers because only the build-time subpaths import them. Node 22.22 or
22
+ later is required.
23
+
24
+ ## Status
25
+
26
+ Pre-alpha: the API settles over phase 6. The package root (`defineBlock` and the
27
+ host-facing types) is safe to import from a client bundle; everything
28
+ server-side lives behind the `./config`, `./routes`, `./vite` and `./server`
29
+ subpaths.
30
+
31
+ ## What a host provides
32
+
33
+ A host is four small files plus its own blocks and site chrome.
34
+
35
+ `app/routes.ts` lists the host's own routes first and spreads the CMS routes
36
+ after them, so a host route wins every tie, including an index route at `/`:
37
+
38
+ ```ts
39
+ import { cmsRoutes } from '@plakboek/core/routes';
40
+ import type { RouteConfig, RouteConfigEntry } from '@react-router/dev/routes';
41
+
42
+ const hostRoutes: RouteConfigEntry[] = [];
43
+
44
+ export default [...hostRoutes, ...cmsRoutes()] satisfies RouteConfig;
45
+ ```
46
+
47
+ `vite.config.ts` adds the plugin next to React Router's:
48
+
49
+ ```ts
50
+ import { plakboek } from '@plakboek/core/vite';
51
+ import { reactRouter } from '@react-router/dev/vite';
52
+ import { defineConfig } from 'vite';
53
+
54
+ export default defineConfig({
55
+ plugins: [
56
+ plakboek({
57
+ config: './plakboek.config.ts',
58
+ site: './app/site/index.ts',
59
+ }),
60
+ reactRouter(),
61
+ ],
62
+ });
63
+ ```
64
+
65
+ `server.ts` is the production entry point:
66
+
67
+ ```ts
68
+ import { createServer } from '@plakboek/core/server';
69
+
70
+ await createServer().start();
71
+ ```
72
+
73
+ `plakboek.config.ts` default-exports the result of `defineConfig` from
74
+ `@plakboek/core/config`: the site name, locales, default locale, timezone,
75
+ blocks, menus, modules, roles and an optional seed page. Everything structural
76
+ is declared in this file and checked when it is evaluated, so a bad
77
+ configuration fails the process at start. A block file imports `defineBlock`
78
+ from the package root, which stays safe for a client bundle.
79
+
80
+ The site module (`app/site/index.ts` in the starter) exports `renderDocument`
81
+ and, optionally, `renderNotFound` and `renderError`; each receives a site
82
+ context with the site name, locale, current path and `getMenu(name)`.
83
+
84
+ ## Environment
85
+
86
+ The runtime reads and validates these variables once, collects every problem
87
+ and fails with all of them listed, never printing a value. In development the
88
+ plugin loads `.env` from the project root; variables already set in the
89
+ process win over the file.
90
+
91
+ | Variable | Required | Meaning |
92
+ | -------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `DATABASE_URL` | yes | Postgres connection string (`postgres://` or `postgresql://`). |
94
+ | `DATABASE_MIGRATION_URL` | no | A direct (session) connection used only by `plakboek migrate`; falls back to `DATABASE_URL`. Migrations need it when `DATABASE_URL` is a pooler. |
95
+ | `PLAKBOEK_URL` | yes | The installation's public origin, such as `https://www.example.org`. No path, query or fragment. Never derived from a request. |
96
+ | `PLAKBOEK_SECRET` | yes | At least 32 characters, no fallback in any environment. Generate one with `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`. |
97
+ | `PLAKBOEK_BUILD_ID` | no | Identifies the deployed render code, such as a commit hash. Letters, digits, `.`, `_` and `-`, up to 128 characters. |
98
+ | `PLAKBOEK_SMTP_HOST` | no | The SMTP server. The rest of the SMTP group is read only when this is set. |
99
+ | `PLAKBOEK_SMTP_PORT` | no | SMTP port, 1 to 65535. |
100
+ | `PLAKBOEK_SMTP_SECURE` | no | `true` for implicit TLS (port 465), `false` otherwise. |
101
+ | `PLAKBOEK_SMTP_USER` | no | SMTP user; must be set together with `PLAKBOEK_SMTP_PASS`. |
102
+ | `PLAKBOEK_SMTP_PASS` | no | SMTP password; must be set together with `PLAKBOEK_SMTP_USER`. |
103
+ | `PLAKBOEK_MAIL_FROM` | no | The sender address for outgoing mail, such as `no-reply@example.org`. |
104
+ | `PLAKBOEK_MIGRATION_LOCK_WAIT_SECONDS` | no | How long `plakboek migrate` waits for another migrator (default 120). |
105
+ | `PLAKBOEK_BOOTSTRAP_PASSWORD` | no | The password `plakboek bootstrap` uses when it is not given on stdin or at a prompt. |
106
+ | `PORT` | no | The production server's port (default 3000). |
107
+ | `HOST` | no | The production server's bind address (default `0.0.0.0`). |
108
+
109
+ ## Routes and reserved paths
110
+
111
+ `cmsRoutes()` returns these routes, in this order, each pointing at a built
112
+ route module inside this package:
113
+
114
+ | Path | Purpose |
115
+ | ----------------------- | -------------------------------------------------- |
116
+ | `/cms/health` | Liveness and database probe (`200` or `503`). |
117
+ | `/cms/setup` | First-run setup of the first superadmin. |
118
+ | `/cms/setup/test-email` | Sends a test email from the setup page. |
119
+ | `/api/auth/*` | The authentication API. |
120
+ | `/` and `/*` | The visitor route: published pages, from Postgres. |
121
+
122
+ `/cms/*` and `/api/auth/*` are reserved for the CMS. A page whose first slug
123
+ segment is `cms` or `api` is shadowed and unreachable. A host's own routes win
124
+ over every CMS route when they come first in `app/routes.ts`.
125
+
126
+ The health body is a fixed `{"status":"ok"}` or `{"status":"unavailable"}`;
127
+ a database error never reaches the response.
128
+
129
+ The visitor route reads only the published snapshot of a page and renders it on
130
+ the server, with no client React. In production one in-process LRU cache per
131
+ app container holds rendered pages, and a publish purges the page right after
132
+ its transaction commits. Every other `NODE_ENV` runs uncached, so the next
133
+ request re-evaluates the host configuration.
134
+
135
+ The plugin's `edit` option names a module exporting `edit`, the editor's
136
+ entrypoint. A request carrying `_edit` is handed to it; without the option such
137
+ a request is bounced to the plain page.
138
+
139
+ ## The `plakboek` command
140
+
141
+ The package ships a `plakboek` bin. Each command loads `.env` from the current
142
+ directory first. Exit codes: `0` success, `1` failure, `2` bad usage.
143
+
144
+ | Command | What it does |
145
+ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `plakboek migrate` | Applies the core database migrations under an advisory lock, so two deploys cannot double-apply. `--lock-wait <seconds>` bounds the wait. |
147
+ | `plakboek bootstrap` | Creates the first superadmin and publishes the seed home page; only on an installation with no users. The password is never a flag. |
148
+ | `plakboek mail:test <address>` | Sends one test email through the real mail transport to check delivery. Needs no database, secret or host configuration. |
149
+
150
+ `plakboek --version` prints the installed version and `plakboek --help` lists
151
+ the commands.
152
+
153
+ `plakboek migrate` applies only the core package's migrations. A host that adds
154
+ its own Drizzle schema keeps a separate migration history and runs it with its
155
+ own tooling.
156
+
157
+ ## Public API
158
+
159
+ Each entry point below is pinned by a test: a name added to or removed from an
160
+ entry, or from its table here, fails the build.
161
+
162
+ ### Root entry: `@plakboek/core`
163
+
164
+ Safe to import from any bundle, including the editor and every block module.
165
+
166
+ | Name | Kind | Purpose |
167
+ | --------------------- | -------- | ------------------------------------------------------------------------ |
168
+ | `defineBlock` | function | Declares one block; an identity with type inference. |
169
+ | `defineModule` | function | Declares a named bundle of blocks, field types, widgets and constraints. |
170
+ | `HostBlockDefinition` | type | A block definition with its render component. |
171
+ | `HostModule` | type | What the virtual host module resolves to. |
172
+ | `MenuDefinitions` | type | Menus by name, declared in code. |
173
+ | `MenuItem` | type | One menu item: a label and an `href`. |
174
+ | `MenuLabel` | type | A label: one string, or a record keyed by locale. |
175
+ | `ModuleDefinition` | type | A module's contributions. |
176
+ | `PlakboekConfig` | type | The frozen, validated configuration `defineConfig` returns. |
177
+ | `PlakboekConfigInput` | type | What `defineConfig` accepts. |
178
+ | `ResolvedMenuItem` | type | A menu item resolved for one locale and the current path. |
179
+ | `SeedBlock` | type | One block of the seed page. |
180
+ | `SeedPage` | type | The page a fresh installation is seeded with. |
181
+ | `SiteContext` | type | What a site module receives alongside the page it composes. |
182
+ | `SiteModule` | type | The host's site chrome: the document around every page. |
183
+
184
+ ### Config entry: `@plakboek/core/config`
185
+
186
+ Server side only.
187
+
188
+ | Name | Kind | Purpose |
189
+ | ------------------------- | -------- | ------------------------------------------------------------------ |
190
+ | `defineConfig` | function | Validates the host configuration and returns it frozen. |
191
+ | `PlakboekConfigError` | class | Thrown with every problem found, collected first. |
192
+ | `defaultRoles` | constant | The role-to-permission mapping used when a host declares no roles. |
193
+ | `PlakboekConfigIssue` | type | One problem: a code and a message. |
194
+ | `PlakboekConfigIssueCode` | type | The closed set of problem codes. |
195
+
196
+ ### Routes entry: `@plakboek/core/routes`
197
+
198
+ | Name | Kind | Purpose |
199
+ | ----------- | -------- | ------------------------------------------------------------- |
200
+ | `cmsRoutes` | function | The CMS's route table, to spread after the host's own routes. |
201
+
202
+ ### Vite entry: `@plakboek/core/vite`
203
+
204
+ | Name | Kind | Purpose |
205
+ | ----------------------- | -------- | ----------------------------------------------------------------------- |
206
+ | `plakboek` | function | The Vite plugin that wires the host configuration to the route modules. |
207
+ | `PlakboekPluginOptions` | type | The plugin's options: `config`, `site` and an optional `edit`. |
208
+
209
+ ### Server entry: `@plakboek/core/server`
210
+
211
+ | Name | Kind | Purpose |
212
+ | --------------------- | -------- | -------------------------------------------------------------------- |
213
+ | `createServer` | function | The production server: static assets, then the React Router handler. |
214
+ | `CreateServerOptions` | type | Build paths, port, host and shutdown bound. |
215
+ | `PlakboekServer` | type | The Hono `app` and `start()`. |
216
+ | `RunningServer` | type | A started server's port, host and `close()`. |
217
+
218
+ ### Internal: `./route-modules/*`
219
+
220
+ The `./route-modules/*` subpath exists only so `cmsRoutes()` can point React
221
+ Router at the built route modules. It is internal: not part of the public API,
222
+ not covered by any compatibility promise, and subject to change in any release.
@@ -0,0 +1,86 @@
1
+ //#region src/setup/body-limit.ts
2
+ /**
3
+ * The size cap on the first-run POST endpoints (T-06-86). Both are reachable
4
+ * without authentication, so an unbounded body would let any client exhaust
5
+ * the app's memory. This module imports nothing from Hono: the route-module
6
+ * chunks use it too.
7
+ */
8
+ /**
9
+ * 16 KiB. A real setup submission is four short urlencoded text fields (a
10
+ * name of at most 200 code points, an email of at most 320 characters and two
11
+ * passwords), which stays under about 8 KiB even with every character
12
+ * percent-encoded. The test-email body is one token of about 200 bytes.
13
+ */
14
+ const SETUP_BODY_LIMIT_BYTES = 16384;
15
+ /**
16
+ * Whether a request path belongs to the setup endpoints. Case-insensitive on
17
+ * purpose: React Router matches routes case-insensitively, so a case-sensitive
18
+ * guard could be bypassed with `/CMS/Setup`.
19
+ */
20
+ function isSetupPath(pathname) {
21
+ const path = pathname.toLowerCase();
22
+ return path === "/cms/setup" || path.startsWith("/cms/setup/");
23
+ }
24
+ /**
25
+ * The 413 for a body over the cap: fixed text, uncacheable, unindexable and
26
+ * nothing taken from the request. No `Connection: close`: closing a socket
27
+ * that still holds unread request bytes can reset it before the client reads
28
+ * the answer.
29
+ */
30
+ function payloadTooLarge() {
31
+ return new Response("Payload Too Large", {
32
+ status: 413,
33
+ headers: {
34
+ "Content-Type": "text/plain; charset=utf-8",
35
+ "Cache-Control": "no-store",
36
+ "X-Robots-Tag": "noindex, nofollow"
37
+ }
38
+ });
39
+ }
40
+ /**
41
+ * Reads a request body of at most `maxBytes`, in every runtime. A declared
42
+ * Content-Length over the cap, or one that is not a plain integer, is refused
43
+ * without touching the body. It is never trusted as an upper bound, though:
44
+ * the bytes are counted as they arrive and the stream is cancelled the moment
45
+ * the count passes the cap, so a header that understates the body gains
46
+ * nothing.
47
+ */
48
+ async function readBodyWithinLimit(request, maxBytes) {
49
+ const declared = request.headers.get("content-length")?.trim();
50
+ if (declared !== void 0) {
51
+ if (!/^\d+$/.test(declared) || Number(declared) > maxBytes) return { kind: "too-large" };
52
+ }
53
+ if (request.body === null) return {
54
+ kind: "bytes",
55
+ bytes: /* @__PURE__ */ new Uint8Array(0)
56
+ };
57
+ const reader = request.body.getReader();
58
+ const chunks = [];
59
+ let total = 0;
60
+ try {
61
+ for (;;) {
62
+ const { done, value } = await reader.read();
63
+ if (done) break;
64
+ total += value.byteLength;
65
+ if (total > maxBytes) {
66
+ await reader.cancel().catch(() => void 0);
67
+ return { kind: "too-large" };
68
+ }
69
+ chunks.push(value);
70
+ }
71
+ } catch {
72
+ return { kind: "unreadable" };
73
+ }
74
+ const bytes = new Uint8Array(total);
75
+ let offset = 0;
76
+ for (const chunk of chunks) {
77
+ bytes.set(chunk, offset);
78
+ offset += chunk.byteLength;
79
+ }
80
+ return {
81
+ kind: "bytes",
82
+ bytes
83
+ };
84
+ }
85
+ //#endregion
86
+ export { readBodyWithinLimit as i, isSetupPath as n, payloadTooLarge as r, SETUP_BODY_LIMIT_BYTES as t };
@@ -0,0 +1,198 @@
1
+ import { r as readRuntimeEnv, t as PlakboekEnvError } from "./env-DEELGUpc.js";
2
+ import { t as closeAllDbs } from "./db-D5ByUm9I.js";
3
+ import { a as parseFlags, c as printLine, i as oneLine, n as connectionSecrets, o as printError, r as messageOf, t as UsageError } from "./output-Co4mJhFM.js";
4
+ import { t as createRuntime } from "./runtime-DDseFOVL.js";
5
+ import { a as validateBootstrapInput, i as bootstrapInstallation, n as DatabaseNotReadyError, r as InstallationNotEmptyError, t as BootstrapValidationError } from "./bootstrap-DplDxN7W.js";
6
+ import { pathToFileURL } from "node:url";
7
+ import { resolve } from "node:path";
8
+ import { existsSync } from "node:fs";
9
+ import { PASSWORD_MIN_LENGTH } from "@plakboek/auth";
10
+ import { Writable } from "node:stream";
11
+ import { createInterface } from "node:readline";
12
+ import { register } from "tsx/esm/api";
13
+ //#region src/cli/load-config.ts
14
+ /**
15
+ * Loads the host's `plakboek.config.ts` into the CLI process.
16
+ *
17
+ * tsx's global `register()` hook teaches this process's own module loader to
18
+ * run TypeScript, and a plain dynamic import then evaluates the file. The
19
+ * config's `defineConfig` therefore fills the very block registry (held in
20
+ * `@plakboek/pages`) that `insertBlock` reads later in this process. tsx's
21
+ * isolated per-call import helper is deliberately not used: it gives the
22
+ * config its own module instances, which would leave the CLI's registry empty.
23
+ */
24
+ /** The config file is missing or is not a Plakboek config. The message is
25
+ * the exact copy shown to the operator. */
26
+ var HostConfigError = class extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "HostConfigError";
30
+ }
31
+ };
32
+ function looksLikeConfig(value) {
33
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "siteName") === "string" && typeof Reflect.get(value, "pages") === "object" && Reflect.get(value, "pages") !== null;
34
+ }
35
+ async function loadHostConfig(path) {
36
+ const absolute = resolve(path);
37
+ if (!existsSync(absolute)) throw new HostConfigError(`Could not find ${path}. Run this command from the project root or pass --config <path>.`);
38
+ const unregister = register();
39
+ let loaded;
40
+ try {
41
+ loaded = await import(pathToFileURL(absolute).href);
42
+ } finally {
43
+ await unregister();
44
+ }
45
+ const config = typeof loaded === "object" && loaded !== null ? Reflect.get(loaded, "default") : void 0;
46
+ if (!looksLikeConfig(config)) throw new HostConfigError(`${path} must default-export defineConfig({...}).`);
47
+ return config;
48
+ }
49
+ //#endregion
50
+ //#region src/cli/bootstrap.ts
51
+ /**
52
+ * `plakboek bootstrap`: the headless first-superadmin surface (D-08). It calls
53
+ * the same `bootstrapInstallation` as the setup page, so the first user and
54
+ * the seeded home page are created identically from the terminal. It is the
55
+ * operator's way around the open web setup window (D-09).
56
+ *
57
+ * The password is never a flag value (it would land in shell history and
58
+ * process lists): it comes from stdin with `--password-stdin`, from
59
+ * `PLAKBOEK_BOOTSTRAP_PASSWORD`, or from a hidden prompt on a TTY.
60
+ */
61
+ const BOOTSTRAP_HELP = `Usage: plakboek bootstrap [--name <name>] [--email <address>] [--password-stdin]
62
+ [--config <path>]
63
+
64
+ Creates the first superadmin and publishes the seed home page. Only runs on an
65
+ installation with no users. Run plakboek migrate first.
66
+
67
+ The password is never a flag. Provide it on stdin with --password-stdin, in
68
+ PLAKBOEK_BOOTSTRAP_PASSWORD, or at the hidden prompt in a terminal.
69
+
70
+ Options:
71
+ --name <name> The superadmin's name
72
+ --email <address> The superadmin's email address
73
+ --password-stdin Read the password from stdin
74
+ --config <path> The host config (default ./plakboek.config.ts)
75
+ -h, --help Show this help`;
76
+ const NON_INTERACTIVE = "when input is not interactive";
77
+ /** Asks one question on the terminal; `hidden` suppresses the echo. */
78
+ function ask(question, hidden) {
79
+ return new Promise((resolve, reject) => {
80
+ const sink = new Writable({ write(_chunk, _encoding, done) {
81
+ done();
82
+ } });
83
+ process.stdout.write(`${question}: `);
84
+ const lines = createInterface({
85
+ input: process.stdin,
86
+ output: hidden ? sink : process.stdout,
87
+ terminal: true
88
+ });
89
+ let answered = false;
90
+ lines.question("", (answer) => {
91
+ answered = true;
92
+ lines.close();
93
+ if (hidden) process.stdout.write("\n");
94
+ resolve(answer);
95
+ });
96
+ lines.on("close", () => {
97
+ if (!answered) reject(new UsageError("Input was cancelled."));
98
+ });
99
+ });
100
+ }
101
+ async function readStdin() {
102
+ const chunks = [];
103
+ for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
104
+ return Buffer.concat(chunks).toString("utf8");
105
+ }
106
+ /** Exactly one trailing newline (LF or CRLF) is not part of the password. */
107
+ function stripTrailingNewline(value) {
108
+ return value.replace(/\r?\n$/, "");
109
+ }
110
+ async function resolvePassword(fromStdin, env, interactive) {
111
+ if (fromStdin) return stripTrailingNewline(await readStdin());
112
+ const fromEnv = env.PLAKBOEK_BOOTSTRAP_PASSWORD;
113
+ if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv;
114
+ if (!interactive) throw new UsageError(`--password-stdin or PLAKBOEK_BOOTSTRAP_PASSWORD is required ${NON_INTERACTIVE}.`);
115
+ const password = await ask("Password", true);
116
+ if (password !== await ask("Confirm password", true)) throw new UsageError("The two passwords do not match.");
117
+ return password;
118
+ }
119
+ /** The copy for one field problem; the password rule has its own wording. */
120
+ function issueCopy(issue) {
121
+ return issue.field === "password" ? `The password must be at least ${String(PASSWORD_MIN_LENGTH)} characters.` : issue.message;
122
+ }
123
+ function reportIssues(issues) {
124
+ for (const issue of issues) printError(issueCopy(issue));
125
+ return 2;
126
+ }
127
+ async function runBootstrapCommand(argv, env = process.env) {
128
+ const { values } = parseFlags(argv, {
129
+ name: { type: "string" },
130
+ email: { type: "string" },
131
+ "password-stdin": { type: "boolean" },
132
+ config: { type: "string" },
133
+ help: {
134
+ type: "boolean",
135
+ short: "h"
136
+ }
137
+ });
138
+ if (values.help === true) {
139
+ printLine(BOOTSTRAP_HELP);
140
+ return 0;
141
+ }
142
+ const interactive = process.stdin.isTTY === true;
143
+ let name = values.name;
144
+ if (name === void 0) {
145
+ if (!interactive) throw new UsageError(`--name is required ${NON_INTERACTIVE}.`);
146
+ name = await ask("Name", false);
147
+ }
148
+ let email = values.email;
149
+ if (email === void 0) {
150
+ if (!interactive) throw new UsageError(`--email is required ${NON_INTERACTIVE}.`);
151
+ email = await ask("Email address", false);
152
+ }
153
+ const password = await resolvePassword(values["password-stdin"] === true, env, interactive);
154
+ const issues = validateBootstrapInput({
155
+ name,
156
+ email,
157
+ password
158
+ });
159
+ if (issues.length > 0) return reportIssues(issues);
160
+ let runtimeEnv;
161
+ try {
162
+ runtimeEnv = readRuntimeEnv(env);
163
+ } catch (error) {
164
+ if (error instanceof PlakboekEnvError) {
165
+ for (const issue of error.issues) printError(issue.message);
166
+ return 1;
167
+ }
168
+ throw error;
169
+ }
170
+ const secrets = [
171
+ password,
172
+ ...connectionSecrets(runtimeEnv.databaseUrl),
173
+ ...runtimeEnv.smtp?.pass === void 0 ? [] : [runtimeEnv.smtp.pass]
174
+ ];
175
+ try {
176
+ const config = await loadHostConfig(values.config ?? "./plakboek.config.ts");
177
+ const runtime = createRuntime({ config }, runtimeEnv);
178
+ const result = await bootstrapInstallation(runtime, {
179
+ name,
180
+ email,
181
+ password
182
+ });
183
+ printLine(`Created superadmin ${result.email}.`);
184
+ if (result.homePublished) printLine(`Published the home page at ${result.homePath}.`);
185
+ return 0;
186
+ } catch (error) {
187
+ if (error instanceof BootstrapValidationError) return reportIssues(error.issues);
188
+ if (error instanceof InstallationNotEmptyError) printError(error.message);
189
+ else if (error instanceof DatabaseNotReadyError) printError("The database is not ready. Run plakboek migrate first.");
190
+ else if (error instanceof HostConfigError) printError(error.message);
191
+ else printError(oneLine(messageOf(error), secrets));
192
+ return 1;
193
+ } finally {
194
+ await closeAllDbs();
195
+ }
196
+ }
197
+ //#endregion
198
+ export { runBootstrapCommand };