@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.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/dist/body-limit-DViLUMvl.js +86 -0
- package/dist/bootstrap-BUV7ahx4.js +198 -0
- package/dist/bootstrap-DplDxN7W.js +181 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +85 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +230 -0
- package/dist/db-D5ByUm9I.js +76 -0
- package/dist/env-DEELGUpc.js +175 -0
- package/dist/handlers-CkdunB39.js +583 -0
- package/dist/host-C_7Zxk1H.js +12 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +12 -0
- package/dist/mail-B6aq49_D.js +63 -0
- package/dist/mail-test-C0Xm6ZtN.js +72 -0
- package/dist/menus-CoMJjPmu.js +113 -0
- package/dist/migrate-Dq-gMq6J.js +103 -0
- package/dist/modules-BQYzOumU.js +12 -0
- package/dist/output-Co4mJhFM.js +69 -0
- package/dist/route-modules/auth-api.d.ts +5 -0
- package/dist/route-modules/auth-api.js +10 -0
- package/dist/route-modules/health.d.ts +3 -0
- package/dist/route-modules/health.js +32 -0
- package/dist/route-modules/setup.d.ts +5 -0
- package/dist/route-modules/setup.js +11 -0
- package/dist/route-modules/test-email.d.ts +5 -0
- package/dist/route-modules/test-email.js +16 -0
- package/dist/route-modules/visitor.d.ts +4 -0
- package/dist/route-modules/visitor.js +38 -0
- package/dist/routes.d.ts +4 -0
- package/dist/routes.js +38 -0
- package/dist/runtime-DDseFOVL.js +163 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +126 -0
- package/dist/types-B5BJRK3k.d.ts +102 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +68 -0
- 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
|
-
#
|
|
1
|
+
# @plakboek/core
|
|
2
2
|
|
|
3
|
-
|
|
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 };
|