@dokebi/cli 0.2.2 → 0.2.3

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,9 +1,9 @@
1
- # AI 작업 안내 — @dokebi/cli 0.2.2 (`dokebi`)
1
+ # AI 작업 안내 — @dokebi/cli 0.2.3 (`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
- - config·core·CLI는 같은 판으로 움직인다(0.2.2). 플러그인은 각자 판이다.
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면 감시 빌드 + 재시작(다음 판, D-0044) |
22
+ | `dokebi start [--foreground] [--wait-ms <ms>]` · `stop [--timeout-ms <ms>] [--force]` · `status` · `logs [-n <줄>] [-f]` | 백그라운드 실행·종료·상태·로그(아래 §2.1, 다음 판, D-0044) |
21
23
 
22
24
  모든 명령에 `--json`(기계용 단일 문서)과 변경 명령에 `--dry-run`(계획만)이 있다.
23
25
 
@@ -30,6 +32,17 @@
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`, 안의 `.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`로 알리고 파일을 지운다.
41
+ - 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`.
44
+ - `src/main.ts`를 고쳐 시작 줄을 지우면 start가 준비 여부를 알 수 없다. 남겨 둔다.
45
+
33
46
  ## 3. JSON 출력
34
47
 
35
48
  stdout에 문서 하나: `{ schemaVersion, cliVersion, command, ok, result?, error? }`. 실패는 종료 1.
@@ -70,7 +83,7 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
70
83
 
71
84
  ## 8. 주요 오류·진단 코드
72
85
 
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`.
86
+ - 명령: `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`.
74
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`.
75
88
  - 경고: `BUILD_SCRIPT_KEPT`, `INERTIA_PLUGIN_MISSING`, `SECRET_ON_COMMAND_LINE`, `EXAMPLE_MISSING`, `EXAMPLE_INVALID`.
76
89
 
@@ -86,4 +99,4 @@ npm 워크스페이스 구성원 폴더에서도 모든 명령이 된다. 패키
86
99
  ## 10. 근거
87
100
 
88
101
  - 예제: `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).
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(서버 명령).
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @dokebi/cli
2
2
 
3
- Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.2이다. 명령은 dokebi다. 0.1.1과 달라진 점(YAML 설정, migrate, 멀티 앱·모듈 명령)은 CHANGELOG에 있다. Node.js >=24.0.0, ESM을 지원하며 기본 CI는 Node 24다.
3
+ Dokebi 프로젝트 생성과 플러그인 설치 도구 0.2.3이다. 명령은 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] · 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), 다음 판).
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
- 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]\n';
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
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,75 @@ 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
+ /** Reads `--name <number>` style flags of the server commands; anything else is a usage error. */
120
+ function serverFlags(input, numbers, switches) {
121
+ const result = { numbers: new Map(), switches: new Set() };
122
+ for (let index = 0; index < input.length; index++) {
123
+ const arg = input[index];
124
+ if (switches.includes(arg) && !result.switches.has(arg))
125
+ 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);
130
+ result.numbers.set(arg, value);
131
+ }
132
+ else
133
+ throw new CliError('USAGE', HELP);
134
+ }
135
+ return result;
136
+ }
137
+ /** `dokebi dev|start|stop|status|logs` (D-0044): the project's process. */
138
+ async function serverCommand(input) {
139
+ const root = process.cwd();
140
+ const print = (result, text) => process.stdout.write(machine ? JSON.stringify(envelope(command, result)) + '\n' : text + '\n');
141
+ if (command === 'dev') {
142
+ if (input.length || machine)
143
+ throw new CliError('USAGE', HELP);
144
+ process.exitCode = await server.dev(root, (line) => process.stderr.write(`[dokebi dev] ${line}\n`));
145
+ }
146
+ else if (command === 'start') {
147
+ const options = serverFlags(input, ['--wait-ms'], ['--foreground']);
148
+ if (options.switches.has('--foreground')) {
149
+ if (machine)
150
+ throw new CliError('USAGE', HELP);
151
+ process.exitCode = await server.foreground(root);
152
+ return;
153
+ }
154
+ const result = await server.start(root, options.numbers.get('--wait-ms'));
155
+ if (!machine)
156
+ for (const warning of result.warnings)
157
+ process.stderr.write(`warning: ${warning}\n`);
158
+ print(result, `started pid ${result.pid}${result.ready ? ` (ready in ${result.ms} ms)` : ''}\nlog: ${result.log}`);
159
+ }
160
+ else if (command === 'stop') {
161
+ const options = serverFlags(input, ['--timeout-ms'], ['--force']);
162
+ const result = await server.stop(root, options.numbers.get('--timeout-ms'), options.switches.has('--force'));
163
+ print(result, result.stopped
164
+ ? `stopped pid ${result.pid} in ${result.ms} ms${result.forced ? ' (killed)' : ''}`
165
+ : result.stale
166
+ ? `not running (pid ${result.pid} had already exited; removed the pid file)`
167
+ : 'not running');
168
+ }
169
+ else if (command === 'status') {
170
+ if (input.length)
171
+ throw new CliError('USAGE', HELP);
172
+ 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}`
175
+ : result.stale
176
+ ? `stopped: pid ${result.pid} exited without dokebi stop (crashed or killed); see dokebi logs\nlog: ${result.log}`
177
+ : 'stopped');
178
+ if (!result.running)
179
+ process.exitCode = 3;
180
+ }
181
+ else {
182
+ if (machine)
183
+ throw new CliError('USAGE', HELP);
184
+ const options = serverFlags(input, ['-n'], ['-f', '--follow']);
185
+ await server.logs(root, options.numbers.get('-n'), options.switches.has('-f') || options.switches.has('--follow'));
186
+ }
187
+ }
114
188
  /** `dokebi app add|remove|list` (D-0030): apps of @dokebi/app and their domains. */
115
189
  async function app(input) {
116
190
  const action = input.shift();
@@ -250,6 +324,9 @@ function nextSteps(error) {
250
324
  for (const item of details.errors)
251
325
  if (typeof item?.missingProperty === 'string')
252
326
  lines.add(`missing: options.${String(details.plugin)}${String(item.instancePath ?? '').replaceAll('/', '.')}.${item.missingProperty}`);
327
+ const tail = error.details.tail;
328
+ if (typeof tail === 'string' && tail)
329
+ lines.add(`last log lines:\n${tail.trimEnd()}`);
253
330
  if (error.code === 'WORKSPACE_SYNC_FAILED')
254
331
  lines.add('hint: run npm install at the workspace root');
255
332
  if (Array.isArray(details.diagnostics))
@@ -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 {};
package/dist/output.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const CLI_VERSION = "0.2.2";
1
+ export declare const CLI_VERSION = "0.2.3";
2
2
  export declare function envelope(command: string, result: unknown, ok?: boolean): object;
3
3
  export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;
package/dist/output.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CliError } from './errors.js';
2
- export const CLI_VERSION = '0.2.2';
2
+ export const CLI_VERSION = '0.2.3';
3
3
  export function envelope(command, result, ok = true) {
4
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
5
5
  }
package/dist/project.js CHANGED
@@ -31,7 +31,7 @@ export class Project {
31
31
  constructor(npm = new NpmClient()) {
32
32
  this.npm = npm;
33
33
  }
34
- async init(target, core = '@dokebi/core@0.2.2') {
34
+ async init(target, core = '@dokebi/core@^0.2.2') {
35
35
  target = resolve(target);
36
36
  if (await exists(target))
37
37
  throw new CliError('PROJECT_EXISTS', target);
@@ -0,0 +1,47 @@
1
+ export interface PidRecord {
2
+ readonly pid: number;
3
+ readonly startedAt: string;
4
+ readonly entry: string;
5
+ readonly node: string;
6
+ }
7
+ export interface ServerStatus {
8
+ readonly running: boolean;
9
+ readonly pid?: number;
10
+ readonly startedAt?: string;
11
+ readonly uptimeSec?: number;
12
+ readonly rssMb?: number;
13
+ /** A pid file was left by a process that is gone (crashed or killed); it was removed. */
14
+ readonly stale?: boolean;
15
+ readonly log: string;
16
+ }
17
+ export declare function status(root: string): Promise<ServerStatus>;
18
+ export interface StartResult {
19
+ readonly pid: number;
20
+ readonly log: string;
21
+ /** False when the process is alive but never logged the start line within the wait. */
22
+ readonly ready: boolean;
23
+ readonly ms: number;
24
+ readonly warnings: string[];
25
+ }
26
+ export declare function start(root: string, waitMs?: number,
27
+ /** Tests replace the project inspection; the CLI always inspects. */
28
+ check?: (root: string) => Promise<string>): Promise<StartResult>;
29
+ export interface StopResult {
30
+ readonly stopped: boolean;
31
+ readonly pid?: number;
32
+ readonly ms?: number;
33
+ readonly forced?: boolean;
34
+ readonly stale?: boolean;
35
+ }
36
+ export declare function stop(root: string, timeoutMs?: number, force?: boolean): Promise<StopResult>;
37
+ /** Prints the last `lines` lines; with `follow`, keeps printing what is appended until SIGINT. */
38
+ export declare function logs(root: string, lines?: number, follow?: boolean, write?: (text: string) => void): Promise<void>;
39
+ /** Runs the app in the foreground and forwards SIGINT/SIGTERM; resolves with its exit code. */
40
+ export declare function foreground(root: string): Promise<number>;
41
+ /**
42
+ * Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
43
+ * src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
44
+ * inside the app (HMR, D-0045) when template-inertia supports it, otherwise from watch builds
45
+ * that restart the app. The first Ctrl+C drains everything; a second one kills.
46
+ */
47
+ export declare function dev(root: string, say: (line: string) => void): Promise<number>;
package/dist/server.js ADDED
@@ -0,0 +1,497 @@
1
+ // Server commands (D-0044): dev, start, stop, status and logs for the project's one process
2
+ // (`src/main.ts`). The background process is tracked by `.dokebi/run/app.pid`; its stdout and
3
+ // stderr go to `.dokebi/run/app.log`. A pid is only acted on when `ps` (/proc on Linux) shows it runs this
4
+ // project's entry and started when the pid file says, so a reused pid is never signalled.
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import { existsSync, readFileSync, watch } from 'node:fs';
7
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { setTimeout as delay } from 'node:timers/promises';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { promisify } from 'node:util';
12
+ import { CliError } from './errors.js';
13
+ import { ProjectInspector } from './project-inspector.js';
14
+ const exec = promisify(execFile);
15
+ const ENTRY = 'src/main.ts';
16
+ const RUN = '.dokebi/run';
17
+ /** Written by the generated `src/main.ts` once `app.start()` resolved. */
18
+ const STARTED = 'Dokebi application started';
19
+ const LOG_WARN_BYTES = 100 * 1024 * 1024;
20
+ const paths = (root) => ({
21
+ run: join(root, RUN),
22
+ pid: join(root, RUN, 'app.pid'),
23
+ log: join(root, RUN, 'app.log'),
24
+ lock: join(root, RUN, 'start.lock'),
25
+ entry: join(root, ENTRY),
26
+ });
27
+ function daemonOnly() {
28
+ if (process.platform === 'win32')
29
+ throw new CliError('UNSUPPORTED_PLATFORM', 'Background start and stop need POSIX signals; on Windows use dokebi dev or dokebi start --foreground');
30
+ }
31
+ async function readRecord(root) {
32
+ let text;
33
+ try {
34
+ text = await readFile(paths(root).pid, 'utf8');
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ try {
40
+ const value = JSON.parse(text);
41
+ if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.entry === 'string')
42
+ return value;
43
+ }
44
+ catch { }
45
+ return undefined;
46
+ }
47
+ /**
48
+ * Linux reads /proc: slim container images (node:*-slim) have no `ps`. Start time is the boot
49
+ * time plus the process start in clock ticks (USER_HZ, 100 on Linux).
50
+ */
51
+ async function procInfo(pid) {
52
+ try {
53
+ const [cmdline, stat, status, uptime] = await Promise.all([
54
+ readFile(`/proc/${pid}/cmdline`, 'utf8'),
55
+ readFile(`/proc/${pid}/stat`, 'utf8'),
56
+ readFile(`/proc/${pid}/status`, 'utf8'),
57
+ readFile('/proc/stat', 'utf8'),
58
+ ]);
59
+ // Fields after the parenthesised command name: state is field 3, starttime field 22.
60
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
61
+ const ticks = Number(fields[19]);
62
+ const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
63
+ if (!Number.isFinite(ticks) || !Number.isFinite(boot))
64
+ return undefined;
65
+ return {
66
+ startedAt: (boot + ticks / 100) * 1000,
67
+ rssKb: Number(/^VmRSS:\s+(\d+)/m.exec(status)?.[1] ?? 0),
68
+ command: cmdline.split('\0').join(' ').trim(),
69
+ };
70
+ }
71
+ catch {
72
+ return undefined; // no such process
73
+ }
74
+ }
75
+ async function processInfo(pid) {
76
+ if (process.platform === 'linux')
77
+ return procInfo(pid);
78
+ try {
79
+ const { stdout } = await exec('ps', ['-ww', '-o', 'lstart=,rss=,command=', '-p', String(pid)], {
80
+ env: { ...process.env, LC_ALL: 'C' },
81
+ });
82
+ // lstart is a fixed 24-character date: "Thu Oct 15 00:21:26 2026".
83
+ const line = stdout.trim();
84
+ const startedAt = Date.parse(line.slice(0, 24));
85
+ const [rss = '', ...command] = line.slice(24).trim().split(/\s+/);
86
+ if (!Number.isFinite(startedAt))
87
+ return undefined;
88
+ return { startedAt, rssKb: Number(rss), command: command.join(' ') };
89
+ }
90
+ catch {
91
+ return undefined; // ps exits 1 when the pid does not exist
92
+ }
93
+ }
94
+ /** The recorded process, when it is still alive and still this project's process. */
95
+ async function live(record) {
96
+ const info = await processInfo(record.pid);
97
+ if (!info?.command.includes(record.entry))
98
+ return undefined;
99
+ // lstart has one-second resolution; the pid file is written right after spawn.
100
+ if (Math.abs(info.startedAt - Date.parse(record.startedAt)) > 5000)
101
+ return undefined;
102
+ return info;
103
+ }
104
+ export async function status(root) {
105
+ const { pid, log } = paths(root);
106
+ const record = await readRecord(root);
107
+ if (!record)
108
+ return { running: false, log };
109
+ const info = await live(record);
110
+ if (!info) {
111
+ await rm(pid, { force: true });
112
+ return { running: false, stale: true, pid: record.pid, startedAt: record.startedAt, log };
113
+ }
114
+ return {
115
+ running: true,
116
+ pid: record.pid,
117
+ startedAt: record.startedAt,
118
+ uptimeSec: Math.round((Date.now() - Date.parse(record.startedAt)) / 1000),
119
+ rssMb: Math.round(info.rssKb / 1024),
120
+ log,
121
+ };
122
+ }
123
+ async function checkProject(root) {
124
+ const { entry } = paths(root);
125
+ if (!existsSync(entry))
126
+ throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
127
+ const inspection = await new ProjectInspector().inspect(root);
128
+ if (!inspection.healthy)
129
+ throw new CliError('START_CHECK_FAILED', 'The project has errors; run dokebi doctor', {
130
+ details: {
131
+ diagnostics: inspection.diagnostics.filter((item) => item.severity === 'error'),
132
+ },
133
+ });
134
+ return entry;
135
+ }
136
+ const environment = (mode) => ({
137
+ ...process.env,
138
+ NODE_ENV: process.env.NODE_ENV ?? mode,
139
+ });
140
+ async function tail(file, lines, maxBytes = 1024 * 1024) {
141
+ let handle;
142
+ try {
143
+ handle = await open(file, 'r');
144
+ const { size } = await handle.stat();
145
+ const length = Math.min(size, maxBytes);
146
+ const buffer = Buffer.alloc(length);
147
+ await handle.read(buffer, 0, length, size - length);
148
+ const text = buffer.toString('utf8').split('\n');
149
+ if (text.at(-1) === '')
150
+ text.pop();
151
+ return text.slice(-lines).join('\n') + (text.length ? '\n' : '');
152
+ }
153
+ catch {
154
+ return '';
155
+ }
156
+ finally {
157
+ await handle?.close();
158
+ }
159
+ }
160
+ export async function start(root, waitMs = 10_000,
161
+ /** Tests replace the project inspection; the CLI always inspects. */
162
+ check = checkProject) {
163
+ daemonOnly();
164
+ const files = paths(root);
165
+ const began = performance.now();
166
+ const current = await status(root);
167
+ if (current.running)
168
+ throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
169
+ details: current,
170
+ });
171
+ const entry = await check(root);
172
+ await mkdir(files.run, { recursive: true });
173
+ await writeFile(join(files.run, '.gitignore'), '*\n');
174
+ // One start at a time; a lock left by a start that died more than a minute ago is stale.
175
+ try {
176
+ await writeFile(files.lock, String(process.pid), { flag: 'wx' });
177
+ }
178
+ catch {
179
+ const age = Date.now() - (await stat(files.lock).catch(() => ({ mtimeMs: 0 }))).mtimeMs;
180
+ if (age < 60_000)
181
+ throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
182
+ await writeFile(files.lock, String(process.pid));
183
+ }
184
+ const warnings = [];
185
+ try {
186
+ const before = await stat(files.log).catch(() => undefined);
187
+ if (before && before.size > LOG_WARN_BYTES)
188
+ warnings.push(`${RUN}/app.log is ${Math.round(before.size / 1048576)} MB; it is not rotated`);
189
+ const offset = before?.size ?? 0;
190
+ const log = await open(files.log, 'a');
191
+ let child;
192
+ try {
193
+ await log.write(`\n--- dokebi start ${new Date().toISOString()} ---\n`);
194
+ child = spawn(process.execPath, [entry], {
195
+ cwd: root,
196
+ detached: true,
197
+ stdio: ['ignore', log.fd, log.fd],
198
+ env: environment('production'),
199
+ });
200
+ }
201
+ finally {
202
+ await log.close();
203
+ }
204
+ const pid = child.pid;
205
+ if (pid === undefined)
206
+ throw new CliError('START_FAILED', 'The process could not be spawned');
207
+ let exited;
208
+ child.once('exit', (code, signal) => {
209
+ exited = code ?? (signal ? 128 : 1);
210
+ });
211
+ child.unref();
212
+ const record = {
213
+ pid,
214
+ startedAt: new Date().toISOString(),
215
+ entry,
216
+ node: process.version,
217
+ };
218
+ await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`);
219
+ await rename(`${files.pid}.tmp`, files.pid);
220
+ const deadline = performance.now() + waitMs;
221
+ let ready = false;
222
+ while (performance.now() < deadline) {
223
+ await delay(100);
224
+ if (exited !== undefined) {
225
+ await rm(files.pid, { force: true });
226
+ throw new CliError('START_FAILED', `The application exited with code ${exited}`, {
227
+ details: { log: files.log, tail: await tail(files.log, 30) },
228
+ });
229
+ }
230
+ const text = await readFile(files.log).catch(() => Buffer.alloc(0));
231
+ if (text.subarray(offset).includes(STARTED)) {
232
+ ready = true;
233
+ break;
234
+ }
235
+ }
236
+ if (!ready)
237
+ warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
238
+ return { pid, log: files.log, ready, ms: Math.round(performance.now() - began), warnings };
239
+ }
240
+ finally {
241
+ await rm(files.lock, { force: true });
242
+ }
243
+ }
244
+ export async function stop(root, timeoutMs = 30_000, force = false) {
245
+ daemonOnly();
246
+ const files = paths(root);
247
+ const record = await readRecord(root);
248
+ if (!record)
249
+ return { stopped: false };
250
+ if (!(await live(record))) {
251
+ await rm(files.pid, { force: true });
252
+ return { stopped: false, pid: record.pid, stale: true };
253
+ }
254
+ const began = performance.now();
255
+ const gone = async (limit) => {
256
+ const until = performance.now() + limit;
257
+ while (performance.now() < until) {
258
+ if (!(await live(record)))
259
+ return true;
260
+ await delay(100);
261
+ }
262
+ return false;
263
+ };
264
+ process.kill(record.pid, 'SIGTERM');
265
+ let forced = false;
266
+ if (!(await gone(timeoutMs))) {
267
+ if (!force)
268
+ throw new CliError('STOP_TIMEOUT', `pid ${record.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`);
269
+ process.kill(record.pid, 'SIGKILL');
270
+ forced = true;
271
+ if (!(await gone(5000)))
272
+ throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
273
+ }
274
+ await rm(files.pid, { force: true });
275
+ return { stopped: true, pid: record.pid, ms: Math.round(performance.now() - began), forced };
276
+ }
277
+ /** Prints the last `lines` lines; with `follow`, keeps printing what is appended until SIGINT. */
278
+ export async function logs(root, lines = 100, follow = false, write = (text) => process.stdout.write(text)) {
279
+ const file = paths(root).log;
280
+ if (!follow && !existsSync(file))
281
+ throw new CliError('NO_LOG', `${RUN}/app.log does not exist; the app was never started`);
282
+ write(await tail(file, lines));
283
+ if (!follow)
284
+ return;
285
+ let position = (await stat(file).catch(() => ({ size: 0 }))).size;
286
+ const stopped = new AbortController();
287
+ process.once('SIGINT', () => stopped.abort());
288
+ while (!stopped.signal.aborted) {
289
+ await delay(250);
290
+ const size = (await stat(file).catch(() => ({ size: 0 }))).size;
291
+ if (size < position)
292
+ position = 0; // removed or truncated: follow the new file
293
+ if (size === position)
294
+ continue;
295
+ const handle = await open(file, 'r');
296
+ try {
297
+ const buffer = Buffer.alloc(size - position);
298
+ await handle.read(buffer, 0, buffer.length, position);
299
+ write(buffer.toString('utf8'));
300
+ position = size;
301
+ }
302
+ finally {
303
+ await handle.close();
304
+ }
305
+ }
306
+ }
307
+ /** Runs the app in the foreground and forwards SIGINT/SIGTERM; resolves with its exit code. */
308
+ export async function foreground(root) {
309
+ const entry = await checkProject(root);
310
+ const child = spawn(process.execPath, [entry], {
311
+ cwd: root,
312
+ stdio: 'inherit',
313
+ env: environment('production'),
314
+ });
315
+ const forward = (signal) => () => child.kill(signal);
316
+ const onInt = forward('SIGINT');
317
+ const onTerm = forward('SIGTERM');
318
+ process.on('SIGINT', onInt);
319
+ process.on('SIGTERM', onTerm);
320
+ try {
321
+ return await new Promise((resolve) => child.once('exit', (code, signal) => resolve(code ?? (signal ? 128 : 1))));
322
+ }
323
+ finally {
324
+ process.off('SIGINT', onInt);
325
+ process.off('SIGTERM', onTerm);
326
+ }
327
+ }
328
+ /** The installed version of a package, looked up like Node does (node_modules up the tree). */
329
+ function installedVersion(root, name) {
330
+ for (let directory = root;; directory = dirname(directory)) {
331
+ try {
332
+ const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
333
+ const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
334
+ return parts ? [Number(parts[1]), Number(parts[2]), Number(parts[3])] : undefined;
335
+ }
336
+ catch { }
337
+ if (dirname(directory) === directory)
338
+ return undefined;
339
+ }
340
+ }
341
+ /** template-inertia 0.1.2 serves views from the Vite dev server with HMR (D-0045). */
342
+ function inertiaDevServer(root) {
343
+ const version = installedVersion(root, '@dokebi/template-inertia');
344
+ if (!version)
345
+ return false;
346
+ const [major, minor, patch] = version;
347
+ return major > 0 || minor > 1 || (minor === 1 && patch >= 2);
348
+ }
349
+ /** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
350
+ const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less)$/;
351
+ /**
352
+ * Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
353
+ * src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
354
+ * inside the app (HMR, D-0045) when template-inertia supports it, otherwise from watch builds
355
+ * that restart the app. The first Ctrl+C drains everything; a second one kills.
356
+ */
357
+ export async function dev(root, say) {
358
+ const entry = await checkProject(root);
359
+ let app;
360
+ let exiting = false;
361
+ const hasVite = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'].some((name) => existsSync(join(root, name)));
362
+ const hot = hasVite && inertiaDevServer(root);
363
+ if (hot)
364
+ say('Inertia views: Vite dev server with hot module replacement');
365
+ const guard = new URL('./dev-guard.js', import.meta.url).href;
366
+ // stdio fd 3 is the IPC channel the guard watches.
367
+ const child = (args) => spawn(process.execPath, ['--import', guard, ...args], {
368
+ cwd: root,
369
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
370
+ env: { ...environment('development'), ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}) },
371
+ });
372
+ const run = () => {
373
+ app = child([entry]);
374
+ const current = app;
375
+ current.once('exit', (code, signal) => {
376
+ if (app === current && !exiting)
377
+ say(`app exited (${signal ?? code}); waiting for a change to restart`);
378
+ });
379
+ };
380
+ const halt = async (target, limitMs = 15_000) => {
381
+ if (!target || target.exitCode !== null || target.signalCode !== null)
382
+ return;
383
+ const exited = new Promise((resolve) => target.once('exit', resolve));
384
+ target.kill('SIGTERM');
385
+ const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
386
+ await exited;
387
+ clearTimeout(timer);
388
+ };
389
+ // Vite builds in watch mode beside the app. The app starts after every first build (a build
390
+ // empties dist/ first, and the SSR bundle is read once at start) and restarts after rebuilds.
391
+ const helpers = [];
392
+ const firstBuilds = [];
393
+ let built = () => { };
394
+ const vite = join(root, 'node_modules/vite/dist/node/index.js');
395
+ const watchScript = fileURLToPath(new URL('./vite-watch.js', import.meta.url));
396
+ for (const [config, label] of [
397
+ [['vite.config.ts', 'vite.config.mjs', 'vite.config.js'], 'vite'],
398
+ [['vite.ssr.config.ts', 'vite.ssr.config.mjs', 'vite.ssr.config.js'], 'vite ssr'],
399
+ ]) {
400
+ const file = config.find((name) => existsSync(join(root, name)));
401
+ if (hot || !file || !existsSync(vite))
402
+ continue;
403
+ say(`${label}: watching (${file})`);
404
+ const helper = child([watchScript, file]);
405
+ helpers.push(helper);
406
+ let first = true;
407
+ let resolveFirst;
408
+ let rejectFirst;
409
+ firstBuilds.push(new Promise((resolve, reject) => {
410
+ resolveFirst = resolve;
411
+ rejectFirst = reject;
412
+ }));
413
+ helper.on('message', (message) => {
414
+ if (message?.failed)
415
+ say(`${label} build failed; fix it and save again`);
416
+ if (!message?.built)
417
+ return;
418
+ if (first) {
419
+ first = false;
420
+ resolveFirst();
421
+ }
422
+ else
423
+ built(label);
424
+ });
425
+ helper.once('exit', (code, signal) => {
426
+ rejectFirst(new CliError('DEV_BUILD_FAILED', `${label} exited (${signal ?? code})`));
427
+ if (!exiting)
428
+ say(`${label} exited (${signal ?? code})`);
429
+ });
430
+ }
431
+ if (firstBuilds.length)
432
+ say('waiting for the first builds');
433
+ try {
434
+ await Promise.all(firstBuilds);
435
+ }
436
+ catch (error) {
437
+ exiting = true;
438
+ await Promise.all(helpers.map((helper) => halt(helper, 5000)));
439
+ throw error;
440
+ }
441
+ run();
442
+ let timer;
443
+ let restarting = Promise.resolve();
444
+ const changed = (what) => {
445
+ if (exiting)
446
+ return;
447
+ clearTimeout(timer);
448
+ timer = setTimeout(() => {
449
+ restarting = restarting.then(async () => {
450
+ if (exiting)
451
+ return;
452
+ say(`${what} changed; restarting`);
453
+ const previous = app;
454
+ app = undefined;
455
+ await halt(previous);
456
+ if (!exiting)
457
+ run();
458
+ });
459
+ }, 300);
460
+ };
461
+ built = (label) => changed(`${label} build`);
462
+ const watchers = [
463
+ watch(join(root, 'src'), { recursive: true }, (_event, name) => {
464
+ // Styles and components reach the app through the Vite builds (restart after the build).
465
+ if (hot && name && VIEW_FILE.test(name))
466
+ return;
467
+ if (helpers.length && name && /\.(vue|css|scss|sass|less)$/.test(name))
468
+ return;
469
+ changed(`src/${name ?? ''}`);
470
+ }),
471
+ watch(root, (_event, name) => {
472
+ if (name === 'dokebi.yaml' || name === '.env')
473
+ changed(name);
474
+ }),
475
+ ];
476
+ return await new Promise((resolve) => {
477
+ const finish = async () => {
478
+ if (exiting) {
479
+ app?.kill('SIGKILL');
480
+ for (const helper of helpers)
481
+ helper.kill('SIGKILL');
482
+ resolve(130);
483
+ return;
484
+ }
485
+ exiting = true;
486
+ say('stopping (Ctrl+C again to kill)');
487
+ clearTimeout(timer);
488
+ for (const watcher of watchers)
489
+ watcher.close();
490
+ await restarting;
491
+ await Promise.all([halt(app), ...helpers.map((helper) => halt(helper, 5000))]);
492
+ resolve(0);
493
+ };
494
+ process.on('SIGINT', () => void finish());
495
+ process.on('SIGTERM', () => void finish());
496
+ });
497
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ // Run by `dokebi dev` (D-0044) for one Vite config: a watch build that never empties its outDir.
2
+ // The browser build's `emptyOutDir: true` would otherwise delete dist/ssr/ on every rebuild,
3
+ // which is fine for `npm run build` (one build after the other) but not for two watchers.
4
+ // Every finished build is reported to dev over the IPC channel ({ built: true } or
5
+ // { failed: true }); output goes to the terminal as usual.
6
+ import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ const configFile = process.argv[2];
9
+ if (!configFile)
10
+ throw new Error('usage: vite-watch <config>');
11
+ const vite = (await import(pathToFileURL(join(process.cwd(), 'node_modules/vite/dist/node/index.js')).href));
12
+ const watcher = (await vite.build({
13
+ configFile,
14
+ build: { watch: {}, emptyOutDir: false },
15
+ }));
16
+ watcher.on('event', (event) => {
17
+ if (event.code === 'BUNDLE_END')
18
+ void event.result?.close();
19
+ if (event.code === 'END')
20
+ process.send?.({ built: true });
21
+ if (event.code === 'ERROR')
22
+ process.send?.({ failed: true });
23
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -24,8 +24,8 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "semver": "7.8.5",
27
- "@dokebi/core": "0.2.2",
28
- "@dokebi/config": "0.2.2",
27
+ "@dokebi/core": "^0.2.2",
28
+ "@dokebi/config": "^0.2.2",
29
29
  "yaml": "2.9.1"
30
30
  },
31
31
  "devDependencies": {