@dokebi/cli 0.4.28 → 0.4.29
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 +4 -4
- package/catalog.json +8 -8
- package/dist/config-edit.js +9 -2
- package/dist/config-secrets.d.ts +11 -0
- package/dist/config-secrets.js +83 -13
- package/dist/deploy-templates.d.ts +1 -1
- package/dist/deploy-templates.js +9 -5
- package/dist/deploy.d.ts +7 -0
- package/dist/deploy.js +60 -49
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/package.json +2 -2
package/AI.md
CHANGED
|
@@ -75,8 +75,8 @@ 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, type}], 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`). `type` is the option's JSON Schema type (`string`; `array` for a list such as `dokebi-session.secrets`, `dokebi-auth.secrets`, `dokebi-csrf.secrets`, `dokebi-mail.secrets`; several as a list; `null` without a schema): a tool that writes the secrets file writes an `array` as a YAML list, even of one value (a text there is `INVALID_PLUGIN_OPTIONS`). 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,
|
|
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
|
|
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, type}], 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`). A secret inside a list item is named by the item where the settings name it (`logging.targets.studio.token` for `logging.targets.1.token`: its `name`, else the `type` of an item without a name, when no other item shares it): the key a secrets file fills that field with, the list staying in the settings (@dokebi/config 0.4.4). `type` is the option's JSON Schema type (`string`; `array` for a list such as `dokebi-session.secrets`, `dokebi-auth.secrets`, `dokebi-csrf.secrets`, `dokebi-mail.secrets`; several as a list; `null` without a schema): a tool that writes the secrets file writes an `array` as a YAML list, even of one value (a text there is `INVALID_PLUGIN_OPTIONS`). 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, the same option in both files, or a list item the settings do not have, fails as the app would (`CONFIG_SECRETS_UNSAFE`, `CONFIG_SECRETS_CONFLICT`, `CONFIG_SECRETS_UNMATCHED`) |
|
|
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 (with @dokebi/web: `production` port 3000, `staging` 3001, another name needs `--port`, two instances never share a port: `PORT_IN_USE`; without it nothing listens: no port (0, `— (no web server)` in DEPLOY.md) and `--port` is `USAGE`) `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; a secret inside a list item is that field only, named by the item: `logging.targets.studio.token`, the list staying in the settings sample), `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
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) |
|
|
81
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. |
|
|
82
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 |
|
|
@@ -88,7 +88,7 @@ The `blog` plan, depending on the answers: `init` (when the folder is not a proj
|
|
|
88
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) |
|
|
89
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>`) |
|
|
90
90
|
| `dokebi migrate [--dry-run]` | Move a 0.1.x project with JSON settings to YAML |
|
|
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 |
|
|
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`/`_UNMATCHED` (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 |
|
|
92
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`) |
|
|
93
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`) |
|
|
94
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 |
|
|
@@ -287,7 +287,7 @@ One server, several instances (a staging and a production one), installed from t
|
|
|
287
287
|
|
|
288
288
|
1. Sync the sample: `npx dokebi config diff --write --values` copies the development values (never the secrets) into `dokebi.example.yaml`.
|
|
289
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.
|
|
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), `CONFIG_SECRETS_UNMATCHED` (the secrets file names a list item the settings do not have). A field of a list item comes from the secrets file by the item's name, the list staying in the settings: `logging: { targets: { studio: { token: "…" } } }` fills the token of the target named `studio` (@dokebi/config 0.4.4). 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
291
|
4. Before a start, on the server: `NODE_ENV=production DOKEBI_CONFIG=… DOKEBI_SECRETS=… npx -y @dokebi/cli doctor --production --json`.
|
|
292
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
293
|
6. `npx dokebi deploy proxy --caddy --domain <host> --instance <name>` (or `--nginx`) for the reverse proxy.
|
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.29",
|
|
5
5
|
"core": {
|
|
6
6
|
"package": "@dokebi/core",
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.5"
|
|
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.29",
|
|
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.4",
|
|
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.5",
|
|
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.",
|
|
@@ -1974,7 +1974,7 @@
|
|
|
1974
1974
|
{
|
|
1975
1975
|
"id": "dokebi-migrate",
|
|
1976
1976
|
"package": "@dokebi/migrate",
|
|
1977
|
-
"version": "0.4.
|
|
1977
|
+
"version": "0.4.3",
|
|
1978
1978
|
"kind": "plugin",
|
|
1979
1979
|
"summary": "Migration and seed runner for dokebi-db connections (dokebi-migrate up in the deploy step)",
|
|
1980
1980
|
"description": "Migrations and seeds for dokebi-db connections: history and lock for PostgreSQL, MySQL, MariaDB and MongoDB, run from the deployment.",
|
|
@@ -2888,7 +2888,7 @@
|
|
|
2888
2888
|
{
|
|
2889
2889
|
"id": "dokebi-session",
|
|
2890
2890
|
"package": "@dokebi/session",
|
|
2891
|
-
"version": "0.4.
|
|
2891
|
+
"version": "0.4.5",
|
|
2892
2892
|
"kind": "plugin",
|
|
2893
2893
|
"summary": "Server-side sessions for @dokebi/web (signed cookie, memory or redis store, flash)",
|
|
2894
2894
|
"description": "Server-side sessions for @dokebi/web: signed cookie, memory or Redis store, flash.",
|
|
@@ -2960,7 +2960,7 @@
|
|
|
2960
2960
|
{
|
|
2961
2961
|
"id": "dokebi-sse",
|
|
2962
2962
|
"package": "@dokebi/sse",
|
|
2963
|
-
"version": "0.4.
|
|
2963
|
+
"version": "0.4.3",
|
|
2964
2964
|
"kind": "plugin",
|
|
2965
2965
|
"summary": "Server-sent events (SSE) streams and rooms, delivery across workers, and a browser client",
|
|
2966
2966
|
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
package/dist/config-edit.js
CHANGED
|
@@ -108,8 +108,15 @@ function ensureMap(document, path, code) {
|
|
|
108
108
|
continue;
|
|
109
109
|
if (!document.hasIn(current) || (isScalar(node) && node.value === null))
|
|
110
110
|
document.setIn(current, document.createNode({}));
|
|
111
|
-
else if (!isMap(node))
|
|
112
|
-
|
|
111
|
+
else if (!isMap(node)) {
|
|
112
|
+
const at = current.slice(1).join('.');
|
|
113
|
+
throw new CliError(code, `Configuration path crosses a non-mapping value at ${at}`, {
|
|
114
|
+
details: {
|
|
115
|
+
path: at,
|
|
116
|
+
fix: `${at} holds a value that is not a mapping (a text, number or list): set the whole value instead (a JSON value, such as a list of objects), or unset it first (npx dokebi config unset ${at})`,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
/** Applies an edit inside options.<id> and proves no other plugin section changed. */
|
package/dist/config-secrets.d.ts
CHANGED
|
@@ -36,6 +36,17 @@ export interface ConfigSecretsResult {
|
|
|
36
36
|
why: string;
|
|
37
37
|
}>;
|
|
38
38
|
}
|
|
39
|
+
/** The option at `path`, list items found by index or by name (`itemIndex`). */
|
|
40
|
+
export declare function valueAt(options: unknown, path: readonly string[]): unknown;
|
|
41
|
+
/** `path` with each list item it names (`itemIndex`) as its index; the keys after a miss as given. */
|
|
42
|
+
export declare function indexedPath(options: unknown, path: readonly string[]): string[];
|
|
43
|
+
/**
|
|
44
|
+
* `path` with each list index named by its item (its `name`, else the `type` of an item without
|
|
45
|
+
* a name) when that names exactly this item: the key a secrets file fills the item with, whatever
|
|
46
|
+
* the order of the list (`logging.targets.2.token` → `logging.targets.studio.token`). An item
|
|
47
|
+
* that cannot be named so keeps its index.
|
|
48
|
+
*/
|
|
49
|
+
export declare function namedPath(options: unknown, path: readonly string[]): string[];
|
|
39
50
|
/** The JSON Schema types the option at `keys` may have (properties, records, anyOf/oneOf). */
|
|
40
51
|
export declare function schemaTypes(schema: unknown, keys: readonly string[]): Set<string>;
|
|
41
52
|
/**
|
package/dist/config-secrets.js
CHANGED
|
@@ -14,18 +14,81 @@ import { dependencyMap } from './npm-client.js';
|
|
|
14
14
|
import { planLifecycle } from './plugin-lifecycle.js';
|
|
15
15
|
import { readState } from './project-state.js';
|
|
16
16
|
import { reloadSchemas } from './reloadable.js';
|
|
17
|
-
|
|
17
|
+
const ITEM_INDEX = /^(?:0|[1-9][0-9]{0,5})$/;
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The item of `list` that `key` names, the way @dokebi/config merges a secrets file into a list:
|
|
23
|
+
* an index, else the one item whose `name` is `key`, else the one item without a name whose
|
|
24
|
+
* `type` is. `undefined` when no item, or more than one, matches.
|
|
25
|
+
*/
|
|
26
|
+
function itemIndex(list, key) {
|
|
27
|
+
if (ITEM_INDEX.test(key))
|
|
28
|
+
return Number(key) < list.length ? Number(key) : undefined;
|
|
29
|
+
const only = (match) => {
|
|
30
|
+
const found = list.flatMap((item, index) => (isRecord(item) && match(item) ? [index] : []));
|
|
31
|
+
return found.length === 1 ? found[0] : found.length ? -1 : undefined;
|
|
32
|
+
};
|
|
33
|
+
const named = only((item) => item.name === key);
|
|
34
|
+
if (named !== undefined)
|
|
35
|
+
return named < 0 ? undefined : named;
|
|
36
|
+
const typed = only((item) => !Object.hasOwn(item, 'name') && item.type === key);
|
|
37
|
+
return typed === undefined || typed < 0 ? undefined : typed;
|
|
38
|
+
}
|
|
39
|
+
/** The option at `path`, list items found by index or by name (`itemIndex`). */
|
|
40
|
+
export function valueAt(options, path) {
|
|
18
41
|
let value = options;
|
|
19
42
|
for (const key of path) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
const index = Array.isArray(value) ? itemIndex(value, key) : undefined;
|
|
44
|
+
if (index !== undefined)
|
|
45
|
+
value = value[index];
|
|
46
|
+
else if (isRecord(value) && Object.hasOwn(value, key))
|
|
23
47
|
value = value[key];
|
|
24
48
|
else
|
|
25
49
|
return undefined;
|
|
26
50
|
}
|
|
27
51
|
return value;
|
|
28
52
|
}
|
|
53
|
+
/** `path` with each list item it names (`itemIndex`) as its index; the keys after a miss as given. */
|
|
54
|
+
export function indexedPath(options, path) {
|
|
55
|
+
const indexed = [];
|
|
56
|
+
let value = options;
|
|
57
|
+
for (const key of path) {
|
|
58
|
+
const index = Array.isArray(value) ? itemIndex(value, key) : undefined;
|
|
59
|
+
indexed.push(index === undefined ? key : String(index));
|
|
60
|
+
value = valueAt(value, [key]);
|
|
61
|
+
}
|
|
62
|
+
return indexed;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `path` with each list index named by its item (its `name`, else the `type` of an item without
|
|
66
|
+
* a name) when that names exactly this item: the key a secrets file fills the item with, whatever
|
|
67
|
+
* the order of the list (`logging.targets.2.token` → `logging.targets.studio.token`). An item
|
|
68
|
+
* that cannot be named so keeps its index.
|
|
69
|
+
*/
|
|
70
|
+
export function namedPath(options, path) {
|
|
71
|
+
const named = [];
|
|
72
|
+
let value = options;
|
|
73
|
+
for (const key of path) {
|
|
74
|
+
if (Array.isArray(value) && ITEM_INDEX.test(key)) {
|
|
75
|
+
const item = value[Number(key)];
|
|
76
|
+
const name = !isRecord(item)
|
|
77
|
+
? undefined
|
|
78
|
+
: Object.hasOwn(item, 'name')
|
|
79
|
+
? item.name
|
|
80
|
+
: item.type;
|
|
81
|
+
named.push(typeof name === 'string' && !ITEM_INDEX.test(name) && itemIndex(value, name) === Number(key)
|
|
82
|
+
? name
|
|
83
|
+
: key);
|
|
84
|
+
value = item;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
named.push(key);
|
|
88
|
+
value = isRecord(value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
89
|
+
}
|
|
90
|
+
return named;
|
|
91
|
+
}
|
|
29
92
|
function empty(value) {
|
|
30
93
|
return (value === undefined ||
|
|
31
94
|
value === null ||
|
|
@@ -56,7 +119,8 @@ export function schemaTypes(schema, keys) {
|
|
|
56
119
|
walk(properties[key], tail, depth + 1);
|
|
57
120
|
else if (value.additionalProperties && typeof value.additionalProperties === 'object')
|
|
58
121
|
walk(value.additionalProperties, tail, depth + 1);
|
|
59
|
-
|
|
122
|
+
// A list item, by index or by name (`logging.targets.studio.token`).
|
|
123
|
+
else if (value.items)
|
|
60
124
|
walk(value.items, tail, depth + 1);
|
|
61
125
|
};
|
|
62
126
|
walk(schema, keys, 0);
|
|
@@ -141,13 +205,11 @@ export async function configSecrets(root, env = process.env) {
|
|
|
141
205
|
// below, so a missing listed secret is reported instead of thrown.
|
|
142
206
|
let merged = document.options;
|
|
143
207
|
let secretsText;
|
|
144
|
-
let secretOptions;
|
|
145
208
|
if (secretsFile !== undefined) {
|
|
146
209
|
try {
|
|
147
210
|
merged = (await applySecrets({ schemaVersion: 1, options: document.options }, secretsFile))
|
|
148
211
|
.options;
|
|
149
212
|
secretsText = await readConfigFile(secretsFile);
|
|
150
|
-
secretOptions = parseConfig(secretsText).options;
|
|
151
213
|
}
|
|
152
214
|
catch (error) {
|
|
153
215
|
if (error instanceof ConfigError)
|
|
@@ -155,23 +217,31 @@ export async function configSecrets(root, env = process.env) {
|
|
|
155
217
|
throw error;
|
|
156
218
|
}
|
|
157
219
|
}
|
|
158
|
-
|
|
220
|
+
// One path per secret: a list item by its name where the settings name it, the key a secrets
|
|
221
|
+
// file fills that item with (Studio v2, 11-02).
|
|
222
|
+
const canonical = (path) => namedPath(document.options, path.split('.')).join('.');
|
|
223
|
+
const fromConfig = new Set((await configShow(root, undefined, { source, file: target.file })).secrets.map(canonical));
|
|
159
224
|
const fromSecrets = new Set(secretsText === undefined
|
|
160
225
|
? []
|
|
161
|
-
: (await configShow(root, undefined, { source: secretsText, file: secretsFile }))
|
|
162
|
-
.secrets);
|
|
226
|
+
: (await configShow(root, undefined, { source: secretsText, file: secretsFile })).secrets.map(canonical));
|
|
163
227
|
const planned = await requiredSecretPaths(root, document.options);
|
|
164
228
|
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
165
229
|
// The types tell a tool that writes the secrets file a list from a text (Studio v2, 11-01).
|
|
166
230
|
const schemas = await reloadSchemas(root, state.plugins, state.settings ?? []);
|
|
167
|
-
const required = new Set(planned.paths);
|
|
168
|
-
const listed = document.secrets ?? [];
|
|
231
|
+
const required = new Set(planned.paths.map(canonical));
|
|
232
|
+
const listed = (document.secrets ?? []).map(canonical);
|
|
169
233
|
const paths = [...new Set([...fromConfig, ...fromSecrets, ...listed, ...required])].sort();
|
|
170
234
|
const secrets = paths.map((path) => {
|
|
171
235
|
const set = !empty(valueAt(merged, path.split('.')));
|
|
172
236
|
return {
|
|
173
237
|
path,
|
|
174
|
-
|
|
238
|
+
// Both files never hold one option (CONFIG_SECRETS_CONFLICT): what the settings lack came
|
|
239
|
+
// from the secrets file.
|
|
240
|
+
source: !set
|
|
241
|
+
? null
|
|
242
|
+
: empty(valueAt(document.options, path.split('.')))
|
|
243
|
+
? 'secrets'
|
|
244
|
+
: 'config',
|
|
175
245
|
listed: listed.includes(path),
|
|
176
246
|
required: required.has(path),
|
|
177
247
|
type: typeOf(schemas, path),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export interface InstanceLayout {
|
|
3
3
|
readonly app: string;
|
|
4
4
|
readonly instance: string;
|
|
5
|
-
/** Local port of the app (127.0.0.1). */
|
|
5
|
+
/** Local port of the app (127.0.0.1); 0 without @dokebi/web (nothing listens). */
|
|
6
6
|
readonly port: number;
|
|
7
7
|
/** systemd unit, system user and journald identifier; compose project name. */
|
|
8
8
|
readonly unit: string;
|
package/dist/deploy-templates.js
CHANGED
|
@@ -104,8 +104,10 @@ WantedBy=timers.target
|
|
|
104
104
|
}
|
|
105
105
|
export function composeFile(at) {
|
|
106
106
|
return `# ${at.app} (${at.instance}): docker compose -f deploy/compose/${at.instance}.yaml up -d --build
|
|
107
|
-
# The container uses the host network, as the systemd unit would:
|
|
108
|
-
|
|
107
|
+
# The container uses the host network, as the systemd unit would: ${at.port
|
|
108
|
+
? `the app listens on
|
|
109
|
+
# 127.0.0.1:${at.port} (settings) behind the reverse proxy of the host, and`
|
|
110
|
+
: 'the app'} reaches databases
|
|
109
111
|
# on the host at 127.0.0.1. Settings and secrets are mounted read-only from /etc.
|
|
110
112
|
name: ${at.unit}
|
|
111
113
|
services:
|
|
@@ -156,7 +158,7 @@ export function deployGuide(input) {
|
|
|
156
158
|
const storage = input.plugins.includes('dokebi-storage');
|
|
157
159
|
const first = input.instances[0];
|
|
158
160
|
const rows = input.instances
|
|
159
|
-
.map((at) => `| ${at.instance} | \`${at.unit}\` | 127.0.0.1:${at.port} | \`${at.settings}\` | \`${at.secrets}\` | \`${at.code}/current\` | \`${at.data}\` | \`${at.logs}\` | \`${at.backups}\` | \`${at.database}\` |`)
|
|
161
|
+
.map((at) => `| ${at.instance} | \`${at.unit}\` | ${at.port ? `127.0.0.1:${at.port}` : '— (no web server)'} | \`${at.settings}\` | \`${at.secrets}\` | \`${at.code}/current\` | \`${at.data}\` | \`${at.logs}\` | \`${at.backups}\` | \`${at.database}\` |`)
|
|
160
162
|
.join('\n');
|
|
161
163
|
const env = (at) => `DOKEBI_CONFIG=${at.settings} DOKEBI_SECRETS=${at.secrets}`;
|
|
162
164
|
return `# Deploying ${app}
|
|
@@ -174,8 +176,10 @@ The repository keeps, per instance, \`deploy/<instance>/dokebi.example.yaml\` (t
|
|
|
174
176
|
without secrets: copy it to the settings path) and \`deploy/<instance>/secrets.example.yaml\`
|
|
175
177
|
(the secret paths with placeholders: the values live only in your secret store). The app refuses
|
|
176
178
|
to start while a path of the settings' \`secrets:\` list is empty (CONFIG_SECRETS_MISSING), the
|
|
177
|
-
secrets file is readable by others (CONFIG_SECRETS_UNSAFE)
|
|
178
|
-
(CONFIG_SECRETS_CONFLICT)
|
|
179
|
+
secrets file is readable by others (CONFIG_SECRETS_UNSAFE), both files set the same option
|
|
180
|
+
(CONFIG_SECRETS_CONFLICT) or the secrets file names a list item the settings lack
|
|
181
|
+
(CONFIG_SECRETS_UNMATCHED: a secret inside a list item, such as logging.targets.studio.token,
|
|
182
|
+
fills that field of the item the settings name studio).${input.secrets.length ? `\n\nSecrets of this app: ${input.secrets.map((path) => `\`${path}\``).join(', ')}.` : ''}
|
|
179
183
|
|
|
180
184
|
## 1. Prepare the server (once per instance)
|
|
181
185
|
|
package/dist/deploy.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface DeployProxyResult extends DeployResult {
|
|
|
28
28
|
readonly file: string;
|
|
29
29
|
readonly domains: readonly string[];
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The port of each instance asked for. An instance's port lives in its settings sample
|
|
33
|
+
* (`dokebi-web.port`); without @dokebi/web nothing listens, so no instance has one (0) and
|
|
34
|
+
* `--port` is refused. With it a new instance other than production (3000) and staging (3001)
|
|
35
|
+
* needs `--port`, and two instances of the server never share one.
|
|
36
|
+
*/
|
|
37
|
+
export declare function instancePorts(names: readonly string[], known: ReadonlyMap<string, number>, port: number | undefined, web: boolean): Map<string, number>;
|
|
31
38
|
export interface DeployInitOptions {
|
|
32
39
|
readonly target: 'systemd' | 'compose';
|
|
33
40
|
readonly instances: readonly string[];
|
package/dist/deploy.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
|
|
|
6
6
|
import { parseConfig } from '@dokebi/config';
|
|
7
7
|
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
8
8
|
import { isPlaceholder } from './config-init.js';
|
|
9
|
-
import { requiredSecretPaths, schemaTypes, unreadableWarning } from './config-secrets.js';
|
|
9
|
+
import { indexedPath, namedPath, requiredSecretPaths, schemaTypes, unreadableWarning, valueAt, } from './config-secrets.js';
|
|
10
10
|
import { configShow } from './config-show.js';
|
|
11
11
|
import { CONFIG_FILE, renderConfig } from './configuration.js';
|
|
12
12
|
import { BACKUP_SCRIPT, RESTORE_DRILL_SCRIPT } from './deploy-scripts.js';
|
|
@@ -124,38 +124,25 @@ async function secretPaths(root, sample) {
|
|
|
124
124
|
const planned = await requiredSecretPaths(root, (development === undefined ? sampleOptions : parseConfig(development).options));
|
|
125
125
|
for (const path of planned.paths)
|
|
126
126
|
found.add(path);
|
|
127
|
-
// A secret inside a list item (logging.targets.
|
|
128
|
-
//
|
|
127
|
+
// A secret inside a list item is named by the item (logging.targets.2.token →
|
|
128
|
+
// logging.targets.studio.token): the secrets file fills that field of the item only, whatever
|
|
129
|
+
// the order of the list.
|
|
129
130
|
const sources = [
|
|
130
|
-
sampleOptions,
|
|
131
131
|
development === undefined ? {} : parseConfig(development).options,
|
|
132
|
+
sampleOptions,
|
|
132
133
|
];
|
|
133
|
-
const
|
|
134
|
+
const named = (path) => {
|
|
134
135
|
const keys = path.split('.');
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return keys.slice(0, end).join('.');
|
|
138
|
-
return path;
|
|
136
|
+
const source = sources.find((options) => valueAt(options, keys) !== undefined) ?? sampleOptions;
|
|
137
|
+
return namedPath(source, keys).join('.');
|
|
139
138
|
};
|
|
140
|
-
const paths = [...new Set([...found].map(
|
|
139
|
+
const paths = [...new Set([...found].map(named))].sort();
|
|
141
140
|
// A path under another listed path is covered by it.
|
|
142
141
|
return {
|
|
143
142
|
paths: paths.filter((path) => !paths.some((other) => path !== other && path.startsWith(`${other}.`))),
|
|
144
143
|
unreadable: planned.unreadable,
|
|
145
144
|
};
|
|
146
145
|
}
|
|
147
|
-
function valueAt(options, path) {
|
|
148
|
-
let value = options;
|
|
149
|
-
for (const key of path) {
|
|
150
|
-
if (Array.isArray(value) && /^\d+$/.test(key))
|
|
151
|
-
value = value[Number(key)];
|
|
152
|
-
else if (value && typeof value === 'object' && Object.hasOwn(value, key))
|
|
153
|
-
value = value[key];
|
|
154
|
-
else
|
|
155
|
-
return undefined;
|
|
156
|
-
}
|
|
157
|
-
return value;
|
|
158
|
-
}
|
|
159
146
|
/** A placeholder for a secret of the secrets sample, saying what goes there (never a value). */
|
|
160
147
|
function placeholderFor(path, at, current, types) {
|
|
161
148
|
const kind = typeof current === 'string'
|
|
@@ -184,9 +171,11 @@ function placeholderFor(path, at, current, types) {
|
|
|
184
171
|
/** The settings sample of one instance: the sample without secrets, instance values, secrets list. */
|
|
185
172
|
function instanceSample(sample, at, target, plugins, secrets, schemas) {
|
|
186
173
|
const document = parseDocument(sample, { version: '1.2' });
|
|
174
|
+
const options = parseConfig(sample).options;
|
|
187
175
|
for (const path of secrets) {
|
|
188
|
-
// Only where the sample has the path: a section left empty (`connections:`) is null there.
|
|
189
|
-
|
|
176
|
+
// Only where the sample has the path: a section left empty (`connections:`) is null there. A
|
|
177
|
+
// list item named by the path is found as the secrets file finds it.
|
|
178
|
+
const keys = ['options', ...indexedPath(options, path.split('.'))];
|
|
190
179
|
let node = document.contents;
|
|
191
180
|
for (const key of keys.slice(0, -1))
|
|
192
181
|
node = isMap(node) || isSeq(node) ? node.get(key, true) : undefined;
|
|
@@ -315,27 +304,27 @@ async function knownInstances(root) {
|
|
|
315
304
|
}
|
|
316
305
|
return found;
|
|
317
306
|
}
|
|
318
|
-
/**
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
307
|
+
/**
|
|
308
|
+
* The port of each instance asked for. An instance's port lives in its settings sample
|
|
309
|
+
* (`dokebi-web.port`); without @dokebi/web nothing listens, so no instance has one (0) and
|
|
310
|
+
* `--port` is refused. With it a new instance other than production (3000) and staging (3001)
|
|
311
|
+
* needs `--port`, and two instances of the server never share one.
|
|
312
|
+
*/
|
|
313
|
+
export function instancePorts(names, known, port, web) {
|
|
314
|
+
const ports = new Map();
|
|
315
|
+
if (!web) {
|
|
316
|
+
if (port !== undefined)
|
|
317
|
+
throw new CliError('USAGE', '--port: this project has no web server (@dokebi/web), so its instances listen on no port', {
|
|
328
318
|
phase: 'arguments',
|
|
319
|
+
details: { fix: 'leave --port out, or npx dokebi add @dokebi/web first' },
|
|
329
320
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const known = await knownInstances(root);
|
|
335
|
-
const ports = new Map();
|
|
321
|
+
for (const name of names)
|
|
322
|
+
ports.set(name, 0);
|
|
323
|
+
return ports;
|
|
324
|
+
}
|
|
336
325
|
for (const name of names) {
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
326
|
+
const chosen = port ?? known.get(name) ?? DEFAULT_PORTS[name];
|
|
327
|
+
if (chosen === undefined || chosen === 0)
|
|
339
328
|
throw new CliError('PORT_REQUIRED', `The instance ${name} needs a port: --port <n>`, {
|
|
340
329
|
phase: 'arguments',
|
|
341
330
|
details: {
|
|
@@ -343,21 +332,43 @@ export async function deployInit(input, options) {
|
|
|
343
332
|
fix: `npx dokebi deploy init --instance ${name} --port <a free port such as 3002>`,
|
|
344
333
|
},
|
|
345
334
|
});
|
|
346
|
-
ports.set(name,
|
|
335
|
+
ports.set(name, chosen);
|
|
347
336
|
}
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
337
|
+
// A known instance without a port (made before @dokebi/web) takes none.
|
|
338
|
+
const all = [...known, ...ports].filter(([, chosen]) => chosen !== 0);
|
|
339
|
+
for (const [name, chosen] of all) {
|
|
340
|
+
const other = all.find(([otherName, otherPort]) => otherName !== name && otherPort === chosen);
|
|
351
341
|
if (other)
|
|
352
|
-
throw new CliError('PORT_IN_USE', `The instances ${name} and ${other[0]} would both listen on 127.0.0.1:${
|
|
342
|
+
throw new CliError('PORT_IN_USE', `The instances ${name} and ${other[0]} would both listen on 127.0.0.1:${chosen}`, {
|
|
353
343
|
phase: 'arguments',
|
|
354
344
|
details: {
|
|
355
|
-
port,
|
|
345
|
+
port: chosen,
|
|
356
346
|
instances: [name, other[0]],
|
|
357
347
|
fix: `give one of them another port: --instance ${name} --port <n>`,
|
|
358
348
|
},
|
|
359
349
|
});
|
|
360
350
|
}
|
|
351
|
+
return ports;
|
|
352
|
+
}
|
|
353
|
+
/** `dokebi deploy init` (D-0184 §2). */
|
|
354
|
+
export async function deployInit(input, options) {
|
|
355
|
+
const root = await realpath(input);
|
|
356
|
+
const state = await projectState(root);
|
|
357
|
+
const app = await appName(root);
|
|
358
|
+
const plugins = state.plugins.map((plugin) => optionsKey(plugin));
|
|
359
|
+
const names = [...new Set(options.instances)];
|
|
360
|
+
for (const name of names)
|
|
361
|
+
if (!INSTANCE.test(name))
|
|
362
|
+
throw new CliError('USAGE', `--instance ${name}: lowercase letters, digits and -, 1 to 24 characters, starting with a letter`, {
|
|
363
|
+
phase: 'arguments',
|
|
364
|
+
});
|
|
365
|
+
if (options.port !== undefined && names.length !== 1)
|
|
366
|
+
throw new CliError('USAGE', '--port names the port of one instance; give one --instance with it', {
|
|
367
|
+
phase: 'arguments',
|
|
368
|
+
});
|
|
369
|
+
const known = await knownInstances(root);
|
|
370
|
+
const web = plugins.includes('dokebi-web');
|
|
371
|
+
const ports = instancePorts(names, known, options.port, web);
|
|
361
372
|
const sample = await readOptional(await securePath(root, EXAMPLE));
|
|
362
373
|
if (sample === undefined)
|
|
363
374
|
throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to make the instance settings from`, {
|
|
@@ -376,7 +387,7 @@ export async function deployInit(input, options) {
|
|
|
376
387
|
parseConfig(sample).options,
|
|
377
388
|
];
|
|
378
389
|
const all = [...new Set([...known.keys(), ...names])].sort((a, b) => a === 'production' ? -1 : b === 'production' ? 1 : a.localeCompare(b));
|
|
379
|
-
const layouts = all.map((name) => layout(app, name, ports.get(name) ?? known.get(name) ?? 0));
|
|
390
|
+
const layouts = all.map((name) => layout(app, name, ports.get(name) ?? (web ? known.get(name) : 0) ?? 0));
|
|
380
391
|
const files = [];
|
|
381
392
|
const add = (path, body) => files.push({ path, content: withMarker(path, body) });
|
|
382
393
|
for (const at of layouts.filter((item) => names.includes(item.instance))) {
|
package/dist/output.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.4.
|
|
1
|
+
export declare const CLI_VERSION = "0.4.29";
|
|
2
2
|
export declare function envelope(command: string, result: unknown, ok?: boolean): object;
|
|
3
3
|
export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;
|
package/dist/output.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
4
4
|
"description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dokebi",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"semver": "7.8.5",
|
|
37
37
|
"@dokebi/core": "^0.4.3",
|
|
38
|
-
"@dokebi/config": "^0.4.
|
|
38
|
+
"@dokebi/config": "^0.4.4",
|
|
39
39
|
"yaml": "2.9.1"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|