@dokebi/cli 0.2.3 → 0.2.5
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 +19 -12
- package/README.md +2 -2
- package/dist/bin.js +48 -16
- package/dist/config-edit.d.ts +6 -0
- package/dist/config-edit.js +16 -0
- package/dist/configuration-scaffold.js +3 -0
- package/dist/configuration.d.ts +2 -2
- package/dist/configuration.js +11 -2
- 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 +32 -7
- package/dist/rotating-log.d.ts +27 -0
- package/dist/rotating-log.js +166 -0
- package/dist/server.d.ts +55 -3
- package/dist/server.js +452 -92
- package/dist/transaction.js +10 -1
- package/dist/workspace.js +6 -2
- package/package.json +1 -1
package/AI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# AI 작업 안내 — @dokebi/cli 0.2.
|
|
1
|
+
# AI 작업 안내 — @dokebi/cli 0.2.5 (`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
|
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
| `dokebi migrate [--dry-run]` | 0.1.x JSON 설정 프로젝트를 YAML로 이전 |
|
|
17
17
|
| `dokebi inspect` / `dokebi doctor` | 읽기 전용 진단. doctor는 error 진단이 있으면 종료 1 |
|
|
18
18
|
| `dokebi app add <이름> [--domain <host>]... [--template <이름>] [--inertia [--ssr]]` | `src/apps/<이름>` 앱(D-0030). `--inertia`는 Vue 골격과 Vite 빌드 준비, `--ssr`은 서버 렌더링 준비(D-0039) |
|
|
19
|
-
| `dokebi app remove <이름>` · `app link <앱> <도메인> [--template]` · `app unlink <도메인>` · `app list` | 앱·도메인
|
|
19
|
+
| `dokebi app remove <이름>` · `app link <앱> <도메인> [--template]` · `app unlink <도메인>` · `app list` | 앱·도메인 관리. `app remove`는 그 앱을 가리키는 도메인이 남아 있으면 `APP_IN_USE`(먼저 unlink), 등록을 지우며 dokebi.yaml·견본의 `options.dokebi-app.apps.<이름>`(D-0061 앱별 설정)도 지운다(0.2.5; 남기면 다음 시작이 `UNKNOWN_APP`). 폴더는 남는다 |
|
|
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면 감시 빌드 +
|
|
22
|
-
| `dokebi start [--foreground] [--wait-ms <ms>]` · `stop [--timeout-ms <ms>] [--force]` · `status` · `logs [-n <줄>] [-f]` | 백그라운드
|
|
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) |
|
|
23
23
|
|
|
24
24
|
모든 명령에 `--json`(기계용 단일 문서)과 변경 명령에 `--dry-run`(계획만)이 있다.
|
|
25
25
|
|
|
@@ -34,13 +34,14 @@
|
|
|
34
34
|
|
|
35
35
|
## 2.1 서버 실행(D-0044, 다음 판)
|
|
36
36
|
|
|
37
|
-
- 한 프로젝트 = 프로세스 하나(`src/main.ts`, 여러 앱도 같은 프로세스). 실행 파일은 `.dokebi/run/`(`app.pid`, `app.log`, 안의 `.gitignore`가 `*`라 Git에 안 들어간다).
|
|
38
|
-
- `start
|
|
39
|
-
- `
|
|
40
|
-
- `
|
|
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`로 알리고 파일을 지운다.
|
|
41
42
|
- PID는 재사용되므로 `ps`로 그 pid의 명령줄에 이 프로젝트의 `src/main.ts`가 있고 시작 시각이 맞을 때만 신호를 보낸다. 아니면 낡은 PID 파일로 본다.
|
|
42
|
-
- `logs`: 마지막 `-n`(기본 100)
|
|
43
|
-
- 하지 않는 것: crash 뒤 자동 재시작, 부팅 시 시작,
|
|
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`.
|
|
44
45
|
- `src/main.ts`를 고쳐 시작 줄을 지우면 start가 준비 여부를 알 수 없다. 남겨 둔다.
|
|
45
46
|
|
|
46
47
|
## 3. JSON 출력
|
|
@@ -84,7 +85,13 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
|
|
|
84
85
|
## 8. 주요 오류·진단 코드
|
|
85
86
|
|
|
86
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`.
|
|
87
|
-
- 진단(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
|
|
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` 봉투)은 바뀌지 않는다.
|
|
88
95
|
- 경고: `BUILD_SCRIPT_KEPT`, `INERTIA_PLUGIN_MISSING`, `SECRET_ON_COMMAND_LINE`, `EXAMPLE_MISSING`, `EXAMPLE_INVALID`.
|
|
89
96
|
|
|
90
97
|
## 9. 주의 사항
|
|
@@ -99,4 +106,4 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
|
|
|
99
106
|
## 10. 근거
|
|
100
107
|
|
|
101
108
|
- 예제: `examples/06-cli`, `26-scaffold`(실제 CLI로 같은 프로젝트를 반복 생성·빌드·실행), `25-playground`(`tools/regenerate.ts`가 CLI 기록 전체를 재생).
|
|
102
|
-
- 결정: 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(서버 명령).
|
|
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
|
|
|
@@ -13,7 +13,7 @@ Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.3이다. 명령은
|
|
|
13
13
|
- dokebi migrate [--dry-run] [--json]: 기존 JSON 프로젝트를 YAML 계약으로 명시적으로 이전한다.
|
|
14
14
|
- dokebi inspect --json, dokebi doctor --json: 설치 버전·매니페스트·스키마·설정 문제를 읽기 전용으로 확인한다.
|
|
15
15
|
- dokebi dev: 코드·설정이 바뀌면 다시 띄우는 개발 실행(Vite 설정이 있으면 `vite build --watch` 함께).
|
|
16
|
-
- dokebi start [--foreground] · stop [--force] · status · logs [-n 줄] [-f]: 백그라운드 실행·종료·상태·로그. `.dokebi/run/app.pid`·`app.log`를 쓴다. 자동
|
|
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), 다음 판).
|
|
17
17
|
|
|
18
18
|
생성 앱의 npm start를 실행한다. 명령은 프로젝트 루트(또는 npm 워크스페이스 구성원 폴더)에서 설치된 CLI로 호출한다. 모든 명령에 `--json`이 있다. dry-run도 패키지 해석을 위해 다운로드·임시 설치할 수 있지만 관리 파일과 node_modules를 적용하지 않는다.
|
|
19
19
|
|
package/dist/bin.js
CHANGED
|
@@ -5,7 +5,7 @@ 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>] [--json]\ndokebi stop [--timeout-ms <ms>] [--force] [--json]\ndokebi status [--json]\ndokebi logs [-n <lines>] [-f]\n';
|
|
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';
|
|
9
9
|
const raw = process.argv.slice(2);
|
|
10
10
|
const machine = raw.includes('--json');
|
|
11
11
|
const args = raw.filter((arg) => arg !== '--json');
|
|
@@ -116,17 +116,22 @@ async function main() {
|
|
|
116
116
|
process.stderr.write(`warning: ${warning.path} looks secret; command-line values can remain in shell history\n`);
|
|
117
117
|
process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
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
|
+
*/
|
|
120
123
|
function serverFlags(input, numbers, switches) {
|
|
121
124
|
const result = { numbers: new Map(), switches: new Set() };
|
|
122
125
|
for (let index = 0; index < input.length; index++) {
|
|
123
126
|
const arg = input[index];
|
|
124
127
|
if (switches.includes(arg) && !result.switches.has(arg))
|
|
125
128
|
result.switches.add(arg);
|
|
126
|
-
else if (
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
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}`);
|
|
130
135
|
result.numbers.set(arg, value);
|
|
131
136
|
}
|
|
132
137
|
else
|
|
@@ -144,22 +149,38 @@ async function serverCommand(input) {
|
|
|
144
149
|
process.exitCode = await server.dev(root, (line) => process.stderr.write(`[dokebi dev] ${line}\n`));
|
|
145
150
|
}
|
|
146
151
|
else if (command === 'start') {
|
|
147
|
-
const
|
|
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']);
|
|
148
158
|
if (options.switches.has('--foreground')) {
|
|
149
|
-
|
|
159
|
+
// The foreground app writes to the terminal; rotation belongs to systemd/Docker there.
|
|
160
|
+
if (machine || options.numbers.size)
|
|
150
161
|
throw new CliError('USAGE', HELP);
|
|
151
162
|
process.exitCode = await server.foreground(root);
|
|
152
163
|
return;
|
|
153
164
|
}
|
|
154
|
-
const
|
|
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
|
+
});
|
|
155
173
|
if (!machine)
|
|
156
174
|
for (const warning of result.warnings)
|
|
157
175
|
process.stderr.write(`warning: ${warning}\n`);
|
|
158
|
-
print(result, `started pid ${result.pid}${result.ready ? ` (ready in ${result.ms} ms)` : ''}\nlog: ${result.log}`);
|
|
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)`);
|
|
159
177
|
}
|
|
160
178
|
else if (command === 'stop') {
|
|
161
|
-
const options = serverFlags(input,
|
|
179
|
+
const options = serverFlags(input, { '--timeout-ms': undefined }, ['--force']);
|
|
162
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`);
|
|
163
184
|
print(result, result.stopped
|
|
164
185
|
? `stopped pid ${result.pid} in ${result.ms} ms${result.forced ? ' (killed)' : ''}`
|
|
165
186
|
: result.stale
|
|
@@ -170,18 +191,29 @@ async function serverCommand(input) {
|
|
|
170
191
|
if (input.length)
|
|
171
192
|
throw new CliError('USAGE', HELP);
|
|
172
193
|
const result = await server.status(root);
|
|
173
|
-
|
|
174
|
-
|
|
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}`
|
|
175
205
|
: result.stale
|
|
176
|
-
? `stopped: pid ${result.pid} exited without dokebi stop (crashed or killed); see dokebi logs
|
|
177
|
-
: 'stopped')
|
|
206
|
+
? `stopped: pid ${result.pid} exited without dokebi stop (crashed or killed); see dokebi logs`
|
|
207
|
+
: 'stopped') +
|
|
208
|
+
(files ? `\nlogs:${files}` : '') +
|
|
209
|
+
diagnostics);
|
|
178
210
|
if (!result.running)
|
|
179
211
|
process.exitCode = 3;
|
|
180
212
|
}
|
|
181
213
|
else {
|
|
182
214
|
if (machine)
|
|
183
215
|
throw new CliError('USAGE', HELP);
|
|
184
|
-
const options = serverFlags(input,
|
|
216
|
+
const options = serverFlags(input, { '-n': undefined }, ['-f', '--follow']);
|
|
185
217
|
await server.logs(root, options.numbers.get('-n'), options.switches.has('-f') || options.switches.has('--follow'));
|
|
186
218
|
}
|
|
187
219
|
}
|
package/dist/config-edit.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export declare function parseSetOption(argument: string): SetOption;
|
|
|
12
12
|
export declare function setWarnings(sets: readonly SetOption[]): Warning[];
|
|
13
13
|
export declare function applySetOptions(source: string, id: string, sets: readonly SetOption[]): string;
|
|
14
14
|
export declare function applyConfigChanges(source: string, id: string, changes: readonly ConfigChange[]): string;
|
|
15
|
+
/**
|
|
16
|
+
* Removes `options.dokebi-app.apps.<app>` (D-0061 per-app settings) and the `apps` map when it is
|
|
17
|
+
* left empty; a source without that entry is returned unchanged. dokebi-app refuses to start with
|
|
18
|
+
* an entry for an app that is not defined (UNKNOWN_APP), so `dokebi app remove` calls this.
|
|
19
|
+
*/
|
|
20
|
+
export declare function removeAppSettings(source: string, app: string): string;
|
|
15
21
|
/** Keeps a removed plugin's section and marks it for a later reinstall or manual cleanup. */
|
|
16
22
|
export declare function markRemoved(source: string, id: string): {
|
|
17
23
|
source: string;
|
package/dist/config-edit.js
CHANGED
|
@@ -109,6 +109,22 @@ export function applyConfigChanges(source, id, changes) {
|
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Removes `options.dokebi-app.apps.<app>` (D-0061 per-app settings) and the `apps` map when it is
|
|
114
|
+
* left empty; a source without that entry is returned unchanged. dokebi-app refuses to start with
|
|
115
|
+
* an entry for an app that is not defined (UNKNOWN_APP), so `dokebi app remove` calls this.
|
|
116
|
+
*/
|
|
117
|
+
export function removeAppSettings(source, app) {
|
|
118
|
+
const path = ['options', 'dokebi-app', 'apps', app];
|
|
119
|
+
if (!parseDocument(source, { version: '1.2' }).hasIn(path))
|
|
120
|
+
return source;
|
|
121
|
+
return edit(source, 'dokebi-app', (document) => {
|
|
122
|
+
document.deleteIn(path);
|
|
123
|
+
const apps = document.getIn(['options', 'dokebi-app', 'apps'], true);
|
|
124
|
+
if (isMap(apps) && apps.items.length === 0)
|
|
125
|
+
document.deleteIn(['options', 'dokebi-app', 'apps']);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
112
128
|
/** Keeps a removed plugin's section and marks it for a later reinstall or manual cleanup. */
|
|
113
129
|
export function markRemoved(source, id) {
|
|
114
130
|
const document = parseDocument(source, { version: '1.2' });
|
|
@@ -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,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[];
|