@dokebi/cli 0.2.2 → 0.2.4
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 +25 -5
- package/README.md +3 -1
- package/dist/bin.js +110 -1
- package/dist/configuration-scaffold.js +3 -0
- package/dist/configuration.d.ts +2 -2
- package/dist/configuration.js +11 -2
- package/dist/dev-guard.d.ts +1 -0
- package/dist/dev-guard.js +9 -0
- package/dist/diagnostics.d.ts +21 -0
- package/dist/diagnostics.js +87 -0
- package/dist/install-module.js +13 -2
- package/dist/log-writer.d.ts +1 -0
- package/dist/log-writer.js +133 -0
- package/dist/logging-config.d.ts +14 -0
- package/dist/logging-config.js +48 -0
- package/dist/npm-client.js +9 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/project-inspector.js +9 -0
- package/dist/project.js +9 -5
- package/dist/rotating-log.d.ts +27 -0
- package/dist/rotating-log.js +166 -0
- package/dist/server.d.ts +99 -0
- package/dist/server.js +857 -0
- package/dist/transaction.js +10 -1
- package/dist/vite-watch.d.ts +1 -0
- package/dist/vite-watch.js +23 -0
- package/dist/workspace.js +6 -2
- package/package.json +3 -3
package/AI.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# AI 작업 안내 — @dokebi/cli 0.2.
|
|
1
|
+
# AI 작업 안내 — @dokebi/cli 0.2.4 (`dokebi`)
|
|
2
2
|
|
|
3
3
|
도깨비 프로젝트를 만들고 플러그인·앱·모듈을 설치·제거·업그레이드하는 명령이다. 프로젝트가 가진 「CLI 소유 파일」(생성 등록 코드, 상태, 견본 설정)을 트랜잭션으로 고친다. AI는 이 파일들을 손으로 편집하지 말고 반드시 명령으로 바꾼다. 명령 세부 정의는 [docs/cli/README.md](../../docs/cli/README.md), 설치 생명주기는 [docs/cli/installation.md](../../docs/cli/installation.md), 자동화 계약은 [docs/cli/automation.md](../../docs/cli/automation.md)다.
|
|
4
4
|
|
|
5
5
|
- Node.js >=24.0.0. 프로젝트 루트에서 그 프로젝트에 설치된 CLI(`npx dokebi`)를 쓴다. 먼저 `npx dokebi --version --json`으로 판을 확인한다.
|
|
6
|
-
-
|
|
6
|
+
- CLI·core·config·플러그인은 각자 판이다(D-0046). CLI는 core·config를 `^0.2.2` 범위로 쓰고, `init`은 그 범위의 최신 core를 설치한다.
|
|
7
7
|
|
|
8
8
|
## 1. 명령
|
|
9
9
|
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
| `dokebi app add <이름> [--domain <host>]... [--template <이름>] [--inertia [--ssr]]` | `src/apps/<이름>` 앱(D-0030). `--inertia`는 Vue 골격과 Vite 빌드 준비, `--ssr`은 서버 렌더링 준비(D-0039) |
|
|
19
19
|
| `dokebi app remove <이름>` · `app link <앱> <도메인> [--template]` · `app unlink <도메인>` · `app list` | 앱·도메인 관리 |
|
|
20
20
|
| `dokebi module add <이름> [--no-views]` · `module remove` · `module list` | `src/shared/modules/<이름>` 기능 모듈(D-0032) |
|
|
21
|
+
| `dokebi dev` | 개발 실행(앞에서). `src/`·`dokebi.yaml`·`.env`가 바뀌면 drain 뒤 다시 띄움. Vite 설정이 있고 template-inertia ≥0.1.2면 `DOKEBI_VITE_DEV=1`로 Vite 개발 서버를 앱 안에 띄워 **HMR**(빌드 불필요, `.vue`·`views/` 변경은 재시작 없음, D-0045). 옛 template-inertia면 감시 빌드 + 재시작. 띄운 앱에 `DOKEBI_DEV=1`을 넣어 cluster 앱은 워커 1개로 돈다(`DOKEBI_DEV_WORKERS`, D-0044·D-0045) |
|
|
22
|
+
| `dokebi start [--foreground] [--wait-ms <ms>] [--log-max-mb <MB>] [--log-files <n>]` · `stop [--timeout-ms <ms>] [--force]` · `status` · `logs [-n <줄>] [-f]` | 백그라운드 실행·종료·상태·로그와 로그 회전(아래 §2.1, 다음 판, D-0044·D-0053) |
|
|
21
23
|
|
|
22
24
|
모든 명령에 `--json`(기계용 단일 문서)과 변경 명령에 `--dry-run`(계획만)이 있다.
|
|
23
25
|
|
|
@@ -30,6 +32,18 @@
|
|
|
30
32
|
5. 결과의 `configRequired`(채워야 할 필수 값), `nextSteps`(예: `npm run build`), `warnings`를 처리한다.
|
|
31
33
|
6. `npx dokebi doctor --json`과 앱 테스트·`npm start`로 확인한다.
|
|
32
34
|
|
|
35
|
+
## 2.1 서버 실행(D-0044, 다음 판)
|
|
36
|
+
|
|
37
|
+
- 한 프로젝트 = 프로세스 하나(`src/main.ts`, 여러 앱도 같은 프로세스). 실행 파일은 `.dokebi/run/`(`app.pid`, `app.log`·`app.log.1`…, `log-writer.log`, 안의 `.gitignore`가 `*`라 Git에 안 들어간다).
|
|
38
|
+
- **로그 회전(D-0053)**: `start`는 분리된 로그 기록 프로세스(log writer)를 띄우고, 기록기가 앱을 stdout·stderr 파이프로 띄워 줄 단위로 `app.log`에 쓴다. 다음 줄이 `--log-max-mb`(기본 50, 1–10240)를 넘기면 줄 경계에서 `app.log.1`로 밀고 `--log-files`(기본 5, 1–100)개만 남긴다. 빠르게 써도 줄을 잃지 않는다(디스크가 느리면 앱 쓰기가 기다림). 범위 밖은 `USAGE`, `--foreground`와 함께 쓰면 `USAGE`. dokebi.yaml 설정은 없다(매번 같은 값은 package.json 스크립트에). 기록기는 SIGTERM을 무시하고 앱이 끝나면(crash 포함) `--- dokebi: app exited (…) ---` 한 줄을 쓰고 끝난다. 기록기 자신의 오류는 `.dokebi/run/log-writer.log`에 JSON 한 줄(`level: error`·`code`·전체 `error`): `LOG_OPEN_FAILED`, `LOG_WRITE_FAILED`, `LOG_ROTATE_FAILED`, `LOG_LINES_DROPPED`, `APP_SPAWN_FAILED`, `APP_OUTPUT_FAILED`.
|
|
39
|
+
- `start`: inspect와 같은 검사(error 진단이 있으면 `START_CHECK_FAILED`) 뒤 `NODE_ENV=production`(이미 정해져 있으면 그대로)으로 백그라운드 실행. 로그에 `Dokebi application started`(생성된 main이 쓰는 줄)가 지나가면 `ready: true`. 결과에 `logWriter`(pid)·`logMaxBytes`·`logFiles`. `--wait-ms`(기본 10000) 안에 그 줄이 없으면 살아 있어도 `ready: false`와 경고. 그 전에 끝나면 `START_FAILED`(details.tail에 로그 끝 30줄, details.logWriterLog에 기록기 진단 끝). 이미 실행 중이면 `ALREADY_RUNNING`, 다른 start가 진행 중이면 `START_IN_PROGRESS`.
|
|
40
|
+
- `stop`: **앱에** SIGTERM으로 drain(기록기는 앱 출력이 끝나면 따라 끝나고, 5초 안에 안 끝나면 `warnings`). `--timeout-ms`(기본 30000) 안에 안 끝나면 `STOP_TIMEOUT`(프로세스는 그대로), `--force`면 그때 SIGKILL. 실행 중이 아니면 `stopped: false`.
|
|
41
|
+
- `status`: 실행 중이면 pid·가동 초·RSS·`logWriter: { pid, running }`(running false면 앱 출력이 기록되지 않는 상태). 항상 `logFiles`(app.log와 회전 파일, 새것부터, `{ file, bytes }`), 기록기 진단이 있으면 `logWriterLog`. 멈춤이면 **종료 코드 3**. PID 파일만 남은 경우(밖에서 죽음·crash)는 `stale: true`로 알리고 파일을 지운다.
|
|
42
|
+
- PID는 재사용되므로 `ps`로 그 pid의 명령줄에 이 프로젝트의 `src/main.ts`가 있고 시작 시각이 맞을 때만 신호를 보낸다. 아니면 낡은 PID 파일로 본다.
|
|
43
|
+
- `logs`: 마지막 `-n`(기본 100)줄(모자라면 `app.log.1`·`.2`…에서 앞을 채움), `-f`는 Ctrl+C까지 따라 읽기(읽던 파일을 inode로 기억해, 회전되면 옛 파일 끝까지 읽고 그보다 새 회전 파일을 오래된 것부터 읽은 뒤 새 `app.log`로 넘어감 — 확인 주기 250ms 사이에 회전이 여러 번 일어나도 줄을 건너뛰지 않는다. 처음 `-n` 줄과 따라 읽기 사이에도 빈틈이 없다. 읽던 파일이 `--log-files` 밖으로 지워질 만큼 뒤처지면 `--- dokebi: logs -f fell behind … ---` 한 줄을 찍고 남은 가장 오래된 파일부터 잇는다). `logs`·`dev`·`start --foreground`는 `--json`이 없다.
|
|
44
|
+
- 하지 않는 것: crash 뒤 자동 재시작, 부팅 시 시작, 시간 기준 회전·압축. 운영 서버는 systemd·Docker에서 `dokebi start --foreground`(또는 `node src/main.ts`)를 쓰는 편이 낫다. Windows는 `start`(백그라운드)·`stop`이 `UNSUPPORTED_PLATFORM`.
|
|
45
|
+
- `src/main.ts`를 고쳐 시작 줄을 지우면 start가 준비 여부를 알 수 없다. 남겨 둔다.
|
|
46
|
+
|
|
33
47
|
## 3. JSON 출력
|
|
34
48
|
|
|
35
49
|
stdout에 문서 하나: `{ schemaVersion, cliVersion, command, ok, result?, error? }`. 실패는 종료 1.
|
|
@@ -70,8 +84,14 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
|
|
|
70
84
|
|
|
71
85
|
## 8. 주요 오류·진단 코드
|
|
72
86
|
|
|
73
|
-
- 명령: `USAGE`, `PROJECT_EXISTS`, `UNSUPPORTED_PROJECT`, `INVALID_SPEC`, `INVALID_PACKAGE`, `INVALID_MANIFEST`, `INCOMPATIBLE_VERSION`, `MISSING_DEPENDENCY`, `DEPENDENCY_CYCLE`, `DUPLICATE_PLUGIN`, `UNKNOWN_PLUGIN`, `REVERSE_DEPENDENCY`, `REQUIRED_PLUGIN`, `INVALID_PLUGIN_OPTIONS`, `INVALID_SET`, `FILE_CONFLICT`, `PATH_CONFLICT`, `PACKAGE_CONFLICT`, `NPM_FAILED`, `CONCURRENT_CHANGE`, `COMMIT_FAILED`, `UPGRADE_DOWNGRADE`, `UPGRADE_IDENTITY_CHANGED`, `UPGRADE_ENTRY_MOVED`, `UPGRADE_CONFIG_CONFLICT`, `APP_EXISTS`, `APP_IN_USE`, `APPS_PRESENT`, `APP_PLUGIN_MISSING`, `DOMAIN_IN_USE`, `UNKNOWN_APP`, `UNKNOWN_DOMAIN`, `MODULE_EXISTS`, `MODULE_PLUGIN_MISSING`, `UNKNOWN_MODULE`.
|
|
74
|
-
- 진단(inspect·doctor): `MIGRATION_REQUIRED`, `CONFIG_CONFLICT`, `INVALID_CORE`, `PACKAGE_VERSION_MISMATCH`, `MISSING_CORE_DEPENDENCY`, `MISSING_PACKAGE_DEPENDENCY`, `MODIFIED_REGISTRATION`, `MODIFIED_TEMPLATE`, `MANIFEST_CHANGED`, `INVALID_PLUGIN_OPTIONS`, `ENTRY_MISSING`, `LEGACY_LAYOUT`, `MISSING_REQUIRED_PLUGIN`, `STALE_REQUIREMENTS`, `PLUGIN_CHECK`, `PROJECT_LOCKED`, `RECOVERY_REQUIRED`, `UNSAFE_PATH
|
|
87
|
+
- 명령: `USAGE`, `PROJECT_EXISTS`, `UNSUPPORTED_PROJECT`, `INVALID_SPEC`, `INVALID_PACKAGE`, `INVALID_MANIFEST`, `INCOMPATIBLE_VERSION`, `MISSING_DEPENDENCY`, `DEPENDENCY_CYCLE`, `DUPLICATE_PLUGIN`, `UNKNOWN_PLUGIN`, `REVERSE_DEPENDENCY`, `REQUIRED_PLUGIN`, `INVALID_PLUGIN_OPTIONS`, `INVALID_SET`, `FILE_CONFLICT`, `PATH_CONFLICT`, `PACKAGE_CONFLICT`, `NPM_FAILED`, `CONCURRENT_CHANGE`, `COMMIT_FAILED`, `UPGRADE_DOWNGRADE`, `UPGRADE_IDENTITY_CHANGED`, `UPGRADE_ENTRY_MOVED`, `UPGRADE_CONFIG_CONFLICT`, `APP_EXISTS`, `APP_IN_USE`, `APPS_PRESENT`, `APP_PLUGIN_MISSING`, `DOMAIN_IN_USE`, `UNKNOWN_APP`, `UNKNOWN_DOMAIN`, `MODULE_EXISTS`, `MODULE_PLUGIN_MISSING`, `UNKNOWN_MODULE`, 서버 명령 `ENTRY_MISSING`, `START_CHECK_FAILED`, `ALREADY_RUNNING`, `START_IN_PROGRESS`, `START_FAILED`, `STOP_TIMEOUT`, `NO_LOG`, `UNSUPPORTED_PLATFORM`.
|
|
88
|
+
- 진단(inspect·doctor): `MIGRATION_REQUIRED`, `CONFIG_CONFLICT`, `INVALID_CORE`, `PACKAGE_VERSION_MISMATCH`, `MISSING_CORE_DEPENDENCY`, `MISSING_PACKAGE_DEPENDENCY`, `MODIFIED_REGISTRATION`, `MODIFIED_TEMPLATE`, `MANIFEST_CHANGED`, `INVALID_PLUGIN_OPTIONS`, `ENTRY_MISSING`, `LEGACY_LAYOUT`, `MISSING_REQUIRED_PLUGIN`, `STALE_REQUIREMENTS`, `PLUGIN_CHECK`, `PROJECT_LOCKED`, `RECOVERY_REQUIRED`, `UNSAFE_PATH`, `INVALID_LOGGING_CONFIG`(error: `options.logging`의 수준 값·키), `LOGGING_PLUGIN_UNKNOWN`(warning: 설치되지 않은 플러그인의 수준).
|
|
89
|
+
|
|
90
|
+
### 로그(D-0047)
|
|
91
|
+
|
|
92
|
+
- `init`이 쓰는 dokebi.yaml·dokebi.example.yaml에는 `options.logging: { level: info }`와 주석으로 된 `plugins` 예가 있다. `options.logging`은 core가 읽는 로그 설정이고 플러그인 옵션이 아니다. 기존 프로젝트는 자동으로 바꾸지 않는다(필요하면 직접 추가).
|
|
93
|
+
- 실행 중 수준은 `DOKEBI_LOG_LEVEL`이 yaml보다 우선한다(`DOKEBI_LOG_LEVEL=debug npx dokebi dev`).
|
|
94
|
+
- CLI 자신도 `DOKEBI_LOG_LEVEL=debug`이면 npm 실행·설치 모듈·트랜잭션 되돌림·start/stop의 결정을 stderr에 JSON 한 줄씩 남긴다. 복구하고 넘어가는 오류(읽을 수 없는 pid 파일·워크스페이스 package.json 등)는 수준과 상관없이 stderr에 `level: error` 한 줄(`code`·`error`)로 남긴다. stdout(`--json` 봉투)은 바뀌지 않는다.
|
|
75
95
|
- 경고: `BUILD_SCRIPT_KEPT`, `INERTIA_PLUGIN_MISSING`, `SECRET_ON_COMMAND_LINE`, `EXAMPLE_MISSING`, `EXAMPLE_INVALID`.
|
|
76
96
|
|
|
77
97
|
## 9. 주의 사항
|
|
@@ -86,4 +106,4 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
|
|
|
86
106
|
## 10. 근거
|
|
87
107
|
|
|
88
108
|
- 예제: `examples/06-cli`, `26-scaffold`(실제 CLI로 같은 프로젝트를 반복 생성·빌드·실행), `25-playground`(`tools/regenerate.ts`가 CLI 기록 전체를 재생).
|
|
89
|
-
- 결정: D-0009·D-0011·D-0012(설정), D-0028(manifest schema 2), D-0030~D-0032(앱·구조·모듈), D-0035(워크스페이스), D-0037(manifest 판), D-0039(SSR).
|
|
109
|
+
- 결정: D-0009·D-0011·D-0012(설정), D-0028(manifest schema 2), D-0030~D-0032(앱·구조·모듈), D-0035(워크스페이스), D-0037(manifest 판), D-0039(SSR), D-0044(서버 명령), D-0053(로그 회전).
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @dokebi/cli
|
|
2
2
|
|
|
3
|
-
Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.
|
|
3
|
+
Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.4이다. 명령은 dokebi다. 0.1.1과 달라진 점(YAML 설정, migrate, 멀티 앱·모듈 명령)은 CHANGELOG에 있다. Node.js >=24.0.0, ESM을 지원하며 기본 CI는 Node 24다.
|
|
4
4
|
|
|
5
5
|
## 프로젝트와 명령
|
|
6
6
|
|
|
@@ -12,6 +12,8 @@ Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.2이다. 명령은
|
|
|
12
12
|
- dokebi module add|remove|list: `src/shared/modules/<모듈>` 기능 모듈(@dokebi/module).
|
|
13
13
|
- dokebi migrate [--dry-run] [--json]: 기존 JSON 프로젝트를 YAML 계약으로 명시적으로 이전한다.
|
|
14
14
|
- dokebi inspect --json, dokebi doctor --json: 설치 버전·매니페스트·스키마·설정 문제를 읽기 전용으로 확인한다.
|
|
15
|
+
- dokebi dev: 코드·설정이 바뀌면 다시 띄우는 개발 실행(Vite 설정이 있으면 `vite build --watch` 함께).
|
|
16
|
+
- dokebi start [--foreground] [--log-max-mb MB] [--log-files n] · stop [--force] · status · logs [-n 줄] [-f]: 백그라운드 실행·종료·상태·로그. `.dokebi/run/app.pid`·`app.log`를 쓴다. 로그는 분리된 기록 프로세스가 파이프로 받아 줄을 잃지 않고 크기로 회전한다(기본 50MB, `app.log.1`…`app.log.5`, [D-0053](../../docs/decisions/D-0053-cli-log-rotation.md)). `logs -n`은 회전된 파일까지 읽고 `-f`는 회전을 넘어 따라간다(확인 주기 사이에 여러 번 회전해도 inode로 가운데 파일까지 읽어 줄을 건너뛰지 않는다). 자동 재시작은 하지 않으므로 운영은 systemd·Docker에서 `start --foreground`를 권한다([D-0044](../../docs/decisions/D-0044-cli-server-commands.md), 다음 판).
|
|
15
17
|
|
|
16
18
|
생성 앱의 npm start를 실행한다. 명령은 프로젝트 루트(또는 npm 워크스페이스 구성원 폴더)에서 설치된 CLI로 호출한다. 모든 명령에 `--json`이 있다. dry-run도 패키지 해석을 위해 다운로드·임시 설치할 수 있지만 관리 파일과 node_modules를 적용하지 않는다.
|
|
17
19
|
|
package/dist/bin.js
CHANGED
|
@@ -4,7 +4,8 @@ import { CliError } from './errors.js';
|
|
|
4
4
|
import { CLI_VERSION, envelope, failure } from './output.js';
|
|
5
5
|
import { Project } from './project.js';
|
|
6
6
|
import { ProjectInspector } from './project-inspector.js';
|
|
7
|
-
|
|
7
|
+
import * as server from './server.js';
|
|
8
|
+
const HELP = 'dokebi init <directory> [--core <package|local.tgz>] [--json]\ndokebi add <package|local.tgz> [--set <path>=<value>]... [--dry-run] [--json]\ndokebi upgrade <plugin-id> [--to <version|package|local.tgz>] [--dry-run] [--json]\ndokebi remove <plugin-id> [--dry-run] [--json]\ndokebi migrate [--dry-run] [--json]\ndokebi inspect [--json]\ndokebi doctor [--json]\ndokebi app add <name> [--domain <host>]... [--template <name>] [--inertia [--ssr]] [--dry-run] [--json]\ndokebi app remove <name> [--dry-run] [--json]\ndokebi app link <name> <domain> [--template <name>] [--dry-run] [--json]\ndokebi app unlink <domain> [--dry-run] [--json]\ndokebi app list [--json]\ndokebi module add <name> [--no-views] [--dry-run] [--json]\ndokebi module remove <name> [--dry-run] [--json]\ndokebi module list [--json]\ndokebi dev\ndokebi start [--foreground] [--wait-ms <ms>] [--log-max-mb <MB>] [--log-files <n>] [--json]\ndokebi stop [--timeout-ms <ms>] [--force] [--json]\ndokebi status [--json]\ndokebi logs [-n <lines>] [-f]\n';
|
|
8
9
|
const raw = process.argv.slice(2);
|
|
9
10
|
const machine = raw.includes('--json');
|
|
10
11
|
const args = raw.filter((arg) => arg !== '--json');
|
|
@@ -72,6 +73,10 @@ async function main() {
|
|
|
72
73
|
await app(args);
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
76
|
+
if (['dev', 'start', 'stop', 'status', 'logs'].includes(command)) {
|
|
77
|
+
await serverCommand(args);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
75
80
|
if (command === 'module') {
|
|
76
81
|
await moduleCommand(args);
|
|
77
82
|
return;
|
|
@@ -111,6 +116,107 @@ async function main() {
|
|
|
111
116
|
process.stderr.write(`warning: ${warning.path} looks secret; command-line values can remain in shell history\n`);
|
|
112
117
|
process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
|
|
113
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads `--name <number>` style flags of the server commands; anything else is a usage error.
|
|
121
|
+
* `numbers` maps each flag to its range (default 1 to 3600000).
|
|
122
|
+
*/
|
|
123
|
+
function serverFlags(input, numbers, switches) {
|
|
124
|
+
const result = { numbers: new Map(), switches: new Set() };
|
|
125
|
+
for (let index = 0; index < input.length; index++) {
|
|
126
|
+
const arg = input[index];
|
|
127
|
+
if (switches.includes(arg) && !result.switches.has(arg))
|
|
128
|
+
result.switches.add(arg);
|
|
129
|
+
else if (Object.hasOwn(numbers, arg) && !result.numbers.has(arg)) {
|
|
130
|
+
const [min, max] = numbers[arg] ?? [1, 3_600_000];
|
|
131
|
+
const text = input[++index];
|
|
132
|
+
const value = Number(text);
|
|
133
|
+
if (text === undefined || !Number.isSafeInteger(value) || value < min || value > max)
|
|
134
|
+
throw new CliError('USAGE', `${arg} must be an integer from ${min} to ${max}\n${HELP}`);
|
|
135
|
+
result.numbers.set(arg, value);
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
throw new CliError('USAGE', HELP);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
/** `dokebi dev|start|stop|status|logs` (D-0044): the project's process. */
|
|
143
|
+
async function serverCommand(input) {
|
|
144
|
+
const root = process.cwd();
|
|
145
|
+
const print = (result, text) => process.stdout.write(machine ? JSON.stringify(envelope(command, result)) + '\n' : text + '\n');
|
|
146
|
+
if (command === 'dev') {
|
|
147
|
+
if (input.length || machine)
|
|
148
|
+
throw new CliError('USAGE', HELP);
|
|
149
|
+
process.exitCode = await server.dev(root, (line) => process.stderr.write(`[dokebi dev] ${line}\n`));
|
|
150
|
+
}
|
|
151
|
+
else if (command === 'start') {
|
|
152
|
+
const { maxMb, files } = server.LOG_LIMITS;
|
|
153
|
+
const options = serverFlags(input, {
|
|
154
|
+
'--wait-ms': undefined,
|
|
155
|
+
'--log-max-mb': [maxMb.min, maxMb.max],
|
|
156
|
+
'--log-files': [files.min, files.max],
|
|
157
|
+
}, ['--foreground']);
|
|
158
|
+
if (options.switches.has('--foreground')) {
|
|
159
|
+
// The foreground app writes to the terminal; rotation belongs to systemd/Docker there.
|
|
160
|
+
if (machine || options.numbers.size)
|
|
161
|
+
throw new CliError('USAGE', HELP);
|
|
162
|
+
process.exitCode = await server.foreground(root);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const megabytes = options.numbers.get('--log-max-mb');
|
|
166
|
+
const logFiles = options.numbers.get('--log-files');
|
|
167
|
+
const waitMs = options.numbers.get('--wait-ms');
|
|
168
|
+
const result = await server.start(root, {
|
|
169
|
+
...(waitMs !== undefined ? { waitMs } : {}),
|
|
170
|
+
...(megabytes !== undefined ? { logMaxBytes: megabytes * 1024 * 1024 } : {}),
|
|
171
|
+
...(logFiles !== undefined ? { logFiles } : {}),
|
|
172
|
+
});
|
|
173
|
+
if (!machine)
|
|
174
|
+
for (const warning of result.warnings)
|
|
175
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
176
|
+
print(result, `started pid ${result.pid}${result.ready ? ` (ready in ${result.ms} ms)` : ''}\nlog: ${result.log} (rotated at ${Math.round(result.logMaxBytes / 1048576)} MB, ${result.logFiles} older files kept)`);
|
|
177
|
+
}
|
|
178
|
+
else if (command === 'stop') {
|
|
179
|
+
const options = serverFlags(input, { '--timeout-ms': undefined }, ['--force']);
|
|
180
|
+
const result = await server.stop(root, options.numbers.get('--timeout-ms'), options.switches.has('--force'));
|
|
181
|
+
if (!machine)
|
|
182
|
+
for (const warning of result.warnings ?? [])
|
|
183
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
184
|
+
print(result, result.stopped
|
|
185
|
+
? `stopped pid ${result.pid} in ${result.ms} ms${result.forced ? ' (killed)' : ''}`
|
|
186
|
+
: result.stale
|
|
187
|
+
? `not running (pid ${result.pid} had already exited; removed the pid file)`
|
|
188
|
+
: 'not running');
|
|
189
|
+
}
|
|
190
|
+
else if (command === 'status') {
|
|
191
|
+
if (input.length)
|
|
192
|
+
throw new CliError('USAGE', HELP);
|
|
193
|
+
const result = await server.status(root);
|
|
194
|
+
const files = result.logFiles
|
|
195
|
+
.map((file) => `\n ${file.file} (${(file.bytes / 1048576).toFixed(1)} MB)`)
|
|
196
|
+
.join('');
|
|
197
|
+
const writer = result.logWriter
|
|
198
|
+
? `\nlog writer: pid ${result.logWriter.pid}${result.logWriter.running ? '' : ' (not running: output is not being logged)'}`
|
|
199
|
+
: '';
|
|
200
|
+
const diagnostics = result.logWriterLog
|
|
201
|
+
? `\nlog writer diagnostics: ${result.logWriterLog}`
|
|
202
|
+
: '';
|
|
203
|
+
print(result, (result.running
|
|
204
|
+
? `running pid ${result.pid} · up ${result.uptimeSec} s · ${result.rssMb} MB${writer}`
|
|
205
|
+
: result.stale
|
|
206
|
+
? `stopped: pid ${result.pid} exited without dokebi stop (crashed or killed); see dokebi logs`
|
|
207
|
+
: 'stopped') +
|
|
208
|
+
(files ? `\nlogs:${files}` : '') +
|
|
209
|
+
diagnostics);
|
|
210
|
+
if (!result.running)
|
|
211
|
+
process.exitCode = 3;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
if (machine)
|
|
215
|
+
throw new CliError('USAGE', HELP);
|
|
216
|
+
const options = serverFlags(input, { '-n': undefined }, ['-f', '--follow']);
|
|
217
|
+
await server.logs(root, options.numbers.get('-n'), options.switches.has('-f') || options.switches.has('--follow'));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
114
220
|
/** `dokebi app add|remove|list` (D-0030): apps of @dokebi/app and their domains. */
|
|
115
221
|
async function app(input) {
|
|
116
222
|
const action = input.shift();
|
|
@@ -250,6 +356,9 @@ function nextSteps(error) {
|
|
|
250
356
|
for (const item of details.errors)
|
|
251
357
|
if (typeof item?.missingProperty === 'string')
|
|
252
358
|
lines.add(`missing: options.${String(details.plugin)}${String(item.instancePath ?? '').replaceAll('/', '.')}.${item.missingProperty}`);
|
|
359
|
+
const tail = error.details.tail;
|
|
360
|
+
if (typeof tail === 'string' && tail)
|
|
361
|
+
lines.add(`last log lines:\n${tail.trimEnd()}`);
|
|
253
362
|
if (error.code === 'WORKSPACE_SYNC_FAILED')
|
|
254
363
|
lines.add('hint: run npm install at the workspace root');
|
|
255
364
|
if (Array.isArray(details.diagnostics))
|
|
@@ -89,6 +89,9 @@ export function scaffoldConfig(source, id, schema) {
|
|
|
89
89
|
}
|
|
90
90
|
if (!record(candidate))
|
|
91
91
|
continue;
|
|
92
|
+
// A deprecated option is still accepted (old files keep starting) but is not offered.
|
|
93
|
+
if (candidate.deprecated === true)
|
|
94
|
+
continue;
|
|
92
95
|
const keys = [...path, name];
|
|
93
96
|
const labels = [...names, name];
|
|
94
97
|
const label = labels.map((key) => JSON.stringify(key)).join('.');
|
package/dist/configuration.d.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { type ProjectConfig } from './project-state.js';
|
|
|
3
3
|
export declare const CONFIG_FILE = "dokebi.yaml";
|
|
4
4
|
export declare const LEGACY_CONFIG_FILE = "dokebi.json";
|
|
5
5
|
export declare const CONFIG_BACKUP = ".dokebi/dokebi.json.backup";
|
|
6
|
-
export declare const CONFIG_EXAMPLE = "# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions:
|
|
6
|
+
export declare const CONFIG_EXAMPLE = "# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.\n logging:\n level: info\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
|
|
7
7
|
/** The real dokebi.yaml written by init: same shape, its own header. */
|
|
8
|
-
export declare const CONFIG_TEMPLATE = "# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions:
|
|
8
|
+
export declare const CONFIG_TEMPLATE = "# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.\n logging:\n level: info\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
|
|
9
9
|
/**
|
|
10
10
|
* Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
|
|
11
11
|
* `options` are written in block style, so schema comments and values stay readable even when the
|
package/dist/configuration.js
CHANGED
|
@@ -5,9 +5,18 @@ import { readConfig } from './project-state.js';
|
|
|
5
5
|
export const CONFIG_FILE = 'dokebi.yaml';
|
|
6
6
|
export const LEGACY_CONFIG_FILE = 'dokebi.json';
|
|
7
7
|
export const CONFIG_BACKUP = '.dokebi/dokebi.json.backup';
|
|
8
|
-
|
|
8
|
+
/** The logging block of a new project (D-0047): core reads options.logging, not a plugin. */
|
|
9
|
+
const LOGGING_BLOCK = `options:
|
|
10
|
+
# Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.
|
|
11
|
+
logging:
|
|
12
|
+
level: info
|
|
13
|
+
# Per-plugin levels by plugin name, louder or quieter than level:
|
|
14
|
+
# plugins:
|
|
15
|
+
# dokebi-nats: debug
|
|
16
|
+
`;
|
|
17
|
+
export const CONFIG_EXAMPLE = `# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\n${LOGGING_BLOCK}`;
|
|
9
18
|
/** The real dokebi.yaml written by init: same shape, its own header. */
|
|
10
|
-
export const CONFIG_TEMPLATE =
|
|
19
|
+
export const CONFIG_TEMPLATE = `# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\n${LOGGING_BLOCK}`;
|
|
11
20
|
/**
|
|
12
21
|
* Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
|
|
13
22
|
* `options` are written in block style, so schema comments and values stay readable even when the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Preloaded (`node --import`) into the processes `dokebi dev` starts (D-0044). They get an IPC
|
|
2
|
+
// channel to dev; when dev dies, even by SIGKILL, the channel closes and the process drains
|
|
3
|
+
// (SIGTERM) instead of living on as an orphan that holds the port or keeps building.
|
|
4
|
+
if (process.send) {
|
|
5
|
+
process.once('disconnect', () => process.kill(process.pid, 'SIGTERM'));
|
|
6
|
+
// The channel must not keep a finished process alive.
|
|
7
|
+
process.channel?.unref();
|
|
8
|
+
}
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI diagnostics on stderr (D-0047). The CLI has no logger: errors it recovers from are printed
|
|
3
|
+
* as one line on stderr, and `DOKEBI_LOG_LEVEL=debug` adds debug lines. stdout stays reserved
|
|
4
|
+
* for command results (and the --json envelope).
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The full text of a caught value for a log field (D-0047): name, whole message and stack, then
|
|
8
|
+
* each `cause` the same way. Only credentials in URLs (`//user:password@`) are removed; a text
|
|
9
|
+
* over 16 KB is cut and marked. A hostile accessor still yields the value's type.
|
|
10
|
+
*/
|
|
11
|
+
export declare function describeError(error: unknown): string;
|
|
12
|
+
/** Errors that mean "the file or folder is not there", which callers treat as absence. */
|
|
13
|
+
export declare function isMissing(error: unknown): boolean;
|
|
14
|
+
export declare function debugEnabled(): boolean;
|
|
15
|
+
/** A debug line when DOKEBI_LOG_LEVEL=debug. Build costly fields only after debugEnabled(). */
|
|
16
|
+
export declare function debug(message: string, fields?: Record<string, unknown>): void;
|
|
17
|
+
/**
|
|
18
|
+
* Reports an error the CLI recovers from. Repeats of the same code within `intervalMs` are
|
|
19
|
+
* counted and the count is added to the next line (for polling loops).
|
|
20
|
+
*/
|
|
21
|
+
export declare function reportError(code: string, message: string, error: unknown, intervalMs?: number): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI diagnostics on stderr (D-0047). The CLI has no logger: errors it recovers from are printed
|
|
3
|
+
* as one line on stderr, and `DOKEBI_LOG_LEVEL=debug` adds debug lines. stdout stays reserved
|
|
4
|
+
* for command results (and the --json envelope).
|
|
5
|
+
*/
|
|
6
|
+
const reported = new Map();
|
|
7
|
+
const suppressed = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* The full text of a caught value for a log field (D-0047): name, whole message and stack, then
|
|
10
|
+
* each `cause` the same way. Only credentials in URLs (`//user:password@`) are removed; a text
|
|
11
|
+
* over 16 KB is cut and marked. A hostile accessor still yields the value's type.
|
|
12
|
+
*/
|
|
13
|
+
export function describeError(error) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
let current = error;
|
|
16
|
+
for (let depth = 0; current !== undefined && depth < 5; depth++) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
if (current instanceof Error)
|
|
20
|
+
text = current.stack ?? `${current.name}: ${current.message}`;
|
|
21
|
+
else if (typeof current === 'string')
|
|
22
|
+
text = current;
|
|
23
|
+
else
|
|
24
|
+
text = `NonError: ${JSON.stringify(current) ?? String(current)}`;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
text = `NonError: [${typeof current}]`; // a hostile accessor or a value JSON cannot hold
|
|
28
|
+
}
|
|
29
|
+
parts.push(depth ? `Caused by: ${text}` : text);
|
|
30
|
+
try {
|
|
31
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
current = undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const text = parts.join('\n').replace(/\/\/[^\s/@]*@/g, '//***@');
|
|
38
|
+
return text.length > 16_384
|
|
39
|
+
? `${text.slice(0, 16_384)}… [truncated ${text.length - 16_384} characters]`
|
|
40
|
+
: text;
|
|
41
|
+
}
|
|
42
|
+
/** Errors that mean "the file or folder is not there", which callers treat as absence. */
|
|
43
|
+
export function isMissing(error) {
|
|
44
|
+
const code = error?.code;
|
|
45
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
46
|
+
}
|
|
47
|
+
function write(record) {
|
|
48
|
+
try {
|
|
49
|
+
process.stderr.write(`${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// stderr itself is unavailable: there is no further channel to report on.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function debugEnabled() {
|
|
56
|
+
return process.env.DOKEBI_LOG_LEVEL?.trim().toLowerCase() === 'debug';
|
|
57
|
+
}
|
|
58
|
+
/** A debug line when DOKEBI_LOG_LEVEL=debug. Build costly fields only after debugEnabled(). */
|
|
59
|
+
export function debug(message, fields = {}) {
|
|
60
|
+
if (debugEnabled())
|
|
61
|
+
write({ level: 'debug', message, ...fields });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reports an error the CLI recovers from. Repeats of the same code within `intervalMs` are
|
|
65
|
+
* counted and the count is added to the next line (for polling loops).
|
|
66
|
+
*/
|
|
67
|
+
export function reportError(code, message, error, intervalMs = 0) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const key = `${code}\0${message}`;
|
|
70
|
+
if (intervalMs > 0) {
|
|
71
|
+
const last = reported.get(key);
|
|
72
|
+
if (last !== undefined && now - last < intervalMs) {
|
|
73
|
+
suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
reported.set(key, now);
|
|
78
|
+
const repeats = suppressed.get(key);
|
|
79
|
+
suppressed.delete(key);
|
|
80
|
+
write({
|
|
81
|
+
level: 'error',
|
|
82
|
+
message,
|
|
83
|
+
code,
|
|
84
|
+
error: describeError(error),
|
|
85
|
+
...(repeats ? { repeated: repeats } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
package/dist/install-module.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { debug, debugEnabled, describeError } from './diagnostics.js';
|
|
5
6
|
export class InstallPlanningError extends Error {
|
|
6
7
|
code;
|
|
7
8
|
constructor(code) {
|
|
@@ -64,10 +65,20 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
64
65
|
if (size > MAX_BYTES)
|
|
65
66
|
stop('INSTALL_OUTPUT_LIMIT');
|
|
66
67
|
});
|
|
67
|
-
|
|
68
|
-
child.
|
|
68
|
+
// Both become the typed InstallPlanningError the caller receives; debug keeps the cause.
|
|
69
|
+
child.on('error', (error) => {
|
|
70
|
+
if (debugEnabled())
|
|
71
|
+
debug('Install module process failed', { phase, error: describeError(error) });
|
|
72
|
+
stop('INSTALL_PROCESS_FAILED');
|
|
73
|
+
});
|
|
74
|
+
child.stdin.on('error', (error) => {
|
|
75
|
+
if (debugEnabled())
|
|
76
|
+
debug('Install module input failed', { phase, error: describeError(error) });
|
|
77
|
+
stop('INSTALL_INPUT_FAILED');
|
|
78
|
+
});
|
|
69
79
|
child.on('close', (code) => {
|
|
70
80
|
clearTimeout(timer);
|
|
81
|
+
debug('Install module finished', { phase, exitCode: code, failure: failure?.code });
|
|
71
82
|
if (failure) {
|
|
72
83
|
reject(failure);
|
|
73
84
|
return;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The log writer of `dokebi start` (D-0053). It runs detached from the CLI, starts the app with
|
|
2
|
+
// its stdout and stderr on pipes, and writes whole lines to `.dokebi/run/app.log`, rotating it by
|
|
3
|
+
// size. Reading the pipes means a slow disk slows the app's writes (back pressure) instead of
|
|
4
|
+
// losing lines. It ignores SIGTERM/SIGINT/SIGHUP (dokebi stop signals the app, not the writer)
|
|
5
|
+
// and exits once the app has exited and its pipes are drained.
|
|
6
|
+
//
|
|
7
|
+
// Usage: log-writer <log file> <max bytes> <rotated files to keep> <entry>
|
|
8
|
+
// The CLI listens on the IPC channel until the start is decided: { spawned: pid }, { ready: true }
|
|
9
|
+
// when the start line passes, { exited: code, signal } and { failed: text }. The writer's own
|
|
10
|
+
// errors go to its stderr, which the CLI points at `.dokebi/run/log-writer.log`.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { debug, describeError, reportError } from './diagnostics.js';
|
|
13
|
+
import { LineBuffer, RotatingLog } from './rotating-log.js';
|
|
14
|
+
/** Written by the generated `src/main.ts` once `app.start()` resolved. */
|
|
15
|
+
const STARTED = 'Dokebi application started';
|
|
16
|
+
/** How long the pipes may stay open after the app exited (a child it left holds them). */
|
|
17
|
+
const DRAIN_MS = 3000;
|
|
18
|
+
/** An unfinished line is written after this much quiet. */
|
|
19
|
+
const PARTIAL_MS = 200;
|
|
20
|
+
function send(message, then) {
|
|
21
|
+
if (!process.connected) {
|
|
22
|
+
then?.();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
process.send?.(message, undefined, {}, (error) => {
|
|
26
|
+
// The CLI went away between the check and the send: it no longer needs the message.
|
|
27
|
+
if (error)
|
|
28
|
+
debug('IPC message not delivered', { error: describeError(error) });
|
|
29
|
+
then?.();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const [file, maxArg, keepArg, entry] = process.argv.slice(2);
|
|
33
|
+
const maxBytes = Number(maxArg);
|
|
34
|
+
const keep = Number(keepArg);
|
|
35
|
+
if (!file ||
|
|
36
|
+
!entry ||
|
|
37
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
38
|
+
maxBytes < 1 ||
|
|
39
|
+
!Number.isSafeInteger(keep) ||
|
|
40
|
+
keep < 1) {
|
|
41
|
+
const text = `usage: log-writer <log file> <max bytes> <files> <entry> (got ${JSON.stringify(process.argv.slice(2))})`;
|
|
42
|
+
reportError('LOG_WRITER_USAGE', 'Invalid log writer arguments', new Error(text));
|
|
43
|
+
send({ failed: text }, () => process.exit(2));
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
run(file, maxBytes, keep, entry);
|
|
47
|
+
function run(file, maxBytes, keep, entry) {
|
|
48
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
|
|
49
|
+
process.on(signal, () => debug('Log writer ignores the signal; it exits when the app exits', { signal }));
|
|
50
|
+
const log = new RotatingLog(file, maxBytes, keep);
|
|
51
|
+
try {
|
|
52
|
+
log.open();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
reportError('LOG_OPEN_FAILED', 'Unable to open the app log', error);
|
|
56
|
+
send({ failed: describeError(error) }, () => process.exit(1));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
log.write(Buffer.from(`\n--- dokebi start ${new Date().toISOString()} ---\n`));
|
|
60
|
+
const app = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
61
|
+
let ready = false;
|
|
62
|
+
let exit;
|
|
63
|
+
let open = 2;
|
|
64
|
+
let finished = false;
|
|
65
|
+
let drainExpired = false;
|
|
66
|
+
const finish = () => {
|
|
67
|
+
if (finished || !exit || (open > 0 && !drainExpired))
|
|
68
|
+
return;
|
|
69
|
+
finished = true;
|
|
70
|
+
const how = exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`;
|
|
71
|
+
log.write(Buffer.from(`--- dokebi: app exited (${how}) ---\n`));
|
|
72
|
+
log.close();
|
|
73
|
+
debug('Log writer finished', { pid: app.pid, code: exit.code, signal: exit.signal });
|
|
74
|
+
send({ exited: exit.code, signal: exit.signal }, () => process.exit(0));
|
|
75
|
+
};
|
|
76
|
+
app.once('error', (error) => {
|
|
77
|
+
// spawn failed (the app never ran): there is no exit event to wait for.
|
|
78
|
+
reportError('APP_SPAWN_FAILED', 'Unable to start the app process', error);
|
|
79
|
+
log.write(Buffer.from(`--- dokebi: app could not start: ${error.message} ---\n`));
|
|
80
|
+
log.close();
|
|
81
|
+
send({ failed: describeError(error) }, () => process.exit(1));
|
|
82
|
+
});
|
|
83
|
+
if (app.pid !== undefined) {
|
|
84
|
+
debug('App process spawned by the log writer', { pid: app.pid, maxBytes, keep });
|
|
85
|
+
send({ spawned: app.pid });
|
|
86
|
+
}
|
|
87
|
+
app.once('exit', (code, signal) => {
|
|
88
|
+
exit = { code, signal };
|
|
89
|
+
debug('App process exited; draining its output', { pid: app.pid, code, signal });
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
if (finished)
|
|
92
|
+
return;
|
|
93
|
+
drainExpired = true;
|
|
94
|
+
// A process the app started still holds the pipes; stop reading so the writer can end.
|
|
95
|
+
debug('App output still open after the app exited; closing it', { drainMs: DRAIN_MS });
|
|
96
|
+
app.stdout?.destroy();
|
|
97
|
+
app.stderr?.destroy();
|
|
98
|
+
finish();
|
|
99
|
+
}, DRAIN_MS).unref();
|
|
100
|
+
finish();
|
|
101
|
+
});
|
|
102
|
+
const follow = (stream, name) => {
|
|
103
|
+
if (!stream)
|
|
104
|
+
return;
|
|
105
|
+
const lines = new LineBuffer();
|
|
106
|
+
let timer;
|
|
107
|
+
const write = (data) => {
|
|
108
|
+
if (!ready && data.includes(STARTED)) {
|
|
109
|
+
ready = true;
|
|
110
|
+
send({ ready: true });
|
|
111
|
+
}
|
|
112
|
+
log.write(data);
|
|
113
|
+
};
|
|
114
|
+
stream.on('data', (chunk) => {
|
|
115
|
+
const whole = lines.push(chunk);
|
|
116
|
+
if (whole)
|
|
117
|
+
write(whole);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
if (lines.pending)
|
|
120
|
+
timer = setTimeout(() => write(lines.take()), PARTIAL_MS);
|
|
121
|
+
});
|
|
122
|
+
stream.once('error', (error) => reportError('APP_OUTPUT_FAILED', `Unable to read the app ${name}`, error));
|
|
123
|
+
stream.once('close', () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
if (lines.pending)
|
|
126
|
+
write(lines.take());
|
|
127
|
+
open--;
|
|
128
|
+
finish();
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
follow(app.stdout, 'stdout');
|
|
132
|
+
follow(app.stderr, 'stderr');
|
|
133
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Levels accepted by core for `options.logging` (D-0047). */
|
|
2
|
+
export declare const LOG_LEVELS: readonly ['debug', 'info', 'warn', 'error', 'silent'];
|
|
3
|
+
export interface LoggingDiagnostic {
|
|
4
|
+
code: 'INVALID_LOGGING_CONFIG' | 'LOGGING_PLUGIN_UNKNOWN';
|
|
5
|
+
path: string;
|
|
6
|
+
plugin: string | null;
|
|
7
|
+
severity: 'error' | 'warning';
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Checks `options.logging` the way core does at startup (an invalid value is an error there),
|
|
12
|
+
* and warns about per-plugin levels for plugins this project does not have.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loggingDiagnostics(options: Record<string, unknown>, plugins: ReadonlySet<string>): LoggingDiagnostic[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CONFIG_FILE } from './configuration.js';
|
|
2
|
+
/** Levels accepted by core for `options.logging` (D-0047). */
|
|
3
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
|
|
4
|
+
const isLevel = (value) => typeof value === 'string' && LOG_LEVELS.includes(value);
|
|
5
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
/**
|
|
7
|
+
* Checks `options.logging` the way core does at startup (an invalid value is an error there),
|
|
8
|
+
* and warns about per-plugin levels for plugins this project does not have.
|
|
9
|
+
*/
|
|
10
|
+
export function loggingDiagnostics(options, plugins) {
|
|
11
|
+
const value = options.logging;
|
|
12
|
+
if (value === undefined || value === null)
|
|
13
|
+
return [];
|
|
14
|
+
const base = `${CONFIG_FILE}#options.logging`;
|
|
15
|
+
const invalid = (path) => ({
|
|
16
|
+
code: 'INVALID_LOGGING_CONFIG',
|
|
17
|
+
path,
|
|
18
|
+
plugin: null,
|
|
19
|
+
severity: 'error',
|
|
20
|
+
hint: `use one of: ${LOG_LEVELS.join(', ')}`,
|
|
21
|
+
});
|
|
22
|
+
if (!isObject(value) || Object.keys(value).some((key) => key !== 'level' && key !== 'plugins'))
|
|
23
|
+
return [{ ...invalid(base), hint: 'logging takes only level and plugins' }];
|
|
24
|
+
const result = [];
|
|
25
|
+
if (value.level !== undefined && !isLevel(value.level))
|
|
26
|
+
result.push(invalid(`${base}.level`));
|
|
27
|
+
if (value.plugins === undefined || value.plugins === null)
|
|
28
|
+
return result;
|
|
29
|
+
if (!isObject(value.plugins))
|
|
30
|
+
return [
|
|
31
|
+
...result,
|
|
32
|
+
{ ...invalid(`${base}.plugins`), hint: 'plugins maps plugin names to levels' },
|
|
33
|
+
];
|
|
34
|
+
for (const [name, level] of Object.entries(value.plugins)) {
|
|
35
|
+
const path = `${base}.plugins.${name}`;
|
|
36
|
+
if (!isLevel(level))
|
|
37
|
+
result.push({ ...invalid(path), plugin: name });
|
|
38
|
+
else if (!plugins.has(name))
|
|
39
|
+
result.push({
|
|
40
|
+
code: 'LOGGING_PLUGIN_UNKNOWN',
|
|
41
|
+
path,
|
|
42
|
+
plugin: name,
|
|
43
|
+
severity: 'warning',
|
|
44
|
+
hint: 'remove the entry or install the plugin',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|