@dokebi/cli 0.2.3 → 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 CHANGED
@@ -1,4 +1,4 @@
1
- # AI 작업 안내 — @dokebi/cli 0.2.3 (`dokebi`)
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
 
@@ -18,8 +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면 감시 빌드 + 재시작(다음 판, D-0044) |
22
- | `dokebi start [--foreground] [--wait-ms <ms>]` · `stop [--timeout-ms <ms>] [--force]` · `status` · `logs [-n <줄>] [-f]` | 백그라운드 실행·종료·상태·로그(아래 §2.1, 다음 판, D-0044) |
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`: inspect와 같은 검사(error 진단이 있으면 `START_CHECK_FAILED`) 뒤 `NODE_ENV=production`(이미 정해져 있으면 그대로)으로 백그라운드 실행. 로그에 `Dokebi application started`(생성된 main이 쓰는 줄)가 찍히면 `ready: true`. `--wait-ms`(기본 10000) 안에 그 줄이 없으면 살아 있어도 `ready: false`와 경고. 그 전에 끝나면 `START_FAILED`(details.tail에 로그 끝 30줄). 이미 실행 중이면 `ALREADY_RUNNING`, 다른 start가 진행 중이면 `START_IN_PROGRESS`.
39
- - `stop`: SIGTERM으로 drain. `--timeout-ms`(기본 30000) 안에 안 끝나면 `STOP_TIMEOUT`(프로세스는 그대로), `--force`면 그때 SIGKILL. 실행 중이 아니면 `stopped: false`.
40
- - `status`: 실행 중이면 pid·가동 초·RSS. 멈춤이면 **종료 코드 3**. PID 파일만 남은 경우(밖에서 죽음·crash)는 `stale: true`로 알리고 파일을 지운다.
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)줄, `-f`는 Ctrl+C까지 따라 읽기. 로그 회전은 없다(100MB를 넘으면 start가 경고). `logs`·`dev`·`start --foreground`는 `--json`이 없다.
43
- - 하지 않는 것: crash 뒤 자동 재시작, 부팅 시 시작, 로그 회전. 운영 서버는 systemd·Docker에서 `dokebi start --foreground`(또는 `node src/main.ts`)를 쓰는 편이 낫다. Windows는 `start`(백그라운드)·`stop`이 `UNSUPPORTED_PLATFORM`.
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.1.1과 달라진 점(YAML 설정, migrate, 멀티 앱·모듈 명령)은 CHANGELOG에 있다. Node.js >=24.0.0, ESM을 지원하며 기본 CI는 Node 24다.
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`를 쓴다. 자동 재시작·로그 회전은 하지 않으므로 운영은 systemd·Docker에서 `start --foreground`를 권한다([D-0044](../../docs/decisions/D-0044-cli-server-commands.md), 다음 판).
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
- /** Reads `--name <number>` style flags of the server commands; anything else is a usage error. */
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 (numbers.includes(arg) && !result.numbers.has(arg)) {
127
- const value = Number(input[++index]);
128
- if (!Number.isSafeInteger(value) || value < 1 || value > 3_600_000)
129
- throw new CliError('USAGE', HELP);
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 options = serverFlags(input, ['--wait-ms'], ['--foreground']);
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
- if (machine)
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 result = await server.start(root, options.numbers.get('--wait-ms'));
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, ['--timeout-ms'], ['--force']);
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
- print(result, result.running
174
- ? `running pid ${result.pid} · up ${result.uptimeSec} s · ${result.rssMb} MB\nlog: ${result.log}`
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\nlog: ${result.log}`
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, ['-n'], ['-f', '--follow']);
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
  }
@@ -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('.');
@@ -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: {}\n";
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: {}\n";
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
@@ -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
- export const CONFIG_EXAMPLE = '# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions: {}\n';
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 = '# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions: {}\n';
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
+ }
@@ -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
- child.on('error', () => stop('INSTALL_PROCESS_FAILED'));
68
- child.stdin.on('error', () => stop('INSTALL_INPUT_FAILED'));
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
+ import { debug } from './diagnostics.js';
4
5
  import { CliError } from './errors.js';
5
6
  import { json, object, parseObject } from './filesystem.js';
6
7
  import { PACKAGE_NAME } from './manifest.js';
@@ -77,6 +78,8 @@ export class NpmClient {
77
78
  const npmScript = process.env.npm_execpath;
78
79
  const command = npmScript?.endsWith('npm-cli.js') ? process.execPath : 'npm';
79
80
  const args = [...(npmScript?.endsWith('npm-cli.js') ? [npmScript] : []), ...npmArgs];
81
+ const began = performance.now();
82
+ debug('Running npm', { args: npmArgs.slice(0, 16), directory });
80
83
  await new Promise((resolvePromise, reject) => {
81
84
  const child = spawn(command, args, {
82
85
  cwd: directory,
@@ -93,6 +96,12 @@ export class NpmClient {
93
96
  });
94
97
  child.on('close', (code) => {
95
98
  clearTimeout(timer);
99
+ debug('npm finished', {
100
+ command: npmArgs[0],
101
+ exitCode: code,
102
+ timedOut,
103
+ ms: Math.round(performance.now() - began),
104
+ });
96
105
  if (code === 0 && !timedOut)
97
106
  resolvePromise();
98
107
  else
package/dist/output.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const CLI_VERSION = "0.2.3";
1
+ export declare const CLI_VERSION = "0.2.4";
2
2
  export declare function envelope(command: string, result: unknown, ok?: boolean): object;
3
3
  export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;