@dokebi/cli 0.4.26 → 0.4.27
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/AI.md +20 -3
- package/catalog.json +5 -5
- package/dist/bin.js +121 -3
- package/dist/check/shared-state-rules.d.ts +13 -0
- package/dist/check/shared-state-rules.js +5 -2
- package/dist/commands.js +76 -7
- package/dist/config-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -0
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-inspector.js +17 -1
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/package.json +3 -3
package/AI.md
CHANGED
|
@@ -75,8 +75,11 @@ The `blog` plan, depending on the answers: `init` (when the folder is not a proj
|
|
|
75
75
|
| `dokebi config secret <id>.<path> [--stdin] [--rotate]` | Writes a random secret (32 bytes; a one-item list when the schema wants a list) into dokebi.yaml, or with `--stdin` a secret you were given (the token of a log receiver: `config secret logging.targets.studio.token --stdin < token.txt`, or typed without echo when stdin is a terminal; one line, at most 64 KiB, nothing or several lines is `INVALID_SET`; the result has `generated: false, stdin: true`). The value never appears in output or logs; an existing value stays (`SECRET_KEPT`, with a `hint` naming `--rotate`); an empty one (`token:`, `''`, `[]`) holds no secret and is filled, and so is the sample's placeholder (`<set the postgres URL here or run npx dokebi dev-infra>`, what `config init` lists in `placeholders`). An address (a key `url` or `uri`, or one ending in `Url` or `Uri`) is never generated: without `--stdin` it is `INVALID_SET` with `details.fix` (`config secret dokebi-db.connections.main.options.url --stdin < url.txt`). Example: `config secret dokebi-websocket-hub.secret`. `logging.<path>` is `options.logging` (the @dokebi/logger options); a list item is named by its `name` (else its `type`) or its index (`logging.targets.studio.token`, `logging.targets.3.token`); a name that is not there is `INVALID_SET` with `details.items`. A secret a plugin declares (such as `dokebi-session.secrets`) is already written by `add`; run it after you removed one. `--rotate` replaces an existing value (AI-M05): a list (`dokebi-session.secrets`, `dokebi-csrf.secrets`, `dokebi-auth.secrets`: the first signs, all verify) gets the new secret **first** and keeps the previous ones after it, at most 2 (`kept`); older ones are dropped (`dropped`, warning `SECRETS_DROPPED` with `count`), and fewer are kept when the schema allows fewer items; when the schema takes no list with a kept one (only a new string or a one-item list), every previous secret is dropped and counted the same way. A string is replaced. The result is `secret{path, list, generated, rotated, kept, dropped}` and a next step to restart the app (`dev` restarts on its own); never a value. What a rotation changes: session cookies signed with a kept secret still verify and are re-signed with the new one as they are used (@dokebi/session); CSRF tokens of a kept secret still verify (@dokebi/csrf); auth stamps signed with a kept key still verify, and remember-me and API tokens are never re-signed, so keep the old key for their longest lifetime (@dokebi/auth AI.md §9). A dropped secret (or a replaced string) invalidates what it signed: sessions end, open forms fail once, logins end. An environment override of the option (`secretsEnv`), where it is set, still wins over dokebi.yaml |
|
|
76
76
|
| `dokebi config set <id>.<path>=<value>...` | Non-secret options into dokebi.yaml (the value is JSON when it parses, otherwise text; checked against the plugin schema, only a missing required value becomes `configRequired`). A plugin id that is not installed is `UNKNOWN_PLUGIN`; any other section nothing declares is `UNDECLARED_SECTION` (D-0155: declare it with `npx dokebi settings add <name>`). `logging.<path>` sets `options.logging` (D-0151): with @dokebi/logger every key of its schema (`config set logging.level=debug logging.targets.file.enabled=true`; list items by `name`, else `type`, or index); without it only `logging.level` and `logging.plugins.<plugin>` (core's levels), anything else is `INVALID_SET` with the add command in `fix`. An old option name (D-0071 alias, `dokebi-web.bodyLimit` of `maxBodyBytes`) and its new name both set is `INVALID_PLUGIN_OPTIONS` (`details.errors[]` with `keyword: "conflict"` and the `field`; `fix`: `config unset` the old name), since the plugin refuses both at start; nothing is written. `add` and `upgrade` write no default beside a set old name for the same reason. The results of `config set`, `unset`, `secret` and `diff --write` carry `paths`: for each file it changed, the option paths it `removed`, `set` and `added` (`{ "dokebi.yaml": { removed: ["logging.redact"], set: [], added: [] } }`; a list is one path; never a value). A path outside the ones asked for (named list items count as their list) is `PATH_MISMATCH`: refused, or a warning in a `--dry-run`, which therefore shows exactly what a run would change. Spell paths as the files do (`npx dokebi config show`; the logger's are `logging.<path>`). |
|
|
77
77
|
| `dokebi config unset <id>[.<path>]...` | Removes options ([D-0153](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0153-config-unset.md)) from dokebi.yaml and from dokebi.example.yaml where the sample has them, in one transaction, with the mappings left empty (the plugin section stays): `{ removed: [{ option, files }], changed, warnings?, configRequired? }`. Values are never printed. A path that is not set is the warning `OPTION_NOT_SET` (a repeated call changes nothing); a secret may be removed (warning `SECRET_REMOVED`: what it signed no longer verifies; to replace one use `config secret --rotate`). The plugin need not be installed (a section `dokebi remove` kept). `logging.<path>` removes from `options.logging`, list items by `name` (else `type`) or index (`config unset logging.targets.studio` removes that target); an item that is not there is `OPTION_NOT_SET`. The removal may only leave missing required values (`configRequired`) or issues the options already had (warning `OPTIONS_STILL_INVALID` with the next `hint`); a new issue is `INVALID_PLUGIN_OPTIONS` and nothing is written. Use it for a key a plugin does not know: `INVALID_PLUGIN_OPTIONS` (from add, config set, doctor) names `npx dokebi config unset <id>.<key>` in its `fix`, and a key that moved to another plugin (`dokebi-web.files` → `dokebi-static.mounts`, D-0147) says where it went and the commands that carry it over A section a file still holds as written is that section, never the plugin the name belongs to: in a sample with the old `options.dokebi-logger` (now `options.logging`), `config unset dokebi-logger.level` removes that old key and warns `SECTION_NOT_READ`, and the live `logging.level` stays (AI-M47 report: it removed the live one before); with no such section the plugin id is `PATH_MISMATCH` (name it `logging.<path>`). List indexes and names are of each file as it is before the call, whichever name reaches the section; one item named twice (`logging.targets.studio` and its index) is removed once. A section alone (`config unset dokebi-logger`) removes a whole section nothing reads; a plugin's, the settings' or a core section (`logging`, `reload`) that way is `INVALID_SET` |
|
|
78
|
+
| `dokebi config secrets` | The secret option paths the app needs, **by name, never values** ([D-0184](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0184-production-deploy.md)): `{ file, secretsFile, listed, secrets[{path, source, listed, required}], missing }` from the settings the app reads (`DOKEBI_CONFIG`, else dokebi.yaml) merged with the secrets file `DOKEBI_SECRETS` names. `source` is `config`, `secrets` or `null` (not set: absent, empty or a `<placeholder>`); `listed` means the document's `secrets:` list names it (the app refuses to start without it), `required` that an installed plugin's install plan generates it (`dokebi-session.secrets`). Exit 1 with `ok: false` while `missing` is not empty. Warnings `SECRET_IN_CONFIG_FILE` (with `DOKEBI_SECRETS` set, the settings file still holds a secret) and `SECRETS_PLAN_UNREADABLE` (a plugin's install module could not run: npm install). A secrets file readable by others, or the same option in both files, fails as the app would (`CONFIG_SECRETS_UNSAFE`, `CONFIG_SECRETS_CONFLICT`) |
|
|
79
|
+
| `dokebi deploy init [--target <systemd\|compose>] [--instance <name>]... [--port <n>] [--dry-run]` | **An existing project made ready for a server** (D-0184, §11): per instance (`production` port 3000, `staging` 3001, another name needs `--port`; two instances never share a port: `PORT_IN_USE`) `deploy/<instance>/dokebi.example.yaml` (the sample without its secrets, with the instance's web host/port/trustProxy, storage folder and a `secrets:` list), `deploy/<instance>/secrets.example.yaml` (the secret paths with placeholders), `deploy/systemd/<app>-<instance>.service` (or `deploy/compose/<instance>.yaml` with `--target compose`, and Dockerfile/.dockerignore when missing), with @dokebi/db also `deploy/systemd/<app>-<instance>-backup.service`/`.timer`, `deploy/backup.mjs` and `deploy/restore-drill.mjs`, and `DEPLOY.md`. Result `{ app, target, instances[{instance, port, unit, settings, secrets, code, data, logs, backups, database}], secrets, changed, unchanged, kept }`. A second run changes nothing; a file you edited since is kept (`kept`, warning `DEPLOY_FILE_KEPT`); an untouched one is rewritten (DEPLOY.md gains a new instance). Errors `NOT_A_PROJECT`, `APP_NAME_INVALID` (package.json name), `EXAMPLE_MISSING`, `PORT_REQUIRED`, `PORT_IN_USE`, `OPTIONS_SCHEMA_UNREADABLE` (npm install first: without a plugin's schema its secrets cannot be told apart) |
|
|
80
|
+
| `dokebi deploy proxy --caddy\|--nginx --domain <host>... [--instance <name>] [--dry-run]` | The reverse proxy file of an instance (D-0184): `deploy/proxy/<app>-<instance>.Caddyfile` or `.nginx.conf`, from `deploy/<instance>/dokebi.example.yaml` (port, health routes, body limit, request timeout): TLS (Caddy's automatic certificates; nginx with certbot's paths and an http → https redirect), `X-Forwarded-For/Proto/Host`, WebSocket upgrades, unbuffered SSE (the app sends `X-Accel-Buffering: no`) and a long read timeout when the app keeps streams (SSE, WebSocket, MCP), `/health/live` and `/health/ready` passed through uncached, the app's own `Cache-Control` and compression left as they are. Warnings `TRUST_PROXY_MISSING` (the sample's `dokebi-web.trustProxy` lacks 127.0.0.1 or ::1) and `DOMAIN_NOT_MAPPED` (a domain not in `dokebi-app.domains`). Errors `WEB_NOT_INSTALLED`, `INSTANCE_UNKNOWN` (deploy init first) |
|
|
78
81
|
| `dokebi dev-infra [up\|start\|stop\|down\|status] [--file <path>] [--db-port <n>] [--dry-run]` | Without an action: prepare compose.yaml and the database URL in dokebi.yaml; no .env is generated. With an action: run Docker Compose using the YAML password in the child environment only. Repeat --file for additional Compose files. down retains data volumes. --db-port and --dry-run apply only to preparation. On a kept compose.yaml (`COMPOSE_KEPT`, its published port is the URL's) `--db-port <n>` changes only the published host port of `services.db` in it (every other byte of the file stays) and the URL in dokebi.yaml, with the warning `COMPOSE_PORT_CHANGED` (`hint`: `dev-infra up` recreates a running container on the new port and keeps its data volume); a taken port is `PORT_IN_USE`. A port entry the CLI cannot read (a variable, a range) refuses `--db-port` with `COMPOSE_PORT_UNKNOWN` (`details.file`, `details.fix`: set the entry yourself) and nothing is written; without the flag the run keeps the URL's port and warns `COMPOSE_PORT_UNKNOWN`. A MongoDB is a single-node replica set (`rs0`, D-0103): the generated mongod gets `--replSet rs0` and a key file made inside the container at each start (mongod needs one for a replica set with authentication), the healthcheck initiates the set once and passes when the member is the writable primary (so `up` returns when transactions work), and the URL ends with `replicaSet=rs0&directConnection=true`; `service.replicaSet` says so. A kept compose.yaml whose server is standalone (written before) keeps a standalone URL and gives the warning `DEV_INFRA_STANDALONE`, whose `hint` is the switch: `dev-infra down`, move compose.yaml away, `dev-infra --db-port <its port>`, `dev-infra up` (password and data volume kept). `DEV_INFRA_CONFIG_INVALID` means the main database URL is invalid/unset; `DEV_INFRA_FAILED` means the Compose action failed. |
|
|
79
|
-
| `dokebi verify [--wait-ms <n>] [--test-timeout-ms <n>]` | `{ ok, checks[{name, ok, skipped?, ms, code?, message?, details?}], urls[], listen[], tests?, tellUser?, relay?, finalReport?, warnings? }` (`warnings`: `ENV_DOKEBI_CONFIG_IGNORED` when `.env` sets `DOKEBI_CONFIG`, §2.2): doctor → check (`dokebi check` below, reusing the doctor result) → typecheck (skipped without the script) → build (Vite) → test (the package.json `test` script, skipped without one: `no test script`; see **Tests in verify** below) → preflight (`ALREADY_RUNNING`, `PORT_IN_USE`, `DB_URL_MISSING`; a taken web port is the user's call as in the recipe preflight: the problem has `alternative` (the first free port after it), `fix` (`npx dokebi config set dokebi-web.port=<alternative>`), `waitForUser`, `question` and `next`, and the result repeats `waitForUser`, `question` and `next`; `waitForUser` is false when the last recipe ran with `--defaults`) → start (`--wait-ms`, default 30000) → HTTP (connects to the listening address: `/health/ready`, and `GET /` with the Host header of every app domain; with neither a readiness route (`healthRoutes: false`) nor an app domain, one identity probe `GET /` that passes with any status below 500 carrying this run's header, so HTTP never passes without a request: `details.requests[].identity: true`; each request must complete within 5 s, a response cut off or never finished is `HTTP_FAILED`) → stop. `urls` are the addresses the app answers (see `start`), `listen` the bound addresses. `ok` only when everything passes (otherwise exit 1). A doctor or preflight failure skips the start; a check failure does not. It never stops an app that already runs. **HTTP counts only answers from the app verify started**: it passes a random token in `DOKEBI_VERIFY_TOKEN` and @dokebi/web echoes it in the response header `x-dokebi-verify` (only while that variable is set, no cost in production). An answer without the token comes from another server on the port: `FOREIGN_SERVER` (`details.requests[].instance: other`). When the app did not start, HTTP is skipped and fails (`NOT_STARTED`). stop ends the processes verify started by the pid start returned (no `ps` needed, works in a sandbox); `APP_EXITED` when the app ended by itself first, `STOP_FAILED` when a signal cannot be sent; a SIGINT, SIGTERM or SIGHUP to verify while its app runs stops that app first (forced after 5 s), then verify ends as the signal says (also while the start step still waits for the app: nothing it started is left running). With `ok: true` and an applied recipe (`.dokebi/recipe.json`), `tellUser: string[]` holds the lines your final message must include (each choice with who made it: `<id> = <value>: your answer`, `: chosen by the AI (you left the choice to it)` followed by `: <aiReason>` when you gave one, `: chosen by default (<reason>)`; then the notices); a notice that names the app address (the admin login) is filled in with the address the app has now (`urls`, else `dokebi-web.port`), so a later port change shows the new one; with them comes `relay: { tellUser: 'verbatim', instruction }` (AI-M05 F6): copy the lines unchanged into your final message, never translated, reworded or summarized, and add your own summary only after them; when verify also has `finalReport`, the instruction adds that the table or list a heading announces (Database practice, Silenced check rules) is written right under it in the same message — a results file or a link does not replace it; the human output ends with them under `Tell the user exactly these lines.` and the same instruction. An EJS blog without @dokebi/session gets a notice that offers it to the user (AI-M05 F3: with `--defaults` the next step says not to add it, since the defaults are the plan; with `--answers-from user` the next step says to ask first). Without a recipe record there is no `tellUser`. With `ok: true` and @dokebi/db installed, `finalReport: string[]` (D-0105 §4) says what your final message must also contain, written by you rather than copied: the table of section 6 of `node_modules/@dokebi/db/AI.md`, one row per rule with the queries or jobs it applies to, the measurement and the verdict (pass, or the fix you made) (a results file does not replace it); the human output lists it under `Your final message must also contain:`, and when there are `tellUser` lines they end with a heading for each (`Database practice (@dokebi/db AI.md section 6): the table below, here in this message, has one row per rule of that section (6.1 Cost follows the page, not the table; …), …`, naming the rules of the installed AI.md, `Silenced check rules (dokebi-check allow markers), each followed below, here in this message, by why the rule does not apply there: …`): relay them as they are and write the table or the reasons under them |
|
|
82
|
+
| `dokebi verify [--wait-ms <n>] [--test-timeout-ms <n>]` · `verify --remote <https-url> [--timeout-ms <ms>]` | With `--remote` (D-0184) only that site is checked, nothing local runs: `{ ok, url, checks[{name, ok, warning?, status?, detail, ms}], certificate? }` with the checks `tls` (the certificate is valid for the host; under 14 days left is a warning), `redirect` (http answers a redirect to https; a warning otherwise), `live` and `ready` (the health routes of the settings when run in a project, else `/health/live` and `/health/ready`, must answer 200), `headers` (HSTS and `X-Content-Type-Options: nosniff`; on an HTML answer also `X-Frame-Options` or CSP `frame-ancestors`, and `Referrer-Policy` (a warning); `X-Powered-By` fails; HSTS under 180 days is a warning); exit 1 when a check fails. Without it: `{ ok, checks[{name, ok, skipped?, ms, code?, message?, details?}], urls[], listen[], tests?, tellUser?, relay?, finalReport?, warnings? }` (`warnings`: `ENV_DOKEBI_CONFIG_IGNORED` when `.env` sets `DOKEBI_CONFIG`, §2.2): doctor → check (`dokebi check` below, reusing the doctor result) → typecheck (skipped without the script) → build (Vite) → test (the package.json `test` script, skipped without one: `no test script`; see **Tests in verify** below) → preflight (`ALREADY_RUNNING`, `PORT_IN_USE`, `DB_URL_MISSING`; a taken web port is the user's call as in the recipe preflight: the problem has `alternative` (the first free port after it), `fix` (`npx dokebi config set dokebi-web.port=<alternative>`), `waitForUser`, `question` and `next`, and the result repeats `waitForUser`, `question` and `next`; `waitForUser` is false when the last recipe ran with `--defaults`) → start (`--wait-ms`, default 30000) → HTTP (connects to the listening address: `/health/ready`, and `GET /` with the Host header of every app domain; with neither a readiness route (`healthRoutes: false`) nor an app domain, one identity probe `GET /` that passes with any status below 500 carrying this run's header, so HTTP never passes without a request: `details.requests[].identity: true`; each request must complete within 5 s, a response cut off or never finished is `HTTP_FAILED`) → stop. `urls` are the addresses the app answers (see `start`), `listen` the bound addresses. `ok` only when everything passes (otherwise exit 1). A doctor or preflight failure skips the start; a check failure does not. It never stops an app that already runs. **HTTP counts only answers from the app verify started**: it passes a random token in `DOKEBI_VERIFY_TOKEN` and @dokebi/web echoes it in the response header `x-dokebi-verify` (only while that variable is set, no cost in production). An answer without the token comes from another server on the port: `FOREIGN_SERVER` (`details.requests[].instance: other`). When the app did not start, HTTP is skipped and fails (`NOT_STARTED`). stop ends the processes verify started by the pid start returned (no `ps` needed, works in a sandbox); `APP_EXITED` when the app ended by itself first, `STOP_FAILED` when a signal cannot be sent; a SIGINT, SIGTERM or SIGHUP to verify while its app runs stops that app first (forced after 5 s), then verify ends as the signal says (also while the start step still waits for the app: nothing it started is left running). With `ok: true` and an applied recipe (`.dokebi/recipe.json`), `tellUser: string[]` holds the lines your final message must include (each choice with who made it: `<id> = <value>: your answer`, `: chosen by the AI (you left the choice to it)` followed by `: <aiReason>` when you gave one, `: chosen by default (<reason>)`; then the notices); a notice that names the app address (the admin login) is filled in with the address the app has now (`urls`, else `dokebi-web.port`), so a later port change shows the new one; with them comes `relay: { tellUser: 'verbatim', instruction }` (AI-M05 F6): copy the lines unchanged into your final message, never translated, reworded or summarized, and add your own summary only after them; when verify also has `finalReport`, the instruction adds that the table or list a heading announces (Database practice, Silenced check rules) is written right under it in the same message — a results file or a link does not replace it; the human output ends with them under `Tell the user exactly these lines.` and the same instruction. An EJS blog without @dokebi/session gets a notice that offers it to the user (AI-M05 F3: with `--defaults` the next step says not to add it, since the defaults are the plan; with `--answers-from user` the next step says to ask first). Without a recipe record there is no `tellUser`. With `ok: true` and @dokebi/db installed, `finalReport: string[]` (D-0105 §4) says what your final message must also contain, written by you rather than copied: the table of section 6 of `node_modules/@dokebi/db/AI.md`, one row per rule with the queries or jobs it applies to, the measurement and the verdict (pass, or the fix you made) (a results file does not replace it); the human output lists it under `Your final message must also contain:`, and when there are `tellUser` lines they end with a heading for each (`Database practice (@dokebi/db AI.md section 6): the table below, here in this message, has one row per rule of that section (6.1 Cost follows the page, not the table; …), …`, naming the rules of the installed AI.md, `Silenced check rules (dokebi-check allow markers), each followed below, here in this message, by why the rule does not apply there: …`): relay them as they are and write the table or the reasons under them |
|
|
80
83
|
| `dokebi mail preview <name> [--locale <locale>]` | Render preview.json without network connections or data hooks; JSON includes subject, html, text and locale |
|
|
81
84
|
| `dokebi describe [--with-db] [--timeout-ms <ms>]` | What the app has, without serving (D-0077): it loads the project like `start` (the settings file — `DOKEBI_CONFIG`, else dokebi.yaml —, `src/dokebi.generated.ts`, the project's `.env` under the environment, the doctor check first: `START_CHECK_FAILED`) in core's describe mode: every plugin's setup runs, no start or ready hook, so nothing listens, no database, Redis or NATS connection is opened and no job is bound. `--json` is `{ schemaVersion: 1, app: { name, core, plugins[{name, version, described}], apps[], domains[], unknownDomain, web, session, csrf, auth, … }, routes[{method, url, id?, app?, host?, constraints?, schema?{body, querystring, params, headers, response}, config?{auth, can, roles, csrf, …}, auth?{mode, realm, via, can?}, seal?, health?}], pages[{app, engine: ejs\|inertia, template, name, file}], jobs[], schedules[], errors[{code, status, message, hint?}], config: { keys[], env[{name, plugin, purpose}] }, db: { connections[{name, adapter, startup?, dialect?, schemas?, tables?, collections?, unavailable?}], unavailable? }, unavailable[{plugin, section?, code, reason}], warnings?, ms }`, checked by the JSON Schema `describe.schema.json` of this package. `route.auth` is the login rule @dokebi/auth applies (its `default` included); `config.keys` are option paths and `config.env` variable names, **never a value**; `errors` are the codes of `src/shared/errors.ts`. A plugin whose setup fails is listed in `unavailable` (with the plugins that need it, `DEPENDENCY_UNAVAILABLE`) and the rest is still described; a database, Redis, NATS, SMTP or S3 that is not running is never the reason (D-0133: plugins on never-connected clients still set up; a nats event bus refuses only `publish`, with `DESCRIBING`), while a real configuration error (an unknown connection name, a connection of the wrong kind) still is. Without `--with-db` the tables are not read (`db.unavailable` says so; describe mode never connects). **`--with-db`** adds a separate step that connects: for each dokebi-db connection it runs the project's `dokebi-migrate schema --connection <name> --json` (@dokebi/migrate 0.4.2) and puts its `dialect` and `tables` (columns with type, nullable, default, identity, generated; primary key, unique, indexes, foreign keys, checks; `isMigrationHistory` on `dokebi_migrations`) or MongoDB `collections` into `db.connections[]`; a connection it cannot read (not listed in `options.dokebi-migrate.connections`, @dokebi/migrate missing, the database refused, `--timeout-ms` passed) gets its own `unavailable` reason and describe still answers. `startup: 'optional'` marks a connection dokebi.yaml lets the app run without ([D-0127](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0127-optional-db-startup.md); the default `required` is left out); `dokebi-migrate schema` opens it as required, so while it is down it is `unavailable` with the open or check error, and `drift` lists it in `unavailable`. No URL or password is printed. Run it before writing a migration so you change what the database really has. Errors: `DESCRIBE_TIMEOUT` (default 30000 ms), `DESCRIBE_FAILED` (the app exited first; `details.output` holds its last lines), `DESCRIBE_UNSUPPORTED` (@dokebi/core older than 0.4.2), or the app's own code (`INVALID_PLUGIN_OPTIONS`, …). Read it before you change an app you did not write |
|
|
82
85
|
| `dokebi describe --openapi [--app <name>]` | OpenAPI 3.1 of the same description (D-0077 C), **one document per app** (route ids are unique per app): `info.title` the app, `servers` its domains (`http://<host>:<port>` for local hosts, `https://<host>` otherwise), `operationId` the route `config.id` (`<id>_<method>` on every operation when the id names several: a route of several methods, or the routes of one plugin route key such as `oauth.authorize_get` and `oauth.authorize_post`, D-0142), else `<method>_<path>` (`get_posts_id`), numbered `_2` on a collision. Request parameters and bodies (JSON, and form bodies with `formBody`) and responses come from the route schemas (draft-07 `items` tuples, `dependencies`, `nullable` and local `#/definitions` refs rewritten for JSON Schema 2020-12; a ref to an `addSchema` schema becomes an open schema with `x-dokebi-ref`). `components.schemas.DokebiError` is the D-0074 body; operations list 422 `VALIDATION_FAILED` (body or query schema), 400 `INVALID_REQUEST` (params or headers schema), 401 `UNAUTHENTICATED` (a login needed), 403 `FORBIDDEN` (`roles` or `can`) and 403 `CSRF_FAILED` (POST, PUT, PATCH, DELETE under @dokebi/csrf unless `config.csrf: false` or `exclude`, with the token header). Security: `cookieAuth` (the session cookie) and `bearerAuth` (`via: token` or `any`), `{}` added for `optional`; `x-dokebi-auth` keeps `{ mode, realm, via, roles?, can? }` (also `routes[].auth.roles` in `--json`). Health and Vite dev routes are left out; routes outside apps are in every app's document. The app's `defineErrors` codes are `x-dokebi-errors`. Without `--app` and with several apps: `{ apps: { <name>: document } }`; an unknown name is `UNKNOWN_APP`. Without `--json` the document is printed as JSON |
|
|
@@ -85,7 +88,7 @@ The `blog` plan, depending on the answers: `init` (when the folder is not a proj
|
|
|
85
88
|
| `dokebi upgrade <plugin id> [--to <version\|package\|tgz>] [--dry-run]` | Change the version. Do not use remove and add instead. Without `--to` it picks the highest version that fits the core and the installed packages, as `add` does (`NEWER_VERSION_HELD_BACK` names a newer one and what it waits for; the same version again changes nothing); an unreachable registry or a workspace package takes npm's `latest`. The copies of templates the project owns (the @dokebi/db adapters, `vite.config.ts` and `vite.ssr.config.ts`) are never rewritten: fixes of the new templates they lack are the warnings `TEMPLATE_FIX_MISSING` (`path`, `plugin`, `hint`: the change to make), as doctor reports them. With the same version and a changed manifest it applies the manifest again (D-0037, `reason: MANIFEST_CHANGED`); with the same version and manifest but other package content (an unpublished version rebuilt, given as `--to <tgz>`; the package-lock integrity differs) it installs the new code (`reason: PACKAGE_CHANGED`) — the way to replace a local snapshot. After package.json moved several plugins at once, upgrade them one by one, dependencies first: each result lists the ones still to do as `MANIFEST_CHANGED` warnings with their `hint`. A required plugin the new version needs and the project lacks is installed with it, as `add` would (`autoInstalled`, D-0116), when its manifest names the package and it needs no choice or settings; otherwise nothing is written and it is `MISSING_DEPENDENCY` (`mutation: none`) whose `details.fix` is the command to run first (`npx dokebi add <package>`, then the same upgrade again) |
|
|
86
89
|
| `dokebi remove <plugin id> [--dry-run]` | Remove a plugin. `REVERSE_DEPENDENCY` when a remaining plugin depends on it, `REQUIRED_PLUGIN` for a required part. An id that is not installed is no change; the package name of an installed plugin is `UNKNOWN_PLUGIN` (`details.fix`: `npx dokebi remove <id>`) |
|
|
87
90
|
| `dokebi migrate [--dry-run]` | Move a 0.1.x project with JSON settings to YAML |
|
|
88
|
-
| `dokebi inspect [--verbose]` / `dokebi doctor [--verbose]` | Read-only diagnosis. doctor exits 1 on an error diagnostic. doctor also runs each installed plugin's own option rules once its section passes the schema (D-0162: the cross-field `checks` the schema cannot say, such as `dokebi-app.unknownDomain` must be `reject` or a key of `domains`), in the same permission-restricted child as install modules: a broken rule is `INVALID_PLUGIN_OPTIONS` with `details[{instancePath, keyword, message}]` (no values) and a `hint`, the error the app would stop with; `config set` refuses such a value the same way and writes nothing. All plugins are checked in one child; a plugin whose entry cannot run there is the warning `PLUGIN_OPTIONS_CHECK_FAILED` (`details.code`; start still checks its rules). The default human output is a summary (health, error and warning counts, one line per diagnostic and plugin); `--verbose` prints everything. `--json` is always complete, with `result.summary{healthy, errors, warnings, plugins, codes}` first. **File ownership (D-0065 §11)**: skeletons that plugins and the CLI create (the shared layout `src/shared/views/layouts/main.ejs`, entry files `src/infra/<name>/…`, `src/infra/worker/tasks.ts`, `src/infra/events/types.ts`, app and module files) are `owner: user`, so editing them is no diagnostic (upgrade keeps edited files); only editing a file a manifest declares `owner: "cli"` (a file that is generated again) is a `MODIFIED_TEMPLATE` warning (`hint`). **Locks**: inspect and doctor take no lock. While another command holds `.dokebi/lock` (a `--dry-run` too), `PROJECT_LOCKED` has `details{pid, running, since, transactions}` and a `hint`: with `running: true` that command is still working (its `.dokebi/transaction-*` is not reported as `RECOVERY_REQUIRED`), so run inspect again when it has ended; with `running: false` the change was interrupted (`RECOVERY_REQUIRED` too): ask the user, never delete the lock |
|
|
91
|
+
| `dokebi inspect [--verbose]` / `dokebi doctor [--production] [--verbose]` | `--production` (D-0184) adds the checks of a server, on the settings the app reads there (`DOKEBI_CONFIG` merged with `DOKEBI_SECRETS`; the plugins' option checks see the merged options too): `NODE_ENV_NOT_PRODUCTION` (error: run it as the service runs), `STATE_NOT_SHARED` (error: the per-process state D-0161 refuses in production — memory sessions, auth tokens and login counts, OAuth grants, local rate limits, a local WebSocket hub, seal nonces; a warning for local lock or cache backends), `TRANSPORT_NOT_FOR_PRODUCTION` (mail `log`/`capture`), `WEB_PORT_UNSET` (port 0), `TRUST_PROXY_EMPTY` and `WEB_LISTENS_EVERYWHERE` (warnings), `STORAGE_RELATIVE` (warning: a storage folder inside the release), `PLACEHOLDER_LEFT`, `CONFIG_SECRETS_MISSING`/`_UNSAFE`/`_CONFLICT` (as the start would fail), `SECRETS_MISSING` (a plugin's generated secret is not set), `SECRET_IN_CONFIG_FILE` (warning), and with @dokebi/migrate `dokebi-migrate status --json` with those settings: `MIGRATIONS_PENDING` (error: pre-deploy files), `MIGRATIONS_POST_DEPLOY_PENDING` and `MIGRATION_FILE_MISSING` (warnings), `MIGRATIONS_UNCHECKED` (error: status could not run). Read-only diagnosis. doctor exits 1 on an error diagnostic. doctor also runs each installed plugin's own option rules once its section passes the schema (D-0162: the cross-field `checks` the schema cannot say, such as `dokebi-app.unknownDomain` must be `reject` or a key of `domains`), in the same permission-restricted child as install modules: a broken rule is `INVALID_PLUGIN_OPTIONS` with `details[{instancePath, keyword, message}]` (no values) and a `hint`, the error the app would stop with; `config set` refuses such a value the same way and writes nothing. All plugins are checked in one child; a plugin whose entry cannot run there is the warning `PLUGIN_OPTIONS_CHECK_FAILED` (`details.code`; start still checks its rules). The default human output is a summary (health, error and warning counts, one line per diagnostic and plugin); `--verbose` prints everything. `--json` is always complete, with `result.summary{healthy, errors, warnings, plugins, codes}` first. **File ownership (D-0065 §11)**: skeletons that plugins and the CLI create (the shared layout `src/shared/views/layouts/main.ejs`, entry files `src/infra/<name>/…`, `src/infra/worker/tasks.ts`, `src/infra/events/types.ts`, app and module files) are `owner: user`, so editing them is no diagnostic (upgrade keeps edited files); only editing a file a manifest declares `owner: "cli"` (a file that is generated again) is a `MODIFIED_TEMPLATE` warning (`hint`). **Locks**: inspect and doctor take no lock. While another command holds `.dokebi/lock` (a `--dry-run` too), `PROJECT_LOCKED` has `details{pid, running, since, transactions}` and a `hint`: with `running: true` that command is still working (its `.dokebi/transaction-*` is not reported as `RECOVERY_REQUIRED`), so run inspect again when it has ended; with `running: false` the change was interrupted (`RECOVERY_REQUIRED` too): ask the user, never delete the lock |
|
|
89
92
|
| `dokebi app add <name> [--domain <host>]... [--template <name>] [--inertia [--ssr]] [--view <ejs\|none>] [--seal] [--answers-from <user\|defaults>] [--ai-chose view] [--ai-reason view=<why>]` | The app `src/apps/<name>` (D-0030). Its view is a product choice whose default is Inertia + Vue (`--inertia`); without `--inertia` it is EJS, except in a project whose recipe built no browser screens (the api recipe records `view = none`): there the app has no views, only `app.ts` and a `routes.ts` whose `/` answers JSON, no EJS pages and no shared layout, and nothing is recorded for it (tellUser already says the recipe's `view = none`). `--view ejs` gives EJS pages there, `--view none` an app without views anywhere (recorded like EJS: who chose it); `--view` with `--inertia`, or `--view none` with `--template`, is `USAGE`. The result's `appView{value, from}` says what the app got (`ejs`, `none`, `inertia-vue`, `inertia-vue-ssr`; `from: recipe` when the project's recipe chose it). D-0098: the view is recorded in `.dokebi/recipe.json` (`choices`) with who chose it, and verify's `tellUser` says it: `--answers-from user` (the user or the request chose it), `--ai-chose view` with `--ai-reason view=<why>` (the user left it to you; `--answers-from defaults` means the same). An EJS app without either is recorded as unstated, the result warns `VIEW_SOURCE_UNSTATED`, and tellUser says `not the default view (Inertia + Vue), and who chose it was not recorded`; an Inertia app without either records nothing (the default). `--ai-chose` names only `view` (`AI_CHOICE_UNKNOWN`); `AI_REASON_WITHOUT_CHOICE` and `INVALID_AI_REASON` as for recipes. The first app that `add @dokebi/app` creates (`main`, EJS) is recorded as unstated too; for a browser app with the default view use `add @dokebi/app --no-first-app` and then `app add <name> --inertia`. `--inertia` prepares the Vue skeleton, the Vite build and the Vue type check (`typecheck` script, `tsconfig.vue.json`, `typecheck-vue.mjs`, `src/infra/inertia/page.d.ts` for the shared page props), `--ssr` server rendering (D-0039), `--seal` writes `seal: true` on its domains (a domain with dots cannot be a `config set` path; without `@dokebi/seal` the warning `SEAL_PLUGIN_MISSING`; `--ssr` with `--seal` is `USAGE`, since a seal host refuses to start SSR: `SEAL_SSR_CONFLICT`). An `@dokebi/app` whose Inertia build template predates the Vue type check (0.4.2 and older) refuses `--inertia` before anything is written: `INVALID_MANIFEST` (phase `plan`, `details.missing`, `details.fix`: `npx dokebi upgrade dokebi-app`, then the same `app add`) |
|
|
90
93
|
| `dokebi app remove <name>` · `app link <app> <domain> [--template] [--seal]` · `app unlink <domain>` · `app list` | Manage apps and domains. `app remove` fails with `APP_IN_USE` while a domain still points to the app (unlink first); it removes the registration and `options.dokebi-app.apps.<name>` (D-0061 per-app settings) from dokebi.yaml and the sample (0.2.5; otherwise the next start fails with `UNKNOWN_APP`) and the app's recorded view from `.dokebi/recipe.json`. The folder stays (user code; the result lists it in `preserved` with the warning `APP_FOLDER_KEPT`) |
|
|
91
94
|
| `dokebi module add <name> [--no-views]` · `module remove` · `module list` | Feature modules `src/shared/modules/<name>` (D-0032). `--no-views`: the routes answer JSON |
|
|
@@ -276,7 +279,21 @@ Mail templates live in `src/shared/mail/`; install writes a user-owned welcome s
|
|
|
276
279
|
|
|
277
280
|
## Deployment skeleton
|
|
278
281
|
|
|
279
|
-
`dokebi init <directory> --deploy` also writes `Dockerfile`, `.dockerignore`, `compose.deploy.yaml` and `DEPLOY.md` inside the new project transaction. `--dry-run --json` reports `deployment.files` without writing or installing. It does not deploy or start services. Read `DEPLOY.md` before building: keep runtime secrets out of the image, mount the container's own `deploy/dokebi.yaml` read-only (never the project's `dokebi.yaml`, which `dokebi start` reads on the machine), configure the container web host/port and public domains, and prepare databases, queues and migrations separately. A core-only app exits when idle. Use a new folder; init never overlays an existing project.
|
|
282
|
+
`dokebi init <directory> --deploy` also writes `Dockerfile`, `.dockerignore`, `compose.deploy.yaml` and `DEPLOY.md` inside the new project transaction. `--dry-run --json` reports `deployment.files` without writing or installing. It does not deploy or start services. Read `DEPLOY.md` before building: keep runtime secrets out of the image, mount the container's own `deploy/dokebi.yaml` read-only (never the project's `dokebi.yaml`, which `dokebi start` reads on the machine), configure the container web host/port and public domains, and prepare databases, queues and migrations separately. A core-only app exits when idle. Use a new folder; init never overlays an existing project. For an existing project, and for a server with several instances (systemd or Compose, a secrets file, a proxy, backups), use `dokebi deploy init` (§11).
|
|
283
|
+
|
|
284
|
+
## 11. Production deployment (D-0184)
|
|
285
|
+
|
|
286
|
+
One server, several instances (a staging and a production one), installed from the repository's `deploy/` folder; the secret values never enter Git.
|
|
287
|
+
|
|
288
|
+
1. Sync the sample: `npx dokebi config diff --write --values` copies the development values (never the secrets) into `dokebi.example.yaml`.
|
|
289
|
+
2. `npx dokebi deploy init --instance staging --instance production --json` writes the files of the table above. Each instance has its own system user and unit `<app>-<instance>`, port on 127.0.0.1, `/etc/<app>/<instance>/dokebi.yaml` (the instance sample, filled in) and `/etc/<app>/<instance>/secrets.yaml` (0600), code under `/srv/<app>/<instance>/releases/<id>` with a `current` link, data under `/var/lib/<app>/<instance>`, file logs under `/var/log/<app>/<instance>`, backups under `/var/backups/<app>/<instance>` and the database `<app>_<instance>`.
|
|
290
|
+
3. **The secrets file.** The settings file names the secrets it needs in a top-level `secrets:` list (`dokebi-db.connections.main.options.url`, `dokebi-session.secrets`, …); the values sit in the file the variable `DOKEBI_SECRETS` names, of the same shape (`schemaVersion: 1`, `options`). `loadConfig` of @dokebi/core (0.4.3) merges it (the app, a cluster, `dokebi-migrate` and every other loader) and refuses before anything starts: `CONFIG_SECRETS_MISSING` (a listed path empty), `CONFIG_SECRETS_UNSAFE` (the file is readable or writable by group or others: 0600 or 0400 only), `CONFIG_SECRETS_CONFLICT` (both files set the same option). The errors name paths, never values. On a development machine the secrets stay in dokebi.yaml and `DOKEBI_SECRETS` is unset. `npx dokebi config secrets --json` lists the paths to put in the secret store.
|
|
291
|
+
4. Before a start, on the server: `NODE_ENV=production DOKEBI_CONFIG=… DOKEBI_SECRETS=… npx -y @dokebi/cli doctor --production --json`.
|
|
292
|
+
5. A release, in DEPLOY.md's order: a new release folder (`npm ci`, `npm run build --if-present`, `npm prune --omit=dev`) → `npx dokebi-migrate up --phase pre-deploy --json` (exit 0, else stop; `LOCK_TIMEOUT`: another run holds the lock) → switch `current` and restart → `npx -y @dokebi/cli verify --remote https://<domain> --json` → `npx dokebi-migrate up --json` (post-deploy). Rollback: `current` back to the previous release; the pre-deploy migrations only added, so the database stays.
|
|
293
|
+
6. `npx dokebi deploy proxy --caddy --domain <host> --instance <name>` (or `--nginx`) for the reverse proxy.
|
|
294
|
+
7. Backups (@dokebi/db): the timer runs `node deploy/backup.mjs` daily (`pg_dump -Fc` or `mongodump --archive --gzip` of every postgres or mongodb connection, mode 0600, the newest of 7 days, 4 weeks and 6 months kept, passwords only in the environment or a 0600 file, one JSON line); `node deploy/restore-drill.mjs` with `DOKEBI_RESTORE_URL` (a scratch database) proves the newest backup restores, monthly. Copy the backups off the server. MySQL and MariaDB: `mysqldump --single-transaction` from your scheduler (not scripted).
|
|
295
|
+
|
|
296
|
+
`examples/77-production-deploy` runs all of it on one machine (the app in production behind TLS, nginx and Caddy checking the generated files, a backup and a restore drill on PostgreSQL).
|
|
280
297
|
|
|
281
298
|
## Multi-app site recipe
|
|
282
299
|
|
package/catalog.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Generated by scripts/catalog/build.ts from package.json dokebiCatalog fields and packages/cli/catalog-src (D-0065). Do not edit; run node scripts/catalog/build.ts.",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"cli": "0.4.
|
|
4
|
+
"cli": "0.4.27",
|
|
5
5
|
"core": {
|
|
6
6
|
"package": "@dokebi/core",
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.3"
|
|
8
8
|
},
|
|
9
9
|
"capabilities": {
|
|
10
10
|
"admin.ui": {
|
|
@@ -713,7 +713,7 @@
|
|
|
713
713
|
{
|
|
714
714
|
"id": null,
|
|
715
715
|
"package": "@dokebi/cli",
|
|
716
|
-
"version": "0.4.
|
|
716
|
+
"version": "0.4.27",
|
|
717
717
|
"kind": "cli",
|
|
718
718
|
"summary": "Commands to create a project, browse the plugin catalog, run recipes, install plugins and verify (dokebi)",
|
|
719
719
|
"description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
|
|
@@ -785,7 +785,7 @@
|
|
|
785
785
|
{
|
|
786
786
|
"id": "dokebi-config",
|
|
787
787
|
"package": "@dokebi/config",
|
|
788
|
-
"version": "0.4.
|
|
788
|
+
"version": "0.4.3",
|
|
789
789
|
"kind": "required",
|
|
790
790
|
"summary": "Required part of the core that reads and validates dokebi.yaml into read-only settings per app (installed automatically)",
|
|
791
791
|
"description": "Validated immutable YAML configuration for Dokebi applications.",
|
|
@@ -860,7 +860,7 @@
|
|
|
860
860
|
{
|
|
861
861
|
"id": null,
|
|
862
862
|
"package": "@dokebi/core",
|
|
863
|
-
"version": "0.4.
|
|
863
|
+
"version": "0.4.3",
|
|
864
864
|
"kind": "core",
|
|
865
865
|
"summary": "Application core: settings loading, plugin registration, lifecycle, logger and shared libraries (required)",
|
|
866
866
|
"description": "Minimal composable Node.js application and plugin lifecycle framework.",
|
package/dist/bin.js
CHANGED
|
@@ -303,6 +303,28 @@ async function main() {
|
|
|
303
303
|
print('mail preview', preview, JSON.stringify(preview, null, 2));
|
|
304
304
|
return;
|
|
305
305
|
}
|
|
306
|
+
if (command === 'verify' && args.includes('--remote')) {
|
|
307
|
+
// D-0184: a deployed site only; nothing local runs.
|
|
308
|
+
const at = args.indexOf('--remote');
|
|
309
|
+
const url = args[at + 1];
|
|
310
|
+
if (url === undefined || url.startsWith('-'))
|
|
311
|
+
throw new CliError('USAGE', USAGE_LINES);
|
|
312
|
+
const options = serverFlags(args.filter((_, index) => index !== at && index !== at + 1), { '--timeout-ms': [100, 120_000] }, []);
|
|
313
|
+
const { remoteVerify, remoteVerifyText } = await import('./remote-verify.js');
|
|
314
|
+
const timeoutMs = options.numbers.get('--timeout-ms');
|
|
315
|
+
const result = await remoteVerify(url, {
|
|
316
|
+
root: process.cwd(),
|
|
317
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
318
|
+
});
|
|
319
|
+
print('verify', result, remoteVerifyText(result), result.ok);
|
|
320
|
+
if (!result.ok)
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (command === 'deploy') {
|
|
325
|
+
await deployCommand(args);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
306
328
|
if (command === 'verify') {
|
|
307
329
|
const options = serverFlags(args, { '--wait-ms': undefined, '--test-timeout-ms': [1000, 3_600_000] }, []);
|
|
308
330
|
const waitMs = options.numbers.get('--wait-ms');
|
|
@@ -464,6 +486,18 @@ async function main() {
|
|
|
464
486
|
print('config init', result);
|
|
465
487
|
return;
|
|
466
488
|
}
|
|
489
|
+
if (action === 'secrets') {
|
|
490
|
+
// D-0184: read only; names, never values.
|
|
491
|
+
if (args.length)
|
|
492
|
+
throw new CliError('USAGE', USAGE_LINES);
|
|
493
|
+
const { configSecrets, configSecretsText } = await import('./config-secrets.js');
|
|
494
|
+
const result = await configSecrets(process.cwd());
|
|
495
|
+
const ok = result.missing.length === 0;
|
|
496
|
+
print('config secrets', result, configSecretsText(result), ok);
|
|
497
|
+
if (!ok)
|
|
498
|
+
process.exitCode = 1;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
467
501
|
if (action === 'secret') {
|
|
468
502
|
const dryRun = args.includes('--dry-run');
|
|
469
503
|
const rotate = args.includes('--rotate');
|
|
@@ -520,7 +554,7 @@ async function main() {
|
|
|
520
554
|
}
|
|
521
555
|
if (action !== 'set') {
|
|
522
556
|
if (action && !action.startsWith('-'))
|
|
523
|
-
throw unknownCommand(action, ['
|
|
557
|
+
throw unknownCommand(action, ['diff', 'init', 'secret', 'secrets', 'set', 'show', 'unset'], 'config ');
|
|
524
558
|
throw new CliError('USAGE', USAGE_LINES);
|
|
525
559
|
}
|
|
526
560
|
let dryRun = false;
|
|
@@ -575,13 +609,31 @@ async function main() {
|
|
|
575
609
|
return;
|
|
576
610
|
}
|
|
577
611
|
if (command === 'inspect' || command === 'doctor') {
|
|
578
|
-
const verbose = args.
|
|
579
|
-
|
|
612
|
+
const verbose = args.includes('--verbose');
|
|
613
|
+
// D-0184: doctor --production adds the checks of a server.
|
|
614
|
+
const production = command === 'doctor' && args.includes('--production');
|
|
615
|
+
if (args.some((arg) => arg !== '--verbose' && !(arg === '--production' && production)) ||
|
|
616
|
+
new Set(args).size !== args.length)
|
|
580
617
|
throw new CliError('USAGE', USAGE_LINES);
|
|
581
618
|
const { ProjectInspector } = await import('./project-inspector.js');
|
|
582
619
|
const result = await new ProjectInspector().inspect(process.cwd(), {
|
|
583
620
|
runChecks: command === 'doctor',
|
|
584
621
|
});
|
|
622
|
+
if (production) {
|
|
623
|
+
const { productionDiagnostics } = await import('./production-doctor.js');
|
|
624
|
+
result.diagnostics.push(...(await productionDiagnostics(process.cwd())));
|
|
625
|
+
result.healthy = !result.diagnostics.some((item) => item.severity === 'error');
|
|
626
|
+
const codes = {};
|
|
627
|
+
for (const item of result.diagnostics)
|
|
628
|
+
codes[item.code] = (codes[item.code] ?? 0) + 1;
|
|
629
|
+
result.summary = {
|
|
630
|
+
...result.summary,
|
|
631
|
+
healthy: result.healthy,
|
|
632
|
+
errors: result.diagnostics.filter((item) => item.severity === 'error').length,
|
|
633
|
+
warnings: result.diagnostics.filter((item) => item.severity === 'warning').length,
|
|
634
|
+
codes,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
585
637
|
const ok = command === 'inspect' || result.healthy;
|
|
586
638
|
// --json is always complete (summary first); people get the short form unless --verbose.
|
|
587
639
|
process.stdout.write(machine
|
|
@@ -881,6 +933,72 @@ async function recipeCommand(input) {
|
|
|
881
933
|
* Reads `--name <number>` style flags of the server commands; anything else is a usage error.
|
|
882
934
|
* `numbers` maps each flag to its range (default 1 to 3600000).
|
|
883
935
|
*/
|
|
936
|
+
/** `dokebi deploy init|proxy` (D-0184). */
|
|
937
|
+
async function deployCommand(input) {
|
|
938
|
+
const action = input.shift();
|
|
939
|
+
if (action !== 'init' && action !== 'proxy')
|
|
940
|
+
throw action && !action.startsWith('-')
|
|
941
|
+
? unknownCommand(action, ['init', 'proxy'], 'deploy ')
|
|
942
|
+
: new CliError('USAGE', USAGE_LINES);
|
|
943
|
+
const instances = [];
|
|
944
|
+
const domains = [];
|
|
945
|
+
let target;
|
|
946
|
+
let port;
|
|
947
|
+
let proxy;
|
|
948
|
+
let dryRun = false;
|
|
949
|
+
const value = (index) => {
|
|
950
|
+
const text = input[index];
|
|
951
|
+
if (text === undefined || text.startsWith('-'))
|
|
952
|
+
throw new CliError('USAGE', USAGE_LINES);
|
|
953
|
+
return text;
|
|
954
|
+
};
|
|
955
|
+
for (let index = 0; index < input.length; index++) {
|
|
956
|
+
const arg = input[index];
|
|
957
|
+
if (arg === '--dry-run' && !dryRun)
|
|
958
|
+
dryRun = true;
|
|
959
|
+
else if (arg === '--instance')
|
|
960
|
+
instances.push(value(++index));
|
|
961
|
+
else if (action === 'init' && arg === '--target' && target === undefined)
|
|
962
|
+
target = value(++index);
|
|
963
|
+
else if (action === 'init' && arg === '--port' && port === undefined) {
|
|
964
|
+
const number = Number(value(++index));
|
|
965
|
+
if (!Number.isSafeInteger(number) || number < 1024 || number > 65535)
|
|
966
|
+
throw new CliError('USAGE', `--port must be an integer from 1024 to 65535\n${USAGE_LINES}`);
|
|
967
|
+
port = number;
|
|
968
|
+
}
|
|
969
|
+
else if (action === 'proxy' && (arg === '--caddy' || arg === '--nginx') && !proxy)
|
|
970
|
+
proxy = arg === '--caddy' ? 'caddy' : 'nginx';
|
|
971
|
+
else if (action === 'proxy' && arg === '--domain')
|
|
972
|
+
domains.push(value(++index));
|
|
973
|
+
else
|
|
974
|
+
throw new CliError('USAGE', USAGE_LINES);
|
|
975
|
+
}
|
|
976
|
+
if (target !== undefined && target !== 'systemd' && target !== 'compose')
|
|
977
|
+
throw new CliError('USAGE', `--target is systemd or compose\n${USAGE_LINES}`);
|
|
978
|
+
const deploy = await import('./deploy.js');
|
|
979
|
+
mutationStarted = !dryRun;
|
|
980
|
+
if (action === 'init') {
|
|
981
|
+
const result = await deploy.deployInit(process.cwd(), {
|
|
982
|
+
target: (target ?? 'systemd'),
|
|
983
|
+
instances: instances.length ? instances : ['production'],
|
|
984
|
+
...(port === undefined ? {} : { port }),
|
|
985
|
+
dryRun,
|
|
986
|
+
});
|
|
987
|
+
printWarnings(result.warnings);
|
|
988
|
+
print('deploy init', result);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (!proxy || !domains.length || instances.length > 1)
|
|
992
|
+
throw new CliError('USAGE', USAGE_LINES);
|
|
993
|
+
const result = await deploy.deployProxy(process.cwd(), {
|
|
994
|
+
proxy,
|
|
995
|
+
domains,
|
|
996
|
+
instance: instances[0] ?? 'production',
|
|
997
|
+
dryRun,
|
|
998
|
+
});
|
|
999
|
+
printWarnings(result.warnings);
|
|
1000
|
+
print('deploy proxy', result);
|
|
1001
|
+
}
|
|
884
1002
|
function serverFlags(input, numbers, switches) {
|
|
885
1003
|
const result = { numbers: new Map(), switches: new Set() };
|
|
886
1004
|
for (let index = 0; index < input.length; index++) {
|
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
import type { Problem } from './problems.js';
|
|
2
|
+
type Options = Record<string, unknown>;
|
|
3
|
+
/** One piece of state kept in process memory: its option path and what it would lose. */
|
|
4
|
+
export interface Kept {
|
|
5
|
+
readonly path: readonly string[];
|
|
6
|
+
readonly what: string;
|
|
7
|
+
readonly fix: (redis: string | undefined) => string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The state of each installed plugin that stays in one process with these options (also
|
|
11
|
+
* `doctor --production`, D-0184: the plugins refuse it in production too).
|
|
12
|
+
*/
|
|
13
|
+
export declare function kept(installed: ReadonlySet<string>, options: Options): Kept[];
|
|
2
14
|
export declare function sharedStateProblems(root: string, installed: ReadonlySet<string>): Promise<Problem[]>;
|
|
15
|
+
export {};
|
|
@@ -16,8 +16,11 @@ const RULE = 'shared-state-memory';
|
|
|
16
16
|
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
17
17
|
const redisValue = (redis) => `driver: redis, connection: ${redis ?? '<a dokebi-redis connection>'}`;
|
|
18
18
|
const addRedis = (redis) => redis === undefined ? ' (npx dokebi add @dokebi/redis first)' : '';
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The state of each installed plugin that stays in one process with these options (also
|
|
21
|
+
* `doctor --production`, D-0184: the plugins refuse it in production too).
|
|
22
|
+
*/
|
|
23
|
+
export function kept(installed, options) {
|
|
21
24
|
const found = [];
|
|
22
25
|
const plugin = (id) => record(options[id]);
|
|
23
26
|
if (installed.has('dokebi-session') &&
|
package/dist/commands.js
CHANGED
|
@@ -88,6 +88,21 @@ export const COMMANDS = [
|
|
|
88
88
|
usage: 'dokebi config secret <plugin-id>.<path> [--stdin] [--rotate] [--dry-run] [--json]',
|
|
89
89
|
summary: 'Write a generated secret (never shown) into dokebi.yaml, e.g. dokebi-session.secrets, or with --stdin one you were given, e.g. logging.targets.studio.token; --rotate replaces one',
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'config secrets',
|
|
93
|
+
usage: 'dokebi config secrets [--json]',
|
|
94
|
+
summary: 'The secret option paths the app needs, by name (never values): set in dokebi.yaml or in the DOKEBI_SECRETS file, or missing; exit 1 when one is missing',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'deploy init',
|
|
98
|
+
usage: 'dokebi deploy init [--target <systemd|compose>] [--instance <name>]... [--port <n>] [--dry-run] [--json]',
|
|
99
|
+
summary: 'Write deploy/ for an existing project: a systemd unit or a compose file per instance (staging and production on one server never share a port, unit, settings, data, log or database name), a settings sample without secrets, a secrets sample, backup scripts and DEPLOY.md',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'deploy proxy',
|
|
103
|
+
usage: 'dokebi deploy proxy --caddy|--nginx --domain <host>... [--instance <name>] [--dry-run] [--json]',
|
|
104
|
+
summary: 'Write the reverse proxy configuration of an instance (TLS, forwarded headers, WebSocket upgrade, unbuffered SSE, /health/*, static caching) from its settings sample',
|
|
105
|
+
},
|
|
91
106
|
{
|
|
92
107
|
name: 'dev-infra',
|
|
93
108
|
usage: 'dokebi dev-infra [up|start|stop|down|status] [--file <path>] [--db-port <n>] [--dry-run] [--json]',
|
|
@@ -105,8 +120,8 @@ export const COMMANDS = [
|
|
|
105
120
|
},
|
|
106
121
|
{
|
|
107
122
|
name: 'doctor',
|
|
108
|
-
usage: 'dokebi doctor [--verbose] [--json]',
|
|
109
|
-
summary: 'Run all checks; exit 1 on an error diagnostic (repairs nothing)',
|
|
123
|
+
usage: 'dokebi doctor [--production] [--verbose] [--json]',
|
|
124
|
+
summary: 'Run all checks; exit 1 on an error diagnostic (repairs nothing); --production adds the checks of a server: NODE_ENV, state every process shares, trustProxy, secrets, migrations',
|
|
110
125
|
},
|
|
111
126
|
{
|
|
112
127
|
name: 'check',
|
|
@@ -130,8 +145,8 @@ export const COMMANDS = [
|
|
|
130
145
|
},
|
|
131
146
|
{
|
|
132
147
|
name: 'verify',
|
|
133
|
-
usage: 'dokebi verify [--wait-ms <ms>] [--test-timeout-ms <ms>] [--json]',
|
|
134
|
-
summary: 'doctor, check, typecheck, build, test, start, HTTP checks and stop; ok only when all pass (say done only then)',
|
|
148
|
+
usage: 'dokebi verify [--wait-ms <ms>] [--test-timeout-ms <ms>] [--remote <https-url> [--timeout-ms <ms>]] [--json]',
|
|
149
|
+
summary: 'doctor, check, typecheck, build, test, start, HTTP checks and stop; ok only when all pass (say done only then); --remote checks a deployed site instead: TLS, redirect, health, security headers',
|
|
135
150
|
},
|
|
136
151
|
{
|
|
137
152
|
name: 'app add',
|
|
@@ -466,6 +481,49 @@ export const COMMAND_DETAILS = {
|
|
|
466
481
|
],
|
|
467
482
|
example: 'npx dokebi config secret dokebi-session.secrets --json\nnpx dokebi config secret dokebi-csrf.secrets --rotate --json\nnpx dokebi config secret logging.targets.studio.token --stdin --json < studio-token.txt',
|
|
468
483
|
},
|
|
484
|
+
'config secrets': {
|
|
485
|
+
flags: [
|
|
486
|
+
[
|
|
487
|
+
'--json',
|
|
488
|
+
'{ file, secretsFile, listed, secrets[{path, source, listed, required}], missing }; source is config, secrets or null; exit 1 when missing is not empty',
|
|
489
|
+
],
|
|
490
|
+
],
|
|
491
|
+
example: 'npx dokebi config secrets --json\nDOKEBI_CONFIG=/etc/shop/production/dokebi.yaml DOKEBI_SECRETS=/etc/shop/production/secrets.yaml npx dokebi config secrets --json',
|
|
492
|
+
},
|
|
493
|
+
'deploy init': {
|
|
494
|
+
flags: [
|
|
495
|
+
[
|
|
496
|
+
'--target <systemd|compose>',
|
|
497
|
+
'systemd (default): a unit per instance running node src/main.ts; compose: a compose file per instance (and Dockerfile, .dockerignore when missing)',
|
|
498
|
+
],
|
|
499
|
+
[
|
|
500
|
+
'--instance <name>',
|
|
501
|
+
'repeatable; default production. production uses port 3000, staging 3001, any other name needs --port',
|
|
502
|
+
],
|
|
503
|
+
['--port <n>', 'the local port of the one instance named (1024-65535)'],
|
|
504
|
+
[
|
|
505
|
+
'--dry-run',
|
|
506
|
+
'plan only: the files it would write or keep (a file you edited is kept: DEPLOY_FILE_KEPT)',
|
|
507
|
+
],
|
|
508
|
+
],
|
|
509
|
+
example: 'npx dokebi deploy init --instance staging --instance production --json\nnpx dokebi deploy init --target compose --instance production --json',
|
|
510
|
+
},
|
|
511
|
+
'deploy proxy': {
|
|
512
|
+
flags: [
|
|
513
|
+
['--caddy', 'write deploy/proxy/<app>-<instance>.Caddyfile (automatic HTTPS)'],
|
|
514
|
+
[
|
|
515
|
+
'--nginx',
|
|
516
|
+
'write deploy/proxy/<app>-<instance>.nginx.conf (certificate paths of certbot; HTTP redirected to HTTPS)',
|
|
517
|
+
],
|
|
518
|
+
['--domain <host>', 'repeatable; the first is the main name'],
|
|
519
|
+
[
|
|
520
|
+
'--instance <name>',
|
|
521
|
+
'the instance whose deploy/<instance>/dokebi.example.yaml it reads (default production)',
|
|
522
|
+
],
|
|
523
|
+
['--dry-run', 'plan only'],
|
|
524
|
+
],
|
|
525
|
+
example: 'npx dokebi deploy proxy --caddy --domain shop.example.com --json\nnpx dokebi deploy proxy --nginx --domain staging.shop.example.com --instance staging --json',
|
|
526
|
+
},
|
|
469
527
|
'dev-infra': {
|
|
470
528
|
flags: [
|
|
471
529
|
[
|
|
@@ -483,8 +541,14 @@ export const COMMAND_DETAILS = {
|
|
|
483
541
|
example: 'npx dokebi inspect --json',
|
|
484
542
|
},
|
|
485
543
|
doctor: {
|
|
486
|
-
flags: [
|
|
487
|
-
|
|
544
|
+
flags: [
|
|
545
|
+
['--verbose', 'the whole inspection (default: a summary; --json is always complete)'],
|
|
546
|
+
[
|
|
547
|
+
'--production',
|
|
548
|
+
'also the server checks, with the settings the app reads (DOKEBI_CONFIG, DOKEBI_SECRETS): NODE_ENV=production, no per-process state (memory session store, local rate limits, …), trustProxy, a fixed port, absolute storage folder, placeholders, listed secrets, pending migrations',
|
|
549
|
+
],
|
|
550
|
+
],
|
|
551
|
+
example: 'npx dokebi doctor\nNODE_ENV=production DOKEBI_CONFIG=/etc/shop/production/dokebi.yaml DOKEBI_SECRETS=/etc/shop/production/secrets.yaml npx dokebi doctor --production --json',
|
|
488
552
|
},
|
|
489
553
|
check: {
|
|
490
554
|
flags: [
|
|
@@ -548,8 +612,13 @@ export const COMMAND_DETAILS = {
|
|
|
548
612
|
'--test-timeout-ms <ms>',
|
|
549
613
|
'time limit of the test step, the package.json test script (default 300000)',
|
|
550
614
|
],
|
|
615
|
+
[
|
|
616
|
+
'--remote <https-url>',
|
|
617
|
+
'check a deployed site only (nothing local runs): TLS certificate and days left, http to https redirect, live and ready health routes, security headers; exit 1 on a failed check',
|
|
618
|
+
],
|
|
619
|
+
['--timeout-ms <ms>', 'with --remote: the limit of each request (default 10000)'],
|
|
551
620
|
],
|
|
552
|
-
example: 'npx dokebi verify --json',
|
|
621
|
+
example: 'npx dokebi verify --json\nnpx dokebi verify --remote https://shop.example.com --json',
|
|
553
622
|
},
|
|
554
623
|
'app add': {
|
|
555
624
|
flags: [
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ConfigObject } from '@dokebi/config';
|
|
2
|
+
export interface SecretEntry {
|
|
3
|
+
/** The option path (`dokebi-db.connections.main.url`). */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
/** Where it is set: dokebi.yaml (or the file DOKEBI_CONFIG names), the secrets file, or nowhere. */
|
|
6
|
+
readonly source: 'config' | 'secrets' | null;
|
|
7
|
+
/** In the document's `secrets` list: the app refuses to start without it. */
|
|
8
|
+
readonly listed: boolean;
|
|
9
|
+
/** A secret a plugin's install plan generates (dokebi-session.secrets): it needs one. */
|
|
10
|
+
readonly required: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ConfigSecretsWarning {
|
|
13
|
+
readonly code: 'SECRET_IN_CONFIG_FILE' | 'SECRETS_PLAN_UNREADABLE';
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly hint: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ConfigSecretsResult {
|
|
18
|
+
/** The settings file read: dokebi.yaml, or the file DOKEBI_CONFIG names. */
|
|
19
|
+
readonly file: string;
|
|
20
|
+
/** The file DOKEBI_SECRETS names, or null. */
|
|
21
|
+
readonly secretsFile: string | null;
|
|
22
|
+
/** The document's `secrets` list. */
|
|
23
|
+
readonly listed: readonly string[];
|
|
24
|
+
readonly secrets: readonly SecretEntry[];
|
|
25
|
+
/** Listed or required paths without a value (a placeholder counts as none). */
|
|
26
|
+
readonly missing: readonly string[];
|
|
27
|
+
readonly warnings?: readonly ConfigSecretsWarning[];
|
|
28
|
+
readonly nextSteps?: ReadonlyArray<{
|
|
29
|
+
command: string;
|
|
30
|
+
why: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The secrets the installed plugins generate at install (their plans' `configSecrets`, such as
|
|
35
|
+
* `dokebi-session.secrets`): each plugin needs one to start.
|
|
36
|
+
*/
|
|
37
|
+
export declare function requiredSecretPaths(root: string, options: ConfigObject): Promise<{
|
|
38
|
+
paths: string[];
|
|
39
|
+
unreadable: string[];
|
|
40
|
+
}>;
|
|
41
|
+
/** The warning for plugins whose generated secrets could not be read (their install modules). */
|
|
42
|
+
export declare function unreadableWarning(plugins: readonly string[]): ConfigSecretsWarning[];
|
|
43
|
+
/** `dokebi config secrets`: secret paths by name, where each is set, and the missing ones. */
|
|
44
|
+
export declare function configSecrets(root: string, env?: NodeJS.ProcessEnv): Promise<ConfigSecretsResult>;
|
|
45
|
+
/** The result for people: one line per path. */
|
|
46
|
+
export declare function configSecretsText(result: ConfigSecretsResult): string;
|