@dokebi/cli 0.4.1 → 0.4.2
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 +5 -5
- package/README.md +1 -1
- package/catalog.json +18 -18
- package/dist/bin.js +9 -0
- package/dist/install-module.d.ts +9 -1
- package/dist/install-module.js +97 -5
- package/dist/log-writer-control.js +89 -25
- package/dist/npm-client.d.ts +19 -0
- package/dist/npm-client.js +68 -1
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/plugin-lifecycle.js +15 -8
- package/dist/project.js +10 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +189 -51
- package/dist/transaction.d.ts +20 -0
- package/dist/transaction.js +187 -39
- package/dist/verify.d.ts +10 -1
- package/dist/verify.js +55 -8
- package/package.json +1 -1
package/AI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# AI guide — @dokebi/cli 0.4.
|
|
1
|
+
# AI guide — @dokebi/cli 0.4.2 (`dokebi`)
|
|
2
2
|
|
|
3
3
|
The command that creates Dokebi projects and installs, removes and upgrades plugins, apps and modules. It changes the project's "CLI-owned files" (generated registration code, state, the sample settings) in transactions. An AI never edits these files by hand: it always changes them with a command. Command details are in [docs/cli/README.md](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/cli/README.md), the install lifecycle in [docs/cli/installation.md](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/cli/installation.md), and the automation contract in [docs/cli/automation.md](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/cli/automation.md).
|
|
4
4
|
|
|
@@ -47,7 +47,7 @@ The `blog` plan, depending on the answers: `init` (when the folder is not a proj
|
|
|
47
47
|
| `dokebi config secret <id>.<path>` | Writes a random secret (32 bytes; a one-item list when the schema wants a list) into dokebi.yaml. The value never appears in output or logs; an existing value stays (`SECRET_KEPT`). Example: `config secret dokebi-websocket-hub.secret`. @dokebi/session needs none: `add` generates its secret into `.env` |
|
|
48
48
|
| `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`). An id that is not installed is `UNKNOWN_PLUGIN` |
|
|
49
49
|
| `dokebi dev-infra [--db-port <n>]` | Development database: `compose.yaml` (user-owned; the project name is the package name), `DOKEBI_DB_PASSWORD` in `.env` (generated, 0600, in `.gitignore`) and `connections.main.options.url`. An existing compose `db` service and password are kept (`COMPOSE_KEPT`). When the port is taken it writes nothing and fails with `PORT_IN_USE` (`details.alternative`: a free port). It does not start the database: `docker compose up -d --wait` |
|
|
50
|
-
| `dokebi verify [--wait-ms <n>]` | `{ ok, checks[{name, ok, skipped?, ms, code?, message?, details?}], urls[], listen[], tellUser? }`: doctor → check (`dokebi check` below, reusing the doctor result) → typecheck (skipped without the script) → build (Vite) → preflight (`ALREADY_RUNNING`, `PORT_IN_USE`, `DB_URL_MISSING`) → start (`--wait-ms`, default 30000) → HTTP (connects to the listening address: `/health/ready`, and `GET /` with the Host header of every app domain) → 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. 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, a default with its reason, and the notices); the human output ends with them under `Tell the user`. Without a recipe record there is no `tellUser` |
|
|
50
|
+
| `dokebi verify [--wait-ms <n>]` | `{ ok, checks[{name, ok, skipped?, ms, code?, message?, details?}], urls[], listen[], tellUser? }`: doctor → check (`dokebi check` below, reusing the doctor result) → typecheck (skipped without the script) → build (Vite) → preflight (`ALREADY_RUNNING`, `PORT_IN_USE`, `DB_URL_MISSING`) → 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. 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, a default with its reason, and the notices); the human output ends with them under `Tell the user`. Without a recipe record there is no `tellUser` |
|
|
51
51
|
| `dokebi check` | User-code conventions (D-0072). `{ ok, problems[{rule, severity?, file, line, message, fix}], files, rules, typescript, ms: { total, typescript } }`, exit 1 on a problem (a `severity: 'warning'` problem is reported but never fails: `ok` stays true). The human output prints `file:line rule message` and a `fix:` line per problem, then a summary (`check ok` or `check FAILED: N problems`, files, time). Rules: `empty-catch` (an empty `catch`, `.catch(() => {})`, an empty onRejected of `then`, an empty `on('error', …)`), `floating-promise` (a statement that calls something returning a Promise without await, return, `void` or `.catch`; the project's TypeScript decides the type; `test()` of node:test is exempt), `non-erasable-syntax` (enum, namespaces with values, parameter properties, decorators, `import x =`, `export =`, `<T>x`), `private-import` (a path of `@dokebi/*` outside its exports, such as `@dokebi/core/dist/…`), `missing-package` (an @dokebi package that is not installed → `add`; a feature Dokebi does not have → the absent `instead`; an unknown name), `generated-file` (doctor `MODIFIED_REGISTRATION`, `GUIDE_BLOCK_MODIFIED` and `MODIFIED_TEMPLATE` of CLI-owned files), `secret-in-config` (a plugin check `SECRET_IN_CONFIG` while dokebi.yaml could be committed). Warning (D-0074): `inertia-form-only` — an Inertia form submit (`form.post/put/patch/delete/submit(…)` of a `useForm(…)` result, `router.post/put/patch/delete(…)`) whose options are missing or an object literal without `only`; the GET after its redirect would send every prop of the page again. Fix: `form.post(url, { only: ['<props it changes>'] })` (`errors` always comes along). It reads the text of `src/**` files that import `@inertiajs/` (the `<script>` blocks of .vue and .svelte files; comments and strings masked); calls inside a template attribute are not read, and options passed as a variable or with a spread are taken as fine. When an empty catch, a background promise or a full-page form submit (a login) is really intended, put `// dokebi-check: allow <rule> <reason>` on that line or the line above (only `empty-catch`, `floating-promise` and `inertia-form-only`; without a reason the marker is ignored). The sources are the program of `tsconfig.json` (generated files, `.d.ts` and node_modules excluded), read with the API of the TypeScript 7 installed in the project (`typescript/unstable/sync`): when it cannot be loaded `TYPESCRIPT_UNAVAILABLE` (`npm install`), when tsconfig cannot be opened `TSCONFIG_INVALID` |
|
|
52
52
|
| `dokebi upgrade <plugin id> [--to <version\|package\|tgz>] [--dry-run]` | Change the version. Do not use remove and add instead. With the same version and a changed manifest it applies the manifest again (D-0037) |
|
|
53
53
|
| `dokebi remove <plugin id> [--dry-run]` | Remove a plugin. `REVERSE_DEPENDENCY` when a remaining plugin depends on it, `REQUIRED_PLUGIN` for a required part |
|
|
@@ -84,7 +84,7 @@ Every command takes `--json` (one machine document) and every changing command `
|
|
|
84
84
|
|
|
85
85
|
- One project = one process (`src/main.ts`; several apps share it). Run files live in `.dokebi/run/` (`app.pid`, `app.log`, `app.log.1`…, `log-writer.log`; the `.gitignore` inside is `*`, so Git never takes them).
|
|
86
86
|
- **Log rotation (D-0053)**: `start` launches a separate log writer process, and the writer starts the app with stdout and stderr pipes and writes `app.log` line by line. When the next line would pass `--log-max-mb` (default 50, 1–10240), it moves the file to `app.log.1` at a line boundary and keeps `--log-files` files (default 5, 1–100). Fast writes lose no line (a slow disk makes the app's writes wait). Out of range is `USAGE`, and so is combining them with `--foreground`. There is no dokebi.yaml setting (put fixed values in a package.json script). The writer ignores SIGTERM and, when the app ends (crashes included), writes one line `--- dokebi: app exited (…) ---` and ends. The writer's own errors go to `.dokebi/run/log-writer.log` as one JSON line each (`level: error`, `code`, the full `error`): `LOG_OPEN_FAILED`, `LOG_WRITE_FAILED`, `LOG_ROTATE_FAILED`, `LOG_LINES_DROPPED`, `APP_SPAWN_FAILED`, `APP_OUTPUT_FAILED`.
|
|
87
|
-
- `start`: the inspect checks (an error diagnostic is `START_CHECK_FAILED`), then a background run with `NODE_ENV=production` (kept when already set). The app also gets a random per-start `DOKEBI_INSTANCE_TOKEN`, kept with the pid, the `urls` and the log writer's loopback `control` port in `.dokebi/run/app.pid` (0600) for the proofs below; it never appears in output. When the log shows `Dokebi application started` (the line the generated main writes), `ready: true`. The result has `logWriter` (pid), `logMaxBytes`, `logFiles`, `listen` (the bound addresses from the web server's listening line, such as `http://127.0.0.1:3000`) and `urls`, the addresses the app answers: with @dokebi/app the Host name picks the app, so `urls` are `http://<domain>:<port>` for every configured domain that resolves to this machine (`localhost`, `*.localhost`, `127.x.x.x`), plus `listen` when `unknownDomain` names an app; without @dokebi/app they equal `listen` (`0.0.0.0` shown as `127.0.0.1`). Open and test `urls`: a request to the bound IP gets 404 when that IP is not an app domain (do not add one with `app link` for that). `urls` is empty when only public domains are configured (they answer through DNS or a proxy). `dokebi dev` prints the same addresses (from `dokebi-web.port`) when it starts. Without that line within `--wait-ms` (default 10000) it reports `ready: false` and a warning, even if the process lives. When the process ends before that: `START_FAILED` (details.tail: the last 30 log lines; details.logWriterLog: the end of the writer's diagnostics). Already running: `ALREADY_RUNNING`; another start in progress: `START_IN_PROGRESS`.
|
|
87
|
+
- `start`: the inspect checks (an error diagnostic is `START_CHECK_FAILED`), then a background run with `NODE_ENV=production` (kept when already set). The app also gets a random per-start `DOKEBI_INSTANCE_TOKEN`, kept with the pid, the `urls` and the log writer's loopback `control` port in `.dokebi/run/app.pid` (0600) for the proofs below; it never appears in output. When the log shows `Dokebi application started` (the line the generated main writes), `ready: true`. The result has `logWriter` (pid), `logMaxBytes`, `logFiles`, `listen` (the bound addresses from the web server's listening line, such as `http://127.0.0.1:3000`) and `urls`, the addresses the app answers: with @dokebi/app the Host name picks the app, so `urls` are `http://<domain>:<port>` for every configured domain that resolves to this machine (`localhost`, `*.localhost`, `127.x.x.x`), plus `listen` when `unknownDomain` names an app; without @dokebi/app they equal `listen` (`0.0.0.0` shown as `127.0.0.1`). Open and test `urls`: a request to the bound IP gets 404 when that IP is not an app domain (do not add one with `app link` for that). `urls` is empty when only public domains are configured (they answer through DNS or a proxy). `dokebi dev` prints the same addresses (from `dokebi-web.port`) when it starts. Without that line within `--wait-ms` (default 10000) it reports `ready: false` and a warning, even if the process lives. When the process ends before that: `START_FAILED` (details.tail: the last 30 log lines; details.logWriterLog: the end of the writer's diagnostics). Already running: `ALREADY_RUNNING`; another start in progress: `START_IN_PROGRESS` (one start at a time holds `.dokebi/run/start.lock` from before it checks the running state until the pid file is written, so of two concurrent starts exactly one runs the app and the other starts nothing).
|
|
88
88
|
- **Without process information** (no `ps`, a sandbox blocks it, /proc unreadable; D-0073), `status`, `stop` and `start` (with a PID file) ask the app instead: `kill(pid, 0)` saying the pid does not exist makes the PID file stale; otherwise they send a random challenge in the request header `x-dokebi-instance` to the recorded address (or `dokebi-web.port` of dokebi.yaml), and @dokebi/web answers with HMAC-SHA256 of its `DOKEBI_INSTANCE_TOKEN` (the token itself never goes on the wire). Only the right answer proves the pid is this start's app: `status` then reports `proof: "http"` (no `rssMb`) and `stop` signals it (`proof: "http"` in its result). When the proof fails (no web plugin, another server on the port, a PID file of an older CLI without a token), they fail with `PROCESS_INFO_FAILED` as before (the reason in `cause`; `details.pid`, `source`, `hint` (run outside the sandbox or allow `ps` or /proc), `manualStop` (check the pid yourself, `kill -TERM <pid>`, then delete `.dokebi/run/app.pid`) and `instanceProof{tried, reason}`), signal nothing and **keep** the PID file (the app may still live). With `ps` the behaviour is unchanged (`proof: "process"`). `verify` stops the process it started seconds ago with the pid start returned (only `kill(pid, 0)` and SIGTERM).
|
|
89
89
|
- **When this shell may not signal the app** (an agent sandbox per command, such as Codex CLI, or Claude Code's sandbox for an app started outside it: even `kill(pid, 0)` is refused with EPERM; D-0073 addendum), `stop` and `status` ask the app's **log writer**, its parent from the start command, which can signal it. The writer listens on a random loopback TCP port (`127.0.0.1`, the `control` field of the 0600 PID file) and accepts only `status` and `stop` after both sides proved the instance token (an HMAC challenge each way; the token never goes on the wire). Without process information the writer is asked first (`proof: "log-writer"`), then the HTTP proof above. With `ps` but a refused SIGTERM, `stop` asks the writer as well. The writer sends SIGTERM, waits `--timeout-ms` and, with `--force`, SIGKILL (`STOP_TIMEOUT` as usual); the result says `via: "log-writer"` (otherwise `via: "signal"`). A writer that is gone, refuses the token, or runs another pid is never asked to stop anything: the error is the one above (`PROCESS_INFO_FAILED` with `instanceProof`, or `STOP_FAILED` with `details.logWriter` when `ps` worked). A sandbox without local network access cannot reach the port either; then stop the app from a shell that may signal it.
|
|
90
90
|
- `stop`: SIGTERM **to the app** for a drain (the writer ends after the app's output ends; when it does not end within 5 seconds, `warnings`). When the app does not end within `--timeout-ms` (default 30000): `STOP_TIMEOUT` (the process stays), with `--force` a SIGKILL then. When nothing runs: `stopped: false`.
|
|
@@ -107,7 +107,7 @@ One document on stdout: `{ schemaVersion, cliVersion, command, ok, result?, erro
|
|
|
107
107
|
|
|
108
108
|
- `result` (changing commands): `changed` (changed files), `preserved`, `preservedDetails` (an edited template that upgrade kept, with the location of the new one), `warnings[{code, path}]`, `configRequired` (missing required values, at most 20 plus `errorCount`), `autoInstalled`, `resolved` (the version add picked), `generatedSecrets[{variable, file, plugin}]` (secrets add generated into `.env`; names only), `nextSteps[{command, why}]` (init, add, app, module, dev-infra and recipe all use this shape; an edit is `edit …`), `workspaceInstall`.
|
|
109
109
|
- `recipe` with missing answers exits **2** with `ok: false` and `error.code: ANSWERS_REQUIRED` (`mutation: none`); `result` holds `status: needs-answers`, `questions`, `missing`, `waitForUser` and `next`. With every answer, `result` is `{ status: planned|applied, root, answers, answersFrom, plan.steps[{id, command, why}], preflight[{code, severity, message, fix}], completedSteps, skippedSteps, nextSteps, report }`. For `verify`, `ok` equals `result.ok`, and a failure exits 1.
|
|
110
|
-
- `error`: `code`, `message`, `phase` (arguments, install, commit, unknown), `mutation`, `details`. With `mutation: 'unknown'` there is no guarantee that the originals are untouched. An npm failure in the temporary install (staging) has `details.stage: staging` and `mutation: none` (the project is untouched). A failure to reach the registry has `details.network: true` and `hint` (try again online, or use the cache with `npm_config_prefer_offline=true npx dokebi …`); when the version came from the snapshot, `details.warnings` holds `CATALOG_OFFLINE`. The `details` of `NPM_FAILED` hold `command`, `exitCode`, `signal`, `timedOut` and the last 4 KB of npm's output (`stderr`, `stdout`; `user:password@` in URLs, `_authToken=` and the like are masked). The same helper masks verify's `TYPECHECK_FAILED` and `BUILD_FAILED`, the recipe's `STEP_COMMAND_FAILED` `details.output` (the last 4 KB of the project script or step command output), and the `tail` and `logWriterLog` of `START_FAILED` (`dokebi logs` shows the log as it is). The CLI always calls npm with `--loglevel=warn` (an inherited `loglevel=silent` from `npx -s` or `npm run -s` used to make npm print not even its errors). `recovery-required` keeps the lock and the backups and needs a person to look (never delete the lock or backups and retry).
|
|
110
|
+
- `error`: `code`, `message`, `phase` (arguments, install, commit, unknown), `mutation`, `details`. With `mutation: 'unknown'` there is no guarantee that the originals are untouched. An npm failure in the temporary install (staging) has `details.stage: staging` and `mutation: none` (the project is untouched). A failure to reach the registry has `details.network: true` and `hint` (try again online, or use the cache with `npm_config_prefer_offline=true npx dokebi …`); when the version came from the snapshot, `details.warnings` holds `CATALOG_OFFLINE`. The `details` of `NPM_FAILED` hold `command`, `exitCode`, `signal`, `timedOut` and the last 4 KB of npm's output (`stderr`, `stdout`; `user:password@` in URLs, `_authToken=` and the like are masked). From npm's whole stderr they also hold `npmCode` (such as `ERESOLVE`), `npmSummary`, `logFile` (npm's debug log: read it for the full cause) and `reportFile`, when npm printed them. The same helper masks verify's `TYPECHECK_FAILED` and `BUILD_FAILED`, the recipe's `STEP_COMMAND_FAILED` `details.output` (the last 4 KB of the project script or step command output), and the `tail` and `logWriterLog` of `START_FAILED` (`dokebi logs` shows the log as it is). The CLI always calls npm with `--loglevel=warn` (an inherited `loglevel=silent` from `npx -s` or `npm run -s` used to make npm print not even its errors). `recovery-required` keeps the lock and the backups and needs a person to look (never delete the lock or backups and retry).
|
|
111
111
|
- Logs of the CLI itself go to stderr (see "Logs" below); stdout holds only the document.
|
|
112
112
|
|
|
113
113
|
## 4. Project structure (D-0031, D-0032)
|
|
@@ -145,7 +145,7 @@ A plugin manifest cannot write files outside `src/shared/{models,modules,views}/
|
|
|
145
145
|
|
|
146
146
|
Each file of `install.files` (and of an install module's `files`) may declare `owner`: `user` (the default: a skeleton the user edits; doctor stays quiet and upgrade keeps an edited file) or `cli` (a file the plugin generates again; editing it is doctor `MODIFIED_TEMPLATE` and check `generated-file`). Entry files are always `user` (`cli` there is `INVALID_MANIFEST`). The owner is recorded in `.dokebi/state.json`.
|
|
147
147
|
|
|
148
|
-
The CLI has no special case for any plugin ID. App and module commands look for the plugin that declares `hosts`; without one they fail with `APP_PLUGIN_MISSING` or `MODULE_PLUGIN_MISSING` (naming the catalog package that has the role), and when apps or modules are recorded but no plugin takes them, generation fails with `HOST_MISSING`. `planInstall` may return `envSecrets` (environment variable names, at most 8): the CLI generates a secret into `.env` for each one the environment and `.env` lack (§2.2), so a plugin never writes files itself. `planInstall`, `planUpgrade` and `check` of `installModule` run under the Node permission model (no writes, no child processes, no environment variables, 5 s and 1 MiB); the network is not blocked, so it is not a full sandbox. npm lifecycle scripts never run.
|
|
148
|
+
The CLI has no special case for any plugin ID. App and module commands look for the plugin that declares `hosts`; without one they fail with `APP_PLUGIN_MISSING` or `MODULE_PLUGIN_MISSING` (naming the catalog package that has the role), and when apps or modules are recorded but no plugin takes them, generation fails with `HOST_MISSING`. `planInstall` may return `envSecrets` (environment variable names, at most 8): the CLI generates a secret into `.env` for each one the environment and `.env` lack (§2.2), so a plugin never writes files itself. `planInstall`, `planUpgrade` and `check` of `installModule` run under the Node permission model (reads only in the plugin's own package and the packages it depends on, never the project, the workspace root or their `.env`; no writes, no child processes, no environment variables, 5 s and 1 MiB); the network is not blocked, so it is not a full sandbox. npm lifecycle scripts never run.
|
|
149
149
|
|
|
150
150
|
**A changed manifest needs a new version** (D-0037). The repository check refuses a changed manifest under the same version, and installed projects report doctor `MANIFEST_CHANGED` (hint: `dokebi upgrade <id>`).
|
|
151
151
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @dokebi/cli
|
|
2
2
|
|
|
3
|
-
The tool that creates Dokebi projects and installs plugins, version 0.4.
|
|
3
|
+
The tool that creates Dokebi projects and installs plugins, version 0.4.2 (0.4.0 was the stage-3 release, D-0067). The command is `dokebi`. What changed since 0.1.1 (YAML settings, migrate, multi-app and module commands) is in the CHANGELOG. Node.js >=24.0.0, ESM; the default CI runs Node 24.
|
|
4
4
|
|
|
5
5
|
## Running it
|
|
6
6
|
|
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.2",
|
|
5
5
|
"core": {
|
|
6
6
|
"package": "@dokebi/core",
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.1"
|
|
8
8
|
},
|
|
9
9
|
"capabilities": {
|
|
10
10
|
"admin.ui": {
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
{
|
|
224
224
|
"id": "dokebi-app",
|
|
225
225
|
"package": "@dokebi/app",
|
|
226
|
-
"version": "0.4.
|
|
226
|
+
"version": "0.4.2",
|
|
227
227
|
"kind": "plugin",
|
|
228
228
|
"summary": "Several apps chosen by request domain (src/apps/<app>) with a template each; installs web, controller and template-ejs with it",
|
|
229
229
|
"description": "Multiple apps per project for Dokebi: pick the app and template by request domain, with EJS and Inertia skeletons.",
|
|
@@ -252,7 +252,7 @@
|
|
|
252
252
|
"requires": [
|
|
253
253
|
{
|
|
254
254
|
"id": "dokebi-web",
|
|
255
|
-
"range": "^0.4.
|
|
255
|
+
"range": "^0.4.2",
|
|
256
256
|
"package": "@dokebi/web",
|
|
257
257
|
"automatic": true
|
|
258
258
|
},
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"id": null,
|
|
369
369
|
"package": "@dokebi/cli",
|
|
370
|
-
"version": "0.4.
|
|
370
|
+
"version": "0.4.2",
|
|
371
371
|
"kind": "cli",
|
|
372
372
|
"summary": "Commands to create a project, browse the plugin catalog, run recipes, install plugins and verify (dokebi)",
|
|
373
373
|
"description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
|
|
@@ -438,7 +438,7 @@
|
|
|
438
438
|
{
|
|
439
439
|
"id": "dokebi-config",
|
|
440
440
|
"package": "@dokebi/config",
|
|
441
|
-
"version": "0.4.
|
|
441
|
+
"version": "0.4.1",
|
|
442
442
|
"kind": "required",
|
|
443
443
|
"summary": "Required part of the core that reads and validates dokebi.yaml into read-only settings per app (installed automatically)",
|
|
444
444
|
"description": "Validated immutable YAML configuration for Dokebi applications.",
|
|
@@ -511,7 +511,7 @@
|
|
|
511
511
|
{
|
|
512
512
|
"id": null,
|
|
513
513
|
"package": "@dokebi/core",
|
|
514
|
-
"version": "0.4.
|
|
514
|
+
"version": "0.4.1",
|
|
515
515
|
"kind": "core",
|
|
516
516
|
"summary": "Application core: settings loading, plugin registration, lifecycle, logger and shared libraries (required)",
|
|
517
517
|
"description": "Minimal composable Node.js application and plugin lifecycle framework.",
|
|
@@ -724,7 +724,7 @@
|
|
|
724
724
|
{
|
|
725
725
|
"id": "dokebi-event-bus",
|
|
726
726
|
"package": "@dokebi/event-bus",
|
|
727
|
-
"version": "0.4.
|
|
727
|
+
"version": "0.4.1",
|
|
728
728
|
"kind": "plugin",
|
|
729
729
|
"summary": "Named event buses (local or NATS, at most once)",
|
|
730
730
|
"description": "Named event buses for Dokebi: in-process dispatch or NATS Core delivery between processes.",
|
|
@@ -921,7 +921,7 @@
|
|
|
921
921
|
{
|
|
922
922
|
"id": "dokebi-migrate",
|
|
923
923
|
"package": "@dokebi/migrate",
|
|
924
|
-
"version": "0.4.
|
|
924
|
+
"version": "0.4.1",
|
|
925
925
|
"kind": "plugin",
|
|
926
926
|
"summary": "Migration and seed runner for dokebi-db connections (dokebi-migrate up in the deploy step)",
|
|
927
927
|
"description": "Migrations and seeds for dokebi-db connections: history and lock for PostgreSQL, MySQL, MariaDB and MongoDB, run from the deployment.",
|
|
@@ -1127,7 +1127,7 @@
|
|
|
1127
1127
|
{
|
|
1128
1128
|
"id": "dokebi-scheduler",
|
|
1129
1129
|
"package": "@dokebi/scheduler",
|
|
1130
|
-
"version": "0.4.
|
|
1130
|
+
"version": "0.4.1",
|
|
1131
1131
|
"kind": "plugin",
|
|
1132
1132
|
"summary": "Submits fixed-interval and cron schedules as @dokebi/job jobs (one leader through Redis)",
|
|
1133
1133
|
"description": "Interval and cron schedules (with time zones) for Dokebi that submit jobs, with one leader over Redis.",
|
|
@@ -1175,7 +1175,7 @@
|
|
|
1175
1175
|
{
|
|
1176
1176
|
"id": "dokebi-seal",
|
|
1177
1177
|
"package": "@dokebi/seal",
|
|
1178
|
-
"version": "0.4.
|
|
1178
|
+
"version": "0.4.1",
|
|
1179
1179
|
"kind": "plugin",
|
|
1180
1180
|
"summary": "Seals WebSocket frames and HTTP bodies (obfuscation, replay protection) with a browser client (WASM)",
|
|
1181
1181
|
"description": "Payload sealing (obfuscation, replay defence, in-transit encryption) for seal hosts: @dokebi/websocket frames and @dokebi/web HTTP bodies, with browser socket, fetch and Inertia clients and their WASM loader",
|
|
@@ -1218,7 +1218,7 @@
|
|
|
1218
1218
|
{
|
|
1219
1219
|
"id": "dokebi-session",
|
|
1220
1220
|
"package": "@dokebi/session",
|
|
1221
|
-
"version": "0.4.
|
|
1221
|
+
"version": "0.4.1",
|
|
1222
1222
|
"kind": "plugin",
|
|
1223
1223
|
"summary": "Server-side sessions for @dokebi/web (signed cookie, memory or redis store, flash)",
|
|
1224
1224
|
"description": "Server-side sessions for @dokebi/web: signed cookie, memory or Redis store, flash.",
|
|
@@ -1279,7 +1279,7 @@
|
|
|
1279
1279
|
{
|
|
1280
1280
|
"id": "dokebi-sse",
|
|
1281
1281
|
"package": "@dokebi/sse",
|
|
1282
|
-
"version": "0.4.
|
|
1282
|
+
"version": "0.4.1",
|
|
1283
1283
|
"kind": "plugin",
|
|
1284
1284
|
"summary": "Server-sent events (SSE) streams and rooms, delivery across workers, and a browser client",
|
|
1285
1285
|
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
|
@@ -1384,7 +1384,7 @@
|
|
|
1384
1384
|
{
|
|
1385
1385
|
"id": "dokebi-template-inertia",
|
|
1386
1386
|
"package": "@dokebi/template-inertia",
|
|
1387
|
-
"version": "0.4.
|
|
1387
|
+
"version": "0.4.2",
|
|
1388
1388
|
"kind": "plugin",
|
|
1389
1389
|
"summary": "Inertia (v3) server adapter: Vue 3 views, Vite build files and optional server-side rendering (SSR)",
|
|
1390
1390
|
"description": "Inertia v3 server adapter for Dokebi: page JSON, Vite manifest assets and optional server-side rendering.",
|
|
@@ -1441,7 +1441,7 @@
|
|
|
1441
1441
|
"requires": [
|
|
1442
1442
|
{
|
|
1443
1443
|
"id": "dokebi-web",
|
|
1444
|
-
"range": "^0.4.
|
|
1444
|
+
"range": "^0.4.2"
|
|
1445
1445
|
}
|
|
1446
1446
|
],
|
|
1447
1447
|
"optional": [
|
|
@@ -1470,7 +1470,7 @@
|
|
|
1470
1470
|
{
|
|
1471
1471
|
"id": "dokebi-web",
|
|
1472
1472
|
"package": "@dokebi/web",
|
|
1473
|
-
"version": "0.4.
|
|
1473
|
+
"version": "0.4.2",
|
|
1474
1474
|
"kind": "plugin",
|
|
1475
1475
|
"summary": "Fastify 5 HTTP server (503 before ready, health paths, drain, zero-downtime cluster replacement)",
|
|
1476
1476
|
"description": "Fastify 5 HTTP server owned by a Dokebi app: 503 before ready, health routes, drain and cluster handover.",
|
|
@@ -1524,7 +1524,7 @@
|
|
|
1524
1524
|
{
|
|
1525
1525
|
"id": "dokebi-websocket",
|
|
1526
1526
|
"package": "@dokebi/websocket",
|
|
1527
|
-
"version": "0.4.
|
|
1527
|
+
"version": "0.4.1",
|
|
1528
1528
|
"kind": "plugin",
|
|
1529
1529
|
"summary": "WebSocket routes, rooms and broadcast on top of dokebi-web (at most once)",
|
|
1530
1530
|
"description": "WebSocket routes on @dokebi/web with rooms and broadcast across servers through @dokebi/websocket-hub.",
|
|
@@ -1581,7 +1581,7 @@
|
|
|
1581
1581
|
{
|
|
1582
1582
|
"id": "dokebi-websocket-hub",
|
|
1583
1583
|
"package": "@dokebi/websocket-hub",
|
|
1584
|
-
"version": "0.4.
|
|
1584
|
+
"version": "0.4.1",
|
|
1585
1585
|
"kind": "plugin",
|
|
1586
1586
|
"summary": "Hub of WebSocket clients, rooms, broadcast and kick (usually used through @dokebi/websocket)",
|
|
1587
1587
|
"description": "WebSocket hub for Dokebi: connection roster, rooms, broadcast and kick across workers and servers.",
|
package/dist/bin.js
CHANGED
|
@@ -774,6 +774,15 @@ function nextSteps(error) {
|
|
|
774
774
|
const npmError = error.details.stderr;
|
|
775
775
|
if (error.code === 'NPM_FAILED' && typeof npmError === 'string' && npmError.trim())
|
|
776
776
|
lines.add(`npm stderr (end):\n${npmError.trimEnd()}`);
|
|
777
|
+
if (error.code === 'NPM_FAILED') {
|
|
778
|
+
const { npmCode, npmSummary, logFile, reportFile } = error.details;
|
|
779
|
+
if (typeof npmCode === 'string')
|
|
780
|
+
lines.add(`npm error: ${npmCode}${typeof npmSummary === 'string' ? ` ${npmSummary}` : ''}`);
|
|
781
|
+
if (typeof reportFile === 'string')
|
|
782
|
+
lines.add(`npm report: ${reportFile}`);
|
|
783
|
+
if (typeof logFile === 'string')
|
|
784
|
+
lines.add(`npm log: ${logFile}`);
|
|
785
|
+
}
|
|
777
786
|
if (error.code === 'UNKNOWN_COMMAND')
|
|
778
787
|
lines.add('hint: dokebi --help lists the commands');
|
|
779
788
|
const hint = error.details.hint;
|
package/dist/install-module.d.ts
CHANGED
|
@@ -14,10 +14,18 @@ export declare class InstallPlanningError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
/** Returned when the module does not export the requested phase. */
|
|
16
16
|
export declare const PHASE_MISSING: unique symbol;
|
|
17
|
+
/**
|
|
18
|
+
* The folders an install module may read (R9): its own package and the packages it depends on
|
|
19
|
+
* (`dependencies`, `optionalDependencies`, `peerDependencies`, transitively), each found the way
|
|
20
|
+
* Node resolves it from the importer's real folder, and allowed both where Node finds it (a
|
|
21
|
+
* `node_modules/<name>` link) and at its real path. Never the project, the workspace root or
|
|
22
|
+
* the rest of `node_modules`, so `.env` files and other projects' files stay unreadable.
|
|
23
|
+
*/
|
|
24
|
+
export declare function installReadRoots(packageDirectory: string): Promise<string[]>;
|
|
17
25
|
/**
|
|
18
26
|
* Runs one lifecycle phase of a plugin installation module in a child process.
|
|
19
27
|
* The child uses the Node permission model: it may read only `readRoots`
|
|
20
|
-
* (real paths), cannot write files or spawn processes, and receives no
|
|
28
|
+
* (at their real and link paths), cannot write files or spawn processes, and receives no
|
|
21
29
|
* parent environment variables. Node 24 does not restrict network access.
|
|
22
30
|
*/
|
|
23
31
|
export declare function runInstallModule(modulePath: string, phase: InstallPhase, context: InstallModuleContext, readRoots?: readonly string[]): Promise<unknown>;
|
package/dist/install-module.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { realpath } from 'node:fs/promises';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, sep } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { debug, debugEnabled, describeError } from './diagnostics.js';
|
|
5
|
+
import { debug, debugEnabled, describeError, isMissing } from './diagnostics.js';
|
|
6
6
|
export class InstallPlanningError extends Error {
|
|
7
7
|
code;
|
|
8
8
|
constructor(code) {
|
|
@@ -16,10 +16,96 @@ export const PHASE_MISSING = Symbol('dokebi.install.phase-missing');
|
|
|
16
16
|
const MAX_BYTES = 1024 * 1024;
|
|
17
17
|
const TIMEOUT_MS = 5000;
|
|
18
18
|
const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code});\n}\n";
|
|
19
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
20
|
+
/** Packages an install module may read (its own and its dependencies, transitively). */
|
|
21
|
+
const MAX_READ_PACKAGES = 512;
|
|
22
|
+
async function dependencyNames(directory) {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = await readFile(join(directory, 'package.json'), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// A folder without package.json (a bare install module) declares no dependencies.
|
|
29
|
+
if (!isMissing(error))
|
|
30
|
+
throw error;
|
|
31
|
+
debug('Install module package has no package.json', { directory });
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const pkg = JSON.parse(text);
|
|
35
|
+
const names = new Set();
|
|
36
|
+
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
37
|
+
const value = pkg[field];
|
|
38
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
39
|
+
for (const name of Object.keys(value))
|
|
40
|
+
if (PACKAGE_NAME.test(name))
|
|
41
|
+
names.add(name);
|
|
42
|
+
}
|
|
43
|
+
return [...names];
|
|
44
|
+
}
|
|
45
|
+
/** Where Node finds `name` from `directory`: `node_modules/<name>` in it or a parent folder. */
|
|
46
|
+
async function findPackage(directory, name) {
|
|
47
|
+
for (let current = directory;; current = dirname(current)) {
|
|
48
|
+
if (basename(current) !== 'node_modules') {
|
|
49
|
+
const candidate = join(current, 'node_modules', name);
|
|
50
|
+
try {
|
|
51
|
+
await stat(candidate);
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!isMissing(error))
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (dirname(current) === current)
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The folders an install module may read (R9): its own package and the packages it depends on
|
|
65
|
+
* (`dependencies`, `optionalDependencies`, `peerDependencies`, transitively), each found the way
|
|
66
|
+
* Node resolves it from the importer's real folder, and allowed both where Node finds it (a
|
|
67
|
+
* `node_modules/<name>` link) and at its real path. Never the project, the workspace root or
|
|
68
|
+
* the rest of `node_modules`, so `.env` files and other projects' files stay unreadable.
|
|
69
|
+
*/
|
|
70
|
+
export async function installReadRoots(packageDirectory) {
|
|
71
|
+
const own = await realpath(packageDirectory);
|
|
72
|
+
const roots = new Set([own]);
|
|
73
|
+
const visited = new Set([own]);
|
|
74
|
+
const queue = [own];
|
|
75
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
76
|
+
for (const name of await dependencyNames(next)) {
|
|
77
|
+
const found = await findPackage(next, name);
|
|
78
|
+
if (!found) {
|
|
79
|
+
// Optional and peer dependencies may be absent; an import of them fails on its own.
|
|
80
|
+
debug('Install module dependency not found', { package: next, dependency: name });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const real = await realpath(found);
|
|
84
|
+
roots.add(found);
|
|
85
|
+
roots.add(real);
|
|
86
|
+
if (visited.has(real))
|
|
87
|
+
continue;
|
|
88
|
+
visited.add(real);
|
|
89
|
+
if (visited.size > MAX_READ_PACKAGES)
|
|
90
|
+
throw new InstallPlanningError('INSTALL_DEPENDENCIES_LIMIT');
|
|
91
|
+
queue.push(real);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A folder inside another allowed folder adds nothing; the own package stays first (cwd).
|
|
95
|
+
const sorted = [...roots].sort();
|
|
96
|
+
const result = [own];
|
|
97
|
+
for (const root of sorted)
|
|
98
|
+
if (root !== own &&
|
|
99
|
+
!sorted.some((other) => other !== root && root.startsWith(other + sep)) &&
|
|
100
|
+
!root.startsWith(own + sep))
|
|
101
|
+
result.push(root);
|
|
102
|
+
debug('Install module read roots', { package: own, roots: result });
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
19
105
|
/**
|
|
20
106
|
* Runs one lifecycle phase of a plugin installation module in a child process.
|
|
21
107
|
* The child uses the Node permission model: it may read only `readRoots`
|
|
22
|
-
* (real paths), cannot write files or spawn processes, and receives no
|
|
108
|
+
* (at their real and link paths), cannot write files or spawn processes, and receives no
|
|
23
109
|
* parent environment variables. Node 24 does not restrict network access.
|
|
24
110
|
*/
|
|
25
111
|
export async function runInstallModule(modulePath, phase, context, readRoots = [dirname(modulePath)]) {
|
|
@@ -29,7 +115,13 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
29
115
|
let roots;
|
|
30
116
|
let moduleUrl;
|
|
31
117
|
try {
|
|
32
|
-
|
|
118
|
+
// Each root at its real path and, when it is a link (a package in node_modules), also at the
|
|
119
|
+
// link: Node checks the path it resolves through (node_modules/<name>/package.json) as well.
|
|
120
|
+
const resolved = await Promise.all(readRoots.map(async (root) => [
|
|
121
|
+
await realpath(root),
|
|
122
|
+
join(await realpath(dirname(root)), basename(root)),
|
|
123
|
+
]));
|
|
124
|
+
roots = [...new Set(resolved.flat())];
|
|
33
125
|
moduleUrl = pathToFileURL(await realpath(modulePath)).href;
|
|
34
126
|
}
|
|
35
127
|
catch {
|
|
@@ -26,8 +26,13 @@ import { debug, describeError, reportError } from './diagnostics.js';
|
|
|
26
26
|
export const CONTROL_HOST = '127.0.0.1';
|
|
27
27
|
const HELLO = 'dokebi-log-writer';
|
|
28
28
|
const VERSION = 1;
|
|
29
|
-
/** A longer line is not this protocol. */
|
|
29
|
+
/** A longer line (in bytes, without its newline) is not this protocol. */
|
|
30
30
|
const MAX_LINE = 4096;
|
|
31
|
+
/** Messages (and their bytes) received but not yet read; the protocol needs one at a time. */
|
|
32
|
+
const MAX_QUEUED = 2;
|
|
33
|
+
const MAX_QUEUED_BYTES = MAX_QUEUED * MAX_LINE;
|
|
34
|
+
const NEWLINE = 0x0a;
|
|
35
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
31
36
|
/** An unauthenticated connection is closed after this long. */
|
|
32
37
|
const HANDSHAKE_MS = 5000;
|
|
33
38
|
/** The longest stop a client may ask for (one day). */
|
|
@@ -43,17 +48,30 @@ function matches(given, expected) {
|
|
|
43
48
|
const b = Buffer.from(expected);
|
|
44
49
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
45
50
|
}
|
|
46
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Newline-delimited JSON over a socket: read one message at a time, write messages.
|
|
53
|
+
*
|
|
54
|
+
* ISSUE-0006 A05: the limits hold whatever the TCP chunking. Bytes are split on `\n` as they
|
|
55
|
+
* come; every line (complete or still open) is held to MAX_LINE bytes before it is decoded or
|
|
56
|
+
* parsed, and a line is taken only while the peer may send one: `expected` messages at first,
|
|
57
|
+
* then the `replies` each `send` asks for. Anything more is a protocol violation: the queue is
|
|
58
|
+
* dropped (nothing queued is acted on), next() rejects and the connection is destroyed.
|
|
59
|
+
*/
|
|
47
60
|
class LineSocket {
|
|
48
61
|
socket;
|
|
49
|
-
|
|
62
|
+
/** The bytes of the line still open (no newline yet). */
|
|
63
|
+
#pending = [];
|
|
64
|
+
#pendingBytes = 0;
|
|
50
65
|
#queue = [];
|
|
66
|
+
#queuedBytes = 0;
|
|
67
|
+
/** How many more messages the peer may send at this step of the protocol. */
|
|
68
|
+
#allowance;
|
|
51
69
|
#waiting;
|
|
52
70
|
#failure;
|
|
53
71
|
closed;
|
|
54
|
-
constructor(socket) {
|
|
72
|
+
constructor(socket, expected) {
|
|
55
73
|
this.socket = socket;
|
|
56
|
-
|
|
74
|
+
this.#allowance = expected;
|
|
57
75
|
socket.on('data', (chunk) => this.#data(chunk));
|
|
58
76
|
// Reported to whoever reads next (next() rejects); the socket closes after an error.
|
|
59
77
|
socket.on('error', (error) => this.#fail(error));
|
|
@@ -61,28 +79,64 @@ class LineSocket {
|
|
|
61
79
|
void this.closed.then(() => this.#fail(new Error('connection closed')));
|
|
62
80
|
}
|
|
63
81
|
#data(chunk) {
|
|
64
|
-
this.#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
if (this.#failure)
|
|
83
|
+
return;
|
|
84
|
+
let start = 0;
|
|
85
|
+
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
|
|
86
|
+
const piece = chunk.subarray(start, end);
|
|
87
|
+
start = end + 1;
|
|
88
|
+
const bytes = this.#pendingBytes + piece.length;
|
|
89
|
+
if (!this.#acceptable(bytes))
|
|
90
|
+
return;
|
|
91
|
+
const line = this.#pendingBytes ? Buffer.concat([...this.#pending, piece]) : piece;
|
|
92
|
+
this.#pending = [];
|
|
93
|
+
this.#pendingBytes = 0;
|
|
94
|
+
if (this.#queue.length >= MAX_QUEUED || this.#queuedBytes + bytes > MAX_QUEUED_BYTES) {
|
|
95
|
+
this.#violation('too many messages waiting');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
68
98
|
let message;
|
|
69
99
|
try {
|
|
70
|
-
message = JSON.parse(line);
|
|
100
|
+
message = JSON.parse(UTF8.decode(line));
|
|
71
101
|
}
|
|
72
102
|
catch (error) {
|
|
73
|
-
this.#
|
|
74
|
-
this.socket.destroy();
|
|
103
|
+
this.#violation(`not a protocol message: ${describeError(error).split('\n')[0]}`);
|
|
75
104
|
return;
|
|
76
105
|
}
|
|
77
|
-
this.#
|
|
106
|
+
this.#allowance--;
|
|
107
|
+
this.#queue.push({ message, bytes });
|
|
108
|
+
this.#queuedBytes += bytes;
|
|
78
109
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
|
|
110
|
+
const rest = chunk.subarray(start);
|
|
111
|
+
if (rest.length) {
|
|
112
|
+
if (!this.#acceptable(this.#pendingBytes + rest.length))
|
|
113
|
+
return;
|
|
114
|
+
this.#pending.push(rest);
|
|
115
|
+
this.#pendingBytes += rest.length;
|
|
83
116
|
}
|
|
84
117
|
this.#deliver();
|
|
85
118
|
}
|
|
119
|
+
/** A line of `bytes` so far may go on: a message is expected and the line is short enough. */
|
|
120
|
+
#acceptable(bytes) {
|
|
121
|
+
if (this.#allowance <= 0) {
|
|
122
|
+
this.#violation('a message the protocol does not expect at this step');
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (bytes > MAX_LINE) {
|
|
126
|
+
this.#violation(`a line longer than ${MAX_LINE} bytes`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
#violation(reason) {
|
|
132
|
+
debug('Control connection broke the protocol', { reason });
|
|
133
|
+
this.#queue = [];
|
|
134
|
+
this.#queuedBytes = 0;
|
|
135
|
+
this.#pending = [];
|
|
136
|
+
this.#pendingBytes = 0;
|
|
137
|
+
this.#fail(new Error(reason));
|
|
138
|
+
this.socket.destroy();
|
|
139
|
+
}
|
|
86
140
|
#fail(error) {
|
|
87
141
|
this.#failure ??= error;
|
|
88
142
|
this.#deliver();
|
|
@@ -92,7 +146,13 @@ class LineSocket {
|
|
|
92
146
|
if (!waiting || (!this.#queue.length && !this.#failure))
|
|
93
147
|
return;
|
|
94
148
|
this.#waiting = undefined;
|
|
95
|
-
|
|
149
|
+
const next = this.#queue.shift();
|
|
150
|
+
if (next) {
|
|
151
|
+
this.#queuedBytes -= next.bytes;
|
|
152
|
+
waiting(next.message);
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
waiting(this.#failure);
|
|
96
156
|
}
|
|
97
157
|
/** The next message; rejects when the connection fails or closes first, or after `ms`. */
|
|
98
158
|
next(ms) {
|
|
@@ -113,7 +173,9 @@ class LineSocket {
|
|
|
113
173
|
this.#deliver();
|
|
114
174
|
});
|
|
115
175
|
}
|
|
116
|
-
send(
|
|
176
|
+
/** Sends `message`; the peer may then send `replies` messages (the protocol's next step). */
|
|
177
|
+
send(message, replies = 0) {
|
|
178
|
+
this.#allowance += replies;
|
|
117
179
|
if (!this.socket.destroyed)
|
|
118
180
|
this.socket.write(`${JSON.stringify(message)}\n`);
|
|
119
181
|
}
|
|
@@ -178,7 +240,8 @@ export class ControlServer {
|
|
|
178
240
|
#connection(socket) {
|
|
179
241
|
this.#sockets.add(socket);
|
|
180
242
|
socket.once('close', () => this.#sockets.delete(socket));
|
|
181
|
-
|
|
243
|
+
// The client speaks only after the hello (which allows its proof).
|
|
244
|
+
const lines = new LineSocket(socket, 0);
|
|
182
245
|
void this.#serve(lines).catch((error) => {
|
|
183
246
|
// A client that broke the protocol or went silent: nothing was done for it.
|
|
184
247
|
reportError('CONTROL_REQUEST_FAILED', 'A control request failed', error, 5000);
|
|
@@ -187,7 +250,7 @@ export class ControlServer {
|
|
|
187
250
|
}
|
|
188
251
|
async #serve(lines) {
|
|
189
252
|
const challenge = randomBytes(24).toString('base64url');
|
|
190
|
-
lines.send({ hello: HELLO, version: VERSION, challenge });
|
|
253
|
+
lines.send({ hello: HELLO, version: VERSION, challenge }, 1);
|
|
191
254
|
const hello = await lines.next(HANDSHAKE_MS);
|
|
192
255
|
if (!matches(hello.answer, controlAnswer(this.token, 'client', challenge))) {
|
|
193
256
|
debug('Control client refused: wrong instance proof');
|
|
@@ -208,7 +271,7 @@ export class ControlServer {
|
|
|
208
271
|
answer: controlAnswer(this.token, 'writer', hello.challenge),
|
|
209
272
|
writer: process.pid,
|
|
210
273
|
app: { pid: app.pid, running: app.running() },
|
|
211
|
-
});
|
|
274
|
+
}, 1);
|
|
212
275
|
const request = await lines.next(HANDSHAKE_MS);
|
|
213
276
|
if (request.op === 'status') {
|
|
214
277
|
lines.send({ ok: true, app: { pid: app.pid, running: app.running() } });
|
|
@@ -266,7 +329,8 @@ export async function askLogWriter(port, token, request,
|
|
|
266
329
|
stepMs = 3000) {
|
|
267
330
|
const path = `${CONTROL_HOST}:${port}`;
|
|
268
331
|
const socket = createConnection({ host: CONTROL_HOST, port });
|
|
269
|
-
|
|
332
|
+
// The writer greets first: one message expected.
|
|
333
|
+
const lines = new LineSocket(socket, 1);
|
|
270
334
|
const unreached = (reason) => {
|
|
271
335
|
socket.destroy();
|
|
272
336
|
debug('Log writer not reached', { path, reason });
|
|
@@ -291,7 +355,7 @@ stepMs = 3000) {
|
|
|
291
355
|
if (hello.hello !== HELLO || typeof hello.challenge !== 'string')
|
|
292
356
|
return unreached(`${path} is not a dokebi log writer`);
|
|
293
357
|
const challenge = randomBytes(24).toString('base64url');
|
|
294
|
-
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) });
|
|
358
|
+
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) }, 1);
|
|
295
359
|
const proof = await lines.next(stepMs);
|
|
296
360
|
if (proof.code === 'NOT_READY')
|
|
297
361
|
return unreached('the log writer has not started the app yet');
|
|
@@ -303,7 +367,7 @@ stepMs = 3000) {
|
|
|
303
367
|
if (typeof app?.pid !== 'number' || typeof proof.writer !== 'number')
|
|
304
368
|
return unreached('the log writer sent an unexpected answer');
|
|
305
369
|
debug('Log writer proved the instance token', { path, writer: proof.writer, op: request.op });
|
|
306
|
-
lines.send(request);
|
|
370
|
+
lines.send(request, 1);
|
|
307
371
|
const replyMs = request.op === 'stop' ? request.timeoutMs + (request.force ? 5000 : 0) + stepMs : stepMs;
|
|
308
372
|
const reply = await lines.next(replyMs);
|
|
309
373
|
return {
|
package/dist/npm-client.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface NpmRun {
|
|
|
17
17
|
/** The last NPM_OUTPUT_TAIL_BYTES bytes of each stream, redacted. */
|
|
18
18
|
readonly stderr: string;
|
|
19
19
|
readonly stdout: string;
|
|
20
|
+
/** npm's error code, summary, log file and report file from the whole stderr. */
|
|
21
|
+
readonly notes: ReturnType<NpmFailureNotes['details']>;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Runs npm in `directory` (the npm that started this CLI when there is one, else `npm` on PATH)
|
|
@@ -54,6 +56,23 @@ export declare class OutputTail {
|
|
|
54
56
|
push(chunk: Buffer): void;
|
|
55
57
|
text(): string;
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* What npm says about a failure, read from its whole stderr (not only the bounded tail, which a
|
|
61
|
+
* long ERESOLVE explanation fills): the error code (`npm error code ERESOLVE`), the line after it
|
|
62
|
+
* (the summary), the debug log (`A complete log of this run can be found in: …`) and the report
|
|
63
|
+
* file (`For a full report see:`). Values are redacted; lines are bounded.
|
|
64
|
+
*/
|
|
65
|
+
export declare class NpmFailureNotes {
|
|
66
|
+
#private;
|
|
67
|
+
push(chunk: Buffer): void;
|
|
68
|
+
/** The notes found so far, as NPM_FAILED details (absent ones are left out). */
|
|
69
|
+
details(): {
|
|
70
|
+
npmCode?: string;
|
|
71
|
+
npmSummary?: string;
|
|
72
|
+
logFile?: string;
|
|
73
|
+
reportFile?: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
57
76
|
/**
|
|
58
77
|
* Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
|
|
59
78
|
* `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
|