@dokebi/cli 0.4.1 → 0.4.26

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.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +527 -35
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +2 -0
  46. package/dist/check/shared-state-rules.js +114 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +307 -31
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +29 -3
  110. package/dist/install-module.js +142 -13
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +106 -27
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +59 -4
  124. package/dist/npm-client.js +220 -16
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +206 -20
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3016 -336
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +50 -4
  170. package/dist/server.js +790 -103
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +67 -0
  180. package/dist/transaction.js +428 -46
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +113 -4
  184. package/dist/verify.js +326 -28
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
package/dist/server.js CHANGED
@@ -27,20 +27,22 @@
27
27
  import { execFile, spawn } from 'node:child_process';
28
28
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
29
29
  import { existsSync, readFileSync, watch } from 'node:fs';
30
- import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
30
+ import { mkdir, open, readdir, readFile, rename, rm, stat, utimes, writeFile, } from 'node:fs/promises';
31
31
  import { request as httpRequest } from 'node:http';
32
32
  import { request as httpsRequest } from 'node:https';
33
- import { basename, dirname, join } from 'node:path';
33
+ import { basename, dirname, join, relative } from 'node:path';
34
34
  import { setTimeout as delay } from 'node:timers/promises';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { promisify } from 'node:util';
37
37
  import { appUrls, readAppHosts } from './app-urls.js';
38
- import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
38
+ import { CONFIG_FILE, configTarget, parseProjectConfig, readProjectSettings, } from './configuration.js';
39
+ import { WatchedState } from './dev-watch.js';
39
40
  import { debug, debugEnabled, describeError, isMissing, reportError } from './diagnostics.js';
40
- import { appEnvironment, readEnvFile } from './env-file.js';
41
+ import { appEnvironment, envFileWarnings, readEnvFile } from './env-file.js';
41
42
  import { CliError } from './errors.js';
42
43
  import { askLogWriter, CONTROL_HOST } from './log-writer-control.js';
43
44
  import { redactNpmOutput } from './npm-client.js';
45
+ import { endLeftovers, holdTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
44
46
  import { ProjectInspector } from './project-inspector.js';
45
47
  import { logFiles } from './rotating-log.js';
46
48
  import { ViteHostProcess, viteHostScript } from './vite-host-process.js';
@@ -63,6 +65,8 @@ const paths = (root) => ({
63
65
  pid: join(root, RUN, 'app.pid'),
64
66
  log: join(root, RUN, 'app.log'),
65
67
  writerLog: join(root, RUN, 'log-writer.log'),
68
+ /** D-0154: the outcome of the app's last configuration reload (RELOAD_STATUS_ENV). */
69
+ reloadStatus: join(root, RUN, 'reload.json'),
66
70
  lock: join(root, RUN, 'start.lock'),
67
71
  entry: join(root, ENTRY),
68
72
  });
@@ -70,6 +74,145 @@ function daemonOnly() {
70
74
  if (process.platform === 'win32')
71
75
  throw new CliError('UNSUPPORTED_PLATFORM', 'Background start and stop need POSIX signals; on Windows use dokebi dev or dokebi start --foreground');
72
76
  }
77
+ /** A start lock not refreshed for this long belongs to a start that died. */
78
+ const LOCK_STALE_MS = 60_000;
79
+ /** A start in progress refreshes its lock this often, however long it waits. */
80
+ const LOCK_REFRESH_MS = 10_000;
81
+ /** Breaking a stale lock takes milliseconds; a guard this old belongs to a process that died. */
82
+ const BREAK_STALE_MS = 10_000;
83
+ /**
84
+ * The start lock `.dokebi/run/start.lock` (R3). One start at a time holds it from before it
85
+ * reads the running state until its pid file is written, and `status`/`stop` take it to remove a
86
+ * stale pid file, so a pid file is only ever replaced or removed by the lock holder. It is
87
+ * created with `wx`; the holder refreshes it while it waits, so a lock older than LOCK_STALE_MS
88
+ * belongs to a start that died and is broken (see breakStale) before trying again.
89
+ */
90
+ class StartLock {
91
+ file;
92
+ timer;
93
+ constructor(file) {
94
+ this.file = file;
95
+ this.timer = setInterval(() => {
96
+ const now = new Date();
97
+ utimes(file, now, now).catch((error) => {
98
+ reportError('START_LOCK_REFRESH_FAILED', 'Unable to refresh the start lock', error);
99
+ });
100
+ }, LOCK_REFRESH_MS);
101
+ this.timer.unref();
102
+ }
103
+ /** The lock, or undefined while another start holds it. */
104
+ static async acquire(file) {
105
+ for (let attempt = 0; attempt < 5; attempt++) {
106
+ try {
107
+ await writeFile(file, String(process.pid), { flag: 'wx' });
108
+ debug('Start lock acquired', { file: basename(file) });
109
+ return new StartLock(file);
110
+ }
111
+ catch (error) {
112
+ if (error.code !== 'EEXIST')
113
+ throw error;
114
+ }
115
+ let held;
116
+ try {
117
+ held = await stat(file);
118
+ }
119
+ catch (error) {
120
+ if (!isMissing(error))
121
+ throw error;
122
+ debug('Start lock released meanwhile; trying again', { attempt });
123
+ continue;
124
+ }
125
+ if (Date.now() - held.mtimeMs < LOCK_STALE_MS)
126
+ return undefined;
127
+ if (!(await StartLock.breakStale(file, held)))
128
+ return undefined;
129
+ }
130
+ debug('Start lock still contended after several attempts');
131
+ return undefined;
132
+ }
133
+ /**
134
+ * Removes the stale lock `held` under the guard `<lock>.break` (created with `wx`), so only one
135
+ * process breaks it and never a lock another start created after `held` was read (same inode
136
+ * and time, checked under the guard). False when another process is breaking it right now.
137
+ * A guard older than BREAK_STALE_MS is left by a process that died within these few steps.
138
+ */
139
+ static async breakStale(file, held) {
140
+ const guard = `${file}.break`;
141
+ try {
142
+ await writeFile(guard, String(process.pid), { flag: 'wx' });
143
+ }
144
+ catch (error) {
145
+ if (error.code !== 'EEXIST')
146
+ throw error;
147
+ let since;
148
+ try {
149
+ since = Date.now() - (await stat(guard)).mtimeMs;
150
+ }
151
+ catch (cause) {
152
+ if (!isMissing(cause))
153
+ throw cause;
154
+ debug('The stale start lock was broken meanwhile; trying again');
155
+ return true;
156
+ }
157
+ if (since < BREAK_STALE_MS)
158
+ return false;
159
+ debug('Removing the guard of a lock break that died', { ageMs: Math.round(since) });
160
+ await rm(guard, { force: true });
161
+ return true;
162
+ }
163
+ try {
164
+ let now;
165
+ try {
166
+ now = await stat(file);
167
+ }
168
+ catch (error) {
169
+ if (!isMissing(error))
170
+ throw error;
171
+ return true;
172
+ }
173
+ if (now.ino === held.ino && now.mtimeMs === held.mtimeMs) {
174
+ await rm(file, { force: true });
175
+ debug('Removed a stale start lock', { ageMs: Math.round(Date.now() - now.mtimeMs) });
176
+ }
177
+ return true;
178
+ }
179
+ finally {
180
+ await rm(guard, { force: true });
181
+ }
182
+ }
183
+ async release() {
184
+ clearInterval(this.timer);
185
+ await rm(this.file, { force: true });
186
+ debug('Start lock released', { file: basename(this.file) });
187
+ }
188
+ }
189
+ /**
190
+ * Removes the pid file when it still holds `record` (R3): under the start lock, so a start that
191
+ * wrote a newer record meanwhile keeps it. While a start holds the lock it is left to that start,
192
+ * which replaces the file anyway. `locked` is true when the caller holds the lock already.
193
+ */
194
+ async function removeRecord(root, record, locked = false) {
195
+ const files = paths(root);
196
+ const lock = locked ? undefined : await StartLock.acquire(files.lock);
197
+ if (!locked && !lock) {
198
+ debug('A start holds the lock; leaving the pid file to it', { pid: record.pid });
199
+ return;
200
+ }
201
+ try {
202
+ const current = await readRecord(root);
203
+ if (current &&
204
+ (current.pid !== record.pid ||
205
+ current.startedAt !== record.startedAt ||
206
+ current.token !== record.token)) {
207
+ debug('The pid file names a newer start; keeping it', { pid: record.pid, now: current.pid });
208
+ return;
209
+ }
210
+ await rm(files.pid, { force: true });
211
+ }
212
+ finally {
213
+ await lock?.release();
214
+ }
215
+ }
73
216
  async function readRecord(root) {
74
217
  let text;
75
218
  try {
@@ -118,6 +261,9 @@ async function procInfo(pid) {
118
261
  ]);
119
262
  // Fields after the parenthesised command name: state is field 3, starttime field 22.
120
263
  const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
264
+ // A zombie has exited and only waits for its parent to reap it: gone for every caller here.
265
+ if (fields[0] === 'Z')
266
+ return undefined;
121
267
  const ticks = Number(fields[19]);
122
268
  const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
123
269
  if (!Number.isFinite(ticks) || !Number.isFinite(boot))
@@ -129,14 +275,22 @@ async function procInfo(pid) {
129
275
  };
130
276
  }
131
277
  catch (error) {
132
- // ENOENT: no such process. Anything else (permissions, a changed /proc) is a failure.
133
278
  if (error instanceof CliError)
134
279
  throw error;
135
- if (isMissing(error))
280
+ if (procGone(error))
136
281
  return undefined;
137
282
  throw processInfoFailed(pid, '/proc', error);
138
283
  }
139
284
  }
285
+ /**
286
+ * Whether a /proc read failed because the process is gone: ENOENT (no /proc/<pid>), or ESRCH,
287
+ * which Linux returns when the process exits while one of its files is open. Anything else
288
+ * (permissions, a changed /proc) is a failure. ESRCH was found by the v0.4.1 tag pipeline: an
289
+ * app that stopped during `stop`'s check ended as PROCESS_INFO_FAILED.
290
+ */
291
+ export function procGone(error) {
292
+ return isMissing(error) || error?.code === 'ESRCH';
293
+ }
140
294
  const readProcess = async (pid) => {
141
295
  if (process.platform === 'linux')
142
296
  return procInfo(pid);
@@ -210,7 +364,11 @@ export async function status(root,
210
364
  read = readProcess,
211
365
  /** Tests replace how a process is signalled; the CLI uses process.kill. */
212
366
  kill = signalProcess) {
213
- const { pid, log } = paths(root);
367
+ return statusOf(root, read, kill, false);
368
+ }
369
+ /** `status`; `locked` when the caller (start) holds the start lock. */
370
+ async function statusOf(root, read, kill, locked) {
371
+ const { log } = paths(root);
214
372
  const record = await readRecord(root);
215
373
  const logs = await logInfo(root);
216
374
  if (!record)
@@ -228,7 +386,7 @@ kill = signalProcess) {
228
386
  proof = found;
229
387
  }
230
388
  if (!info && proof === 'process') {
231
- await rm(pid, { force: true });
389
+ await removeRecord(root, record, locked);
232
390
  return {
233
391
  running: false,
234
392
  stale: true,
@@ -280,15 +438,18 @@ function pidState(pid, kill) {
280
438
  return 'unknown';
281
439
  }
282
440
  }
283
- /** The web address dokebi.yaml configures, for pid files that recorded none. */
441
+ /**
442
+ * The web address the settings file configures (DOKEBI_CONFIG, else dokebi.yaml: the one the app
443
+ * reads, M50), for pid files that recorded none.
444
+ */
284
445
  async function configuredUrl(root) {
285
446
  let source;
286
447
  try {
287
- source = await readFile(join(root, CONFIG_FILE), 'utf8');
448
+ source = (await readProjectSettings(root)).source;
288
449
  }
289
450
  catch (error) {
290
451
  if (!isMissing(error))
291
- reportError('CONFIG_READ_FAILED', `Unable to read ${CONFIG_FILE}`, error);
452
+ reportError('CONFIG_READ_FAILED', 'Unable to read the settings file of the project', error);
292
453
  return undefined;
293
454
  }
294
455
  let web;
@@ -449,7 +610,7 @@ async function withoutProcessInfo(root, record, failure, kill) {
449
610
  },
450
611
  });
451
612
  }
452
- async function checkProject(root) {
613
+ export async function checkProject(root) {
453
614
  const { entry } = paths(root);
454
615
  if (!existsSync(entry))
455
616
  throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
@@ -510,6 +671,42 @@ async function tail(file, lines, current) {
510
671
  }
511
672
  return result.length ? `${result.join('\n')}\n` : '';
512
673
  }
674
+ /** The line the log writer writes before each start (log-writer.ts). */
675
+ const START_MARK = '--- dokebi start ';
676
+ /**
677
+ * R1 T4 review: why a start failed, from the app log of this start only (after its last start
678
+ * mark): the first record with an error, as `<code>: <first line>` and, when the error has
679
+ * causes, `… <its last Caused by line>`. A START_FAILED said only "exited with code 1", and its
680
+ * tail began with an earlier start's lines. Undefined when no record says.
681
+ */
682
+ export function startFailureCause(lines) {
683
+ const from = lines.findLastIndex((line) => line.startsWith(START_MARK));
684
+ for (const line of lines.slice(from + 1)) {
685
+ // Records are JSON lines; a stack line or a plain console line is not one.
686
+ if (!line.startsWith('{'))
687
+ continue;
688
+ let record;
689
+ try {
690
+ record = JSON.parse(line);
691
+ }
692
+ catch (error) {
693
+ debug('A log line that starts like a record is not JSON', { error: describeError(error) });
694
+ continue;
695
+ }
696
+ if (record === null || typeof record !== 'object')
697
+ continue;
698
+ const error = typeof record.error === 'string' ? record.error : undefined;
699
+ if (error === undefined && record.level !== 'error')
700
+ continue;
701
+ const text = error ?? String(record.message ?? '');
702
+ const first = text.split('\n')[0] ?? '';
703
+ const causes = text.split('\n').filter((each) => each.startsWith('Caused by: '));
704
+ const deepest = causes.at(-1)?.slice('Caused by: '.length);
705
+ const code = typeof record.code === 'string' ? `${record.code}: ` : '';
706
+ return `${code}${first}${deepest ? ` … ${deepest}` : ''}`.slice(0, 600);
707
+ }
708
+ return undefined;
709
+ }
513
710
  /** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
514
711
  function logSettings(options) {
515
712
  const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
@@ -528,48 +725,59 @@ check = checkProject) {
528
725
  const waitMs = options.waitMs ?? 10_000;
529
726
  const files = paths(root);
530
727
  const began = performance.now();
531
- const current = await status(root);
532
- if (current.running)
533
- throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
534
- details: current,
535
- });
536
- const entry = await check(root);
537
- // The project's .env under the CLI's environment (D-0073); a malformed file stops here.
538
- const token = randomBytes(24).toString('base64url');
539
- const env = appEnvironment(root, 'production', {
540
- ...options.env,
541
- [INSTANCE_TOKEN_ENV]: token,
542
- });
543
728
  await mkdir(files.run, { recursive: true });
544
729
  await writeFile(join(files.run, '.gitignore'), '*\n');
545
- // Lines this start writes begin here (the web listening line is parsed from them).
546
- const logOffset = await stat(files.log).then((info) => info.size, (error) => {
547
- if (!isMissing(error))
548
- throw error;
549
- return 0;
550
- });
551
- // One start at a time; a lock left by a start that died more than a minute ago is stale.
552
- try {
553
- await writeFile(files.lock, String(process.pid), { flag: 'wx' });
554
- }
555
- catch (error) {
556
- if (error.code !== 'EEXIST')
557
- throw error;
558
- const age = Date.now() -
559
- (await stat(files.lock).catch((cause) => {
560
- // The other start removed its lock meanwhile: the lock counts as stale.
561
- if (!isMissing(cause))
562
- throw cause;
563
- return { mtimeMs: 0 };
564
- })).mtimeMs;
565
- if (age < 60_000)
566
- throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
567
- debug('Replacing a stale start lock', { ageMs: age });
568
- await writeFile(files.lock, String(process.pid));
569
- }
730
+ // One start at a time (R3): the lock is taken before the running state is read, so a start
731
+ // that waited (in its checks) while another start finished sees that start's app.
732
+ const lock = await StartLock.acquire(files.lock);
733
+ if (!lock)
734
+ throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
735
+ // R4CC-1: a SIGINT, SIGTERM or SIGHUP while the start runs (Ctrl+C, an agent's command time
736
+ // limit during verify's start step) ends what it started and its lock first, then the command
737
+ // ends as the signal says. Before, the app and its writer ran on with a stale start lock, and
738
+ // the next verify refused with ALREADY_RUNNING.
739
+ const termination = holdTermination();
740
+ const interrupted = () => {
741
+ const signal = termination.signal();
742
+ if (signal)
743
+ throw new CliError('INTERRUPTED', `dokebi start was interrupted (${signal}); nothing it started is left running`, { details: { signal } });
744
+ };
745
+ /** This start wrote the pid file (removed again when it is interrupted). */
746
+ let wrote = false;
747
+ /** The start returned: the app is the caller's from here on. */
748
+ let returned = false;
570
749
  const warnings = [];
571
750
  let writer;
751
+ /** The writer's process group while a failure must not leave it (and the app) running. */
752
+ let leftover;
753
+ /** The app's own process group (the log writer spawns it detached), once it was spawned. */
754
+ let appGroup;
572
755
  try {
756
+ const current = await statusOf(root, readProcess, signalProcess, true);
757
+ if (current.running)
758
+ throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
759
+ details: current,
760
+ });
761
+ const entry = await check(root);
762
+ interrupted();
763
+ // The project's .env under the CLI's environment (D-0073); a malformed file stops here.
764
+ const token = randomBytes(24).toString('base64url');
765
+ const env = appEnvironment(root, 'production', {
766
+ ...options.env,
767
+ [INSTANCE_TOKEN_ENV]: token,
768
+ // D-0154: the app writes the outcome of each configuration reload here (dokebi reload).
769
+ [RELOAD_STATUS_ENV]: files.reloadStatus,
770
+ });
771
+ // R4CC-7: what .env sets that the app does not get (DOKEBI_CONFIG) is said, never dropped
772
+ // silently.
773
+ for (const warning of envFileWarnings(root))
774
+ warnings.push(`${warning.code} (${warning.path}): ${warning.hint}`);
775
+ // Lines this start writes begin here (the web listening line is parsed from them).
776
+ const logOffset = await stat(files.log).then((info) => info.size, (error) => {
777
+ if (!isMissing(error))
778
+ throw error;
779
+ return 0;
780
+ });
573
781
  // The writer's own diagnostics (it has no app logger) go to log-writer.log.
574
782
  const writerLog = await open(files.writerLog, 'a');
575
783
  try {
@@ -593,13 +801,16 @@ check = checkProject) {
593
801
  const writerPid = writer.pid;
594
802
  if (writerPid === undefined)
595
803
  throw new CliError('START_FAILED', 'The log writer process could not be spawned');
804
+ leftover = writerPid;
596
805
  const state = {};
597
806
  writer.on('message', (message) => {
598
807
  // The writer sends its control port before it spawns the app (IPC keeps the order).
599
808
  if (typeof message?.control === 'number')
600
809
  state.control = message.control;
601
- if (typeof message?.spawned === 'number')
810
+ if (typeof message?.spawned === 'number') {
602
811
  state.pid = message.spawned;
812
+ appGroup = message.spawned;
813
+ }
603
814
  if (message?.ready)
604
815
  state.ready = true;
605
816
  if (message && 'exited' in message)
@@ -617,11 +828,15 @@ check = checkProject) {
617
828
  debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
618
829
  const failure = async (message) => {
619
830
  await rm(files.pid, { force: true });
620
- return new CliError('START_FAILED', message, {
831
+ // This start's lines only (after its mark), and the cause in the message itself.
832
+ const recent = (await tail(files.log, 200)).split('\n');
833
+ const mark = recent.findLastIndex((line) => line.startsWith(START_MARK));
834
+ const cause = startFailureCause(recent);
835
+ return new CliError('START_FAILED', cause ? `${message}: ${redactNpmOutput(cause)}` : message, {
621
836
  details: {
622
837
  log: files.log,
623
838
  // App output in a result: credentials masked like npm output (logs prints it as is).
624
- tail: redactNpmOutput(await tail(files.log, 30)),
839
+ tail: redactNpmOutput(recent.slice(Math.max(mark + 1, recent.length - 31)).join('\n')),
625
840
  logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
626
841
  },
627
842
  });
@@ -631,11 +846,13 @@ check = checkProject) {
631
846
  await rm(`${files.pid}.tmp`, { force: true });
632
847
  await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
633
848
  await rename(`${files.pid}.tmp`, files.pid);
849
+ wrote = true;
634
850
  };
635
851
  const deadline = performance.now() + waitMs;
636
852
  let recorded;
637
853
  while (performance.now() < deadline) {
638
854
  await delay(50);
855
+ interrupted();
639
856
  if (state.pid !== undefined && recorded === undefined) {
640
857
  recorded = {
641
858
  pid: state.pid,
@@ -651,27 +868,29 @@ check = checkProject) {
651
868
  await save(recorded);
652
869
  debug('App process spawned; waiting for the start line', { pid: state.pid });
653
870
  }
654
- if (state.exited !== undefined)
871
+ if (state.exited !== undefined) {
872
+ // Nothing is left to stop: the writer writes the app's last lines and ends by itself.
873
+ leftover = undefined;
655
874
  throw await failure(`The application exited with ${state.exited}`);
875
+ }
656
876
  if (state.failed !== undefined)
657
877
  throw await failure(`The application could not be started: ${state.failed}`);
658
878
  if (state.ready && recorded !== undefined)
659
879
  break;
660
880
  }
661
- if (recorded === undefined) {
662
- // The writer never started the app: do not leave it (or a late app) behind. The writer
663
- // leads its own process group (detached), so the group signal reaches both.
664
- process.kill(-writerPid, 'SIGKILL');
881
+ if (recorded === undefined)
665
882
  throw await failure(`The log writer did not start the application within ${waitMs} ms`);
666
- }
667
883
  const ready = state.ready === true;
668
884
  if (!ready)
669
- warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
670
- const listen = ready ? await startedUrls(files.log, logOffset) : [];
885
+ warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready (a src/main.ts of your own: send process.send?.({ dokebi: 'started' }) after app.start(), as the generated one does, so a logging.level above info still shows the start)`);
886
+ const listen = ready ? await listeningAfterStart(root, files.log, logOffset, deadline) : [];
671
887
  // Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
672
888
  // listening address (the proof does not depend on the Host header).
673
889
  if (listen.length)
674
890
  await save({ ...recorded, urls: listen });
891
+ const urls = appUrls(listen, await appHostsOrNone(root));
892
+ interrupted();
893
+ returned = true;
675
894
  return {
676
895
  pid: recorded.pid,
677
896
  log: files.log,
@@ -679,18 +898,45 @@ check = checkProject) {
679
898
  logMaxBytes: maxBytes,
680
899
  logFiles: keep,
681
900
  ready,
682
- urls: appUrls(listen, await appHostsOrNone(root)),
901
+ urls,
683
902
  listen,
684
903
  ms: Math.round(performance.now() - began),
685
904
  warnings,
686
905
  };
687
906
  }
907
+ catch (error) {
908
+ // A start that fails once the writer runs leaves neither it nor the app behind: the writer
909
+ // never started the app in time (or a late app), the writer died (the app it spawned would
910
+ // keep running and hold the port, and verify stops only what start returned), or the pid
911
+ // file could not be written. The writer leads its own process group (detached) and the app
912
+ // leads one below it (with what the app started), so both groups are signalled.
913
+ if (leftover !== undefined) {
914
+ if (killGroup(leftover, 'SIGKILL'))
915
+ debug('Killed the log writer group of a failed start', { writer: leftover });
916
+ if (appGroup !== undefined && killGroup(appGroup, 'SIGKILL'))
917
+ debug('Killed the app group of a failed start', { pid: appGroup });
918
+ }
919
+ // An interrupted start leaves no pid file naming what it ended (a failed one removed it).
920
+ if (wrote && termination.signal())
921
+ await rm(files.pid, { force: true });
922
+ throw error;
923
+ }
688
924
  finally {
689
925
  // The writer and the app outlive this command.
690
926
  if (writer?.connected)
691
927
  writer.disconnect();
692
928
  writer?.unref();
693
- await rm(files.lock, { force: true });
929
+ await lock.release();
930
+ // A signal during these last steps of a start that returned: this command ends with it (just
931
+ // below), and nobody would stop the app, so it is ended like an interrupted start.
932
+ if (returned && termination.signal() && leftover !== undefined) {
933
+ killGroup(leftover, 'SIGKILL');
934
+ if (appGroup !== undefined)
935
+ killGroup(appGroup, 'SIGKILL');
936
+ await rm(files.pid, { force: true });
937
+ }
938
+ // A signal that came meanwhile ends this command now, as it would have without the hold.
939
+ termination.release();
694
940
  }
695
941
  }
696
942
  /**
@@ -745,6 +991,38 @@ export async function appHostsOrNone(root) {
745
991
  return { hosted: false, domains: [], answersUnknown: false };
746
992
  }
747
993
  }
994
+ /**
995
+ * The listening URLs once the app said it started. The start message comes over IPC, the
996
+ * listening line through the log, a path with more hops (the logger's worker thread; a cluster's
997
+ * primary relaying whole lines, M51): the line can reach app.log just after the message. So with
998
+ * @dokebi/web in the project the log is read again until the line is there or the start's own
999
+ * deadline passes; a project without web logs no such line and is read once.
1000
+ */
1001
+ async function listeningAfterStart(root, log, offset, deadline) {
1002
+ let listen = await startedUrls(log, offset);
1003
+ if (listen.length || !hasWebDependency(root))
1004
+ return listen;
1005
+ while (!listen.length && performance.now() < deadline) {
1006
+ await delay(25);
1007
+ listen = await startedUrls(log, offset);
1008
+ }
1009
+ if (!listen.length)
1010
+ debug('No web listening line in the log before the start deadline', { log });
1011
+ return listen;
1012
+ }
1013
+ /** Whether package.json depends on @dokebi/web (a malformed one counts as no). */
1014
+ function hasWebDependency(root) {
1015
+ try {
1016
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
1017
+ return manifest.dependencies?.['@dokebi/web'] !== undefined;
1018
+ }
1019
+ catch (error) {
1020
+ debug('package.json not read while waiting for the listening line', {
1021
+ error: describeError(error),
1022
+ });
1023
+ return false;
1024
+ }
1025
+ }
748
1026
  async function startedUrls(log, offset) {
749
1027
  try {
750
1028
  const bytes = await readFile(log);
@@ -763,7 +1041,6 @@ read = readProcess,
763
1041
  /** Tests replace how a process is signalled; the CLI uses process.kill. */
764
1042
  kill = signalProcess) {
765
1043
  daemonOnly();
766
- const files = paths(root);
767
1044
  const record = await readRecord(root);
768
1045
  if (!record)
769
1046
  return { stopped: false };
@@ -785,7 +1062,7 @@ kill = signalProcess) {
785
1062
  }
786
1063
  if (!found) {
787
1064
  debug('Removing a stale pid file', { pid: record.pid });
788
- await rm(files.pid, { force: true });
1065
+ await removeRecord(root, record);
789
1066
  return { stopped: false, pid: record.pid, stale: true };
790
1067
  }
791
1068
  if (proof === 'log-writer')
@@ -829,7 +1106,7 @@ kill = signalProcess) {
829
1106
  if (!(await gone(app, 5000)))
830
1107
  throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
831
1108
  }
832
- await rm(files.pid, { force: true });
1109
+ await removeRecord(root, record);
833
1110
  const warnings = [];
834
1111
  // The app is stopped and its pid file removed: a failure to read the writer is a warning.
835
1112
  const writerGone = await gone(writer, 5000).catch((error) => {
@@ -857,7 +1134,6 @@ kill = signalProcess) {
857
1134
  * the connection). An unreachable or unproven writer leaves everything as it was.
858
1135
  */
859
1136
  async function stopThroughWriter(root, record, timeoutMs, force, proof, refused) {
860
- const files = paths(root);
861
1137
  const began = performance.now();
862
1138
  const asked = await askWriter(record, { op: 'stop', timeoutMs, force });
863
1139
  if (!asked.reached)
@@ -877,7 +1153,7 @@ async function stopThroughWriter(root, record, timeoutMs, force, proof, refused)
877
1153
  ? reply.message
878
1154
  : `The log writer could not stop pid ${record.pid} (${String(reply.code)})`);
879
1155
  }
880
- await rm(files.pid, { force: true });
1156
+ await removeRecord(root, record);
881
1157
  if (reply.stopped !== true) {
882
1158
  debug('The log writer reports that the app had already exited', { pid: record.pid });
883
1159
  await asked.closed(0);
@@ -923,11 +1199,11 @@ function pidExists(pid, kill = signalProcess) {
923
1199
  */
924
1200
  export async function stopStarted(root, started, timeoutMs = 30_000, force = false) {
925
1201
  daemonOnly();
926
- const files = paths(root);
927
1202
  const forget = async () => {
928
1203
  // Only the pid file of this start: another start may have replaced it meanwhile.
929
- if ((await readRecord(root))?.pid === started.pid)
930
- await rm(files.pid, { force: true });
1204
+ const current = await readRecord(root);
1205
+ if (current?.pid === started.pid)
1206
+ await removeRecord(root, current);
931
1207
  };
932
1208
  const gone = async (pid, limit) => {
933
1209
  const until = performance.now() + limit;
@@ -977,7 +1253,9 @@ export async function stopStarted(root, started, timeoutMs = 30_000, force = fal
977
1253
  * it finishes its file, then reads each rotated file newer than it (found by inode, oldest
978
1254
  * first) before following the new app.log, so several rotations between two polls skip no line.
979
1255
  */
980
- export async function logs(root, lines = 100, follow = false, write = (text) => process.stdout.write(text), signal) {
1256
+ export async function logs(root, lines = 100, follow = false,
1257
+ // dokebi-check: allow console-output dokebi logs prints the app log lines to the terminal
1258
+ write = (text) => process.stdout.write(text), signal) {
981
1259
  const file = paths(root).log;
982
1260
  if (!follow) {
983
1261
  if (!existsSync(file))
@@ -1194,7 +1472,7 @@ export async function foreground(root) {
1194
1472
  }
1195
1473
  }
1196
1474
  /** The installed version of a package, looked up like Node does (node_modules up the tree). */
1197
- function installedVersion(root, name) {
1475
+ export function installedVersion(root, name) {
1198
1476
  for (let directory = root;; directory = dirname(directory)) {
1199
1477
  try {
1200
1478
  const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
@@ -1220,16 +1498,87 @@ function inertiaDevServer(root) {
1220
1498
  }
1221
1499
  /** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
1222
1500
  const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less)$/;
1501
+ let devReloadId = 0;
1502
+ /**
1503
+ * D-0154: asks the app `dokebi dev` runs to read dokebi.yaml again (through the IPC channel of
1504
+ * its preload, dev-guard.ts). Resolves with its answer, or undefined when it cannot be asked,
1505
+ * exits or does not answer within `timeoutMs`.
1506
+ */
1507
+ function askDevReload(app, timeoutMs) {
1508
+ if (!app.connected || app.exitCode !== null || app.signalCode !== null)
1509
+ return Promise.resolve(undefined);
1510
+ const id = ++devReloadId;
1511
+ return new Promise((resolve) => {
1512
+ const finish = (answer) => {
1513
+ clearTimeout(timer);
1514
+ app.off('message', onMessage);
1515
+ app.off('exit', onExit);
1516
+ resolve(answer);
1517
+ };
1518
+ const onMessage = (message) => {
1519
+ const reply = message;
1520
+ if (reply?.dokebi === 'dev-reload-result' &&
1521
+ reply.id === id &&
1522
+ typeof reply.outcome === 'string')
1523
+ finish(reply);
1524
+ };
1525
+ const onExit = () => finish(undefined);
1526
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
1527
+ app.on('message', onMessage);
1528
+ app.once('exit', onExit);
1529
+ try {
1530
+ app.send({ dokebi: 'dev-reload', id }, (error) => {
1531
+ if (error) {
1532
+ debug('dev: the reload request could not be sent; restarting instead', {
1533
+ error: describeError(error),
1534
+ });
1535
+ finish(undefined);
1536
+ }
1537
+ });
1538
+ }
1539
+ catch (error) {
1540
+ debug('dev: the reload request could not be sent; restarting instead', {
1541
+ error: describeError(error),
1542
+ });
1543
+ finish(undefined);
1544
+ }
1545
+ });
1546
+ }
1547
+ /** How long dev waits for a reload answer: the app's prepareTimeoutMs (dokebi.yaml) and 5 s. */
1548
+ async function devReloadTimeout(root) {
1549
+ try {
1550
+ const options = parseProjectConfig((await readProjectSettings(root)).source).options;
1551
+ const reload = options.reload;
1552
+ const ms = reload?.prepareTimeoutMs;
1553
+ return (typeof ms === 'number' && Number.isSafeInteger(ms) && ms > 0 ? ms : 30_000) + 5000;
1554
+ }
1555
+ catch (error) {
1556
+ // A broken dokebi.yaml: the app answers `invalid` at once; the default bounds the wait.
1557
+ debug('dev: dokebi.yaml not read for the reload timeout', { error: describeError(error) });
1558
+ return 35_000;
1559
+ }
1560
+ }
1223
1561
  /**
1224
1562
  * Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
1225
- * src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
1226
- * (HMR, D-0045) when template-inertia supports it, otherwise from watch builds that restart the
1227
- * app. With template-inertia 0.4.0 that Vite is one host process beside the app, shared by every
1228
- * cluster worker and kept across app restarts (D-0045 addendum 3); older ones start it inside the app.
1563
+ * src/, dokebi.yaml or .env (a path whose state differs from the recorded one, see dev-watch.ts).
1564
+ * D-0154: a change of dokebi.yaml alone is first offered to the running app: when every changed
1565
+ * option is reloadable it applies them and keeps running; an invalid file keeps the app as it is;
1566
+ * anything else (an option that needs a restart, an app without reloads) restarts it.
1567
+ * With a Vite config, Inertia views come from the Vite dev server (HMR, D-0045) when
1568
+ * template-inertia supports it, otherwise from watch builds that restart the app. With
1569
+ * template-inertia 0.4.0 that Vite is one host process beside the app, shared by every cluster
1570
+ * worker and kept across app restarts (D-0045 addendum 3); older ones start it inside the app.
1229
1571
  * The first Ctrl+C drains everything; a second one kills.
1230
1572
  */
1231
1573
  export async function dev(root, say) {
1232
1574
  const entry = await checkProject(root);
1575
+ // R4CC-7: a DOKEBI_CONFIG line of .env is not passed to the app; dev says so once.
1576
+ for (const warning of envFileWarnings(root))
1577
+ say(`${warning.code} (${warning.path}): ${warning.hint}`);
1578
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml) is the one watched
1579
+ // for a reload and named in what dev says.
1580
+ const settings = await configTarget(root);
1581
+ const settingsName = settings.file;
1233
1582
  // The project's .env (D-0073), read again before every (re)start: a change restarts the app
1234
1583
  // with the new values. A malformed file fails here and, later, fails that restart only.
1235
1584
  let fileEnv = readEnvFile(root);
@@ -1248,10 +1597,14 @@ export async function dev(root, say) {
1248
1597
  : `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
1249
1598
  }
1250
1599
  const guard = new URL('./dev-guard.js', import.meta.url).href;
1251
- // stdio fd 3 is the IPC channel the guard watches.
1252
- const child = (args, extra = {}) => spawn(process.execPath, ['--import', guard, ...args], {
1600
+ // stdio fd 3 is the IPC channel the guard watches. `group`: the app leads a process group of
1601
+ // its own (POSIX), so what it started and left running when it exits (a restart, a crash) is
1602
+ // found there and ended instead of holding a port against the next start. Ctrl+C reaches it
1603
+ // through dev (SIGTERM), and the guard still ends it when dev dies.
1604
+ const child = (args, extra = {}, group = false) => spawn(process.execPath, ['--import', guard, ...args], {
1253
1605
  cwd: root,
1254
1606
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
1607
+ detached: group && PROCESS_GROUPS,
1255
1608
  // DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
1256
1609
  env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
1257
1610
  });
@@ -1277,24 +1630,52 @@ export async function dev(root, say) {
1277
1630
  return;
1278
1631
  extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
1279
1632
  }
1280
- app = child([entry], extra);
1633
+ app = child([entry], extra, true);
1281
1634
  const current = app;
1282
1635
  current.once('exit', (code, signal) => {
1283
1636
  if (app === current && !exiting)
1284
1637
  say(`app exited (${signal ?? code}); waiting for a change to restart`);
1638
+ if (current.pid !== undefined)
1639
+ void leftovers(current.pid);
1285
1640
  });
1286
1641
  };
1287
- const halt = async (target, limitMs = 15_000) => {
1288
- if (!target || target.exitCode !== null || target.signalCode !== null)
1642
+ /** Ends what an exited app left in its process group, and says so. */
1643
+ const ended = new Map();
1644
+ const leftovers = (pid) => {
1645
+ let done = ended.get(pid);
1646
+ if (!done) {
1647
+ done = endLeftovers(pid, 2000).then((how) => {
1648
+ if (how !== 'none')
1649
+ say(`processes the app started were still running after it exited; ended them (${how === 'killed' ? 'SIGKILL' : 'SIGTERM'})`);
1650
+ });
1651
+ ended.set(pid, done);
1652
+ }
1653
+ return done;
1654
+ };
1655
+ /**
1656
+ * R4CC-4: apps dev is stopping (a restart drains the old one while `app` is already unset): a
1657
+ * second Ctrl+C kills them too. Since the app leads its own group it no longer gets the
1658
+ * terminal's Ctrl+C itself, so without this the old app drained on for up to 15 s.
1659
+ */
1660
+ const halting = new Set();
1661
+ const halt = async (target, limitMs = 15_000, group = false) => {
1662
+ if (!target)
1289
1663
  return;
1290
- const exited = new Promise((resolve) => target.once('exit', resolve));
1291
- target.kill('SIGTERM');
1292
- const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
1293
- await exited;
1294
- clearTimeout(timer);
1664
+ if (target.exitCode === null && target.signalCode === null) {
1665
+ const exited = new Promise((resolve) => target.once('exit', resolve));
1666
+ halting.add(target);
1667
+ target.kill('SIGTERM');
1668
+ const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
1669
+ await exited;
1670
+ clearTimeout(timer);
1671
+ halting.delete(target);
1672
+ }
1673
+ // The next app must not meet a port that a process of this one still holds.
1674
+ if (group && target.pid !== undefined)
1675
+ await leftovers(target.pid);
1295
1676
  };
1296
- // Vite builds in watch mode beside the app. The app starts after every first build (a build
1297
- // empties dist/ first, and the SSR bundle is read once at start) and restarts after rebuilds.
1677
+ // Vite builds in watch mode beside the app. The app starts after every first build (the
1678
+ // manifest and the SSR bundle it reads once at start must exist) and restarts after rebuilds.
1298
1679
  const helpers = [];
1299
1680
  const firstBuilds = [];
1300
1681
  let built = () => { };
@@ -1346,23 +1727,52 @@ export async function dev(root, say) {
1346
1727
  throw error;
1347
1728
  }
1348
1729
  let timer;
1349
- let restarting = run().catch((error) => {
1350
- // Keep the loop alive: the next change tries again.
1351
- reportError('DEV_START_FAILED', 'Starting the app failed', error);
1352
- });
1730
+ // The first start is chained below, once the watched files are recorded.
1731
+ let restarting = Promise.resolve();
1732
+ /** What changed since the last restart or reload: dokebi.yaml alone may be reloaded. */
1733
+ let pending = { config: false, other: undefined };
1353
1734
  const changed = (what) => {
1354
1735
  if (exiting)
1355
1736
  return;
1737
+ if (what === settingsName)
1738
+ pending.config = true;
1739
+ else
1740
+ pending.other ??= what;
1356
1741
  clearTimeout(timer);
1357
1742
  timer = setTimeout(() => {
1743
+ const batch = pending;
1744
+ pending = { config: false, other: undefined };
1358
1745
  restarting = restarting
1359
1746
  .then(async () => {
1360
1747
  if (exiting)
1361
1748
  return;
1749
+ if (batch.config && batch.other === undefined && app) {
1750
+ const answer = await askDevReload(app, await devReloadTimeout(root));
1751
+ debug('dev: reload answer', {
1752
+ outcome: answer?.outcome ?? 'no-answer',
1753
+ code: answer?.code,
1754
+ });
1755
+ if (answer?.outcome === 'applied') {
1756
+ say(`${settingsName} reloaded without a restart${answer.paths?.length ? `: ${answer.paths.join(', ')}` : ''}`);
1757
+ return;
1758
+ }
1759
+ if (answer?.outcome === 'unchanged')
1760
+ return;
1761
+ if (answer?.outcome === 'invalid') {
1762
+ say(`${settingsName} is invalid; the app keeps its current settings (its log says where); fix it and save again`);
1763
+ return;
1764
+ }
1765
+ // S-c: an app with no reload hook is still starting, or passes no configFile (R12).
1766
+ if (answer?.outcome === 'unsupported')
1767
+ say(`${settingsName}: the app did not take it (it is still starting, or src/main.ts passes no configFile to new Application or ClusterRunner: doctor says RELOAD_NOT_WIRED then)`);
1768
+ if (answer?.outcome === 'restart-required' && answer.restartRequired?.length)
1769
+ say(`${settingsName}: ${answer.restartRequired.join(', ')} ${answer.restartRequired.length === 1 ? 'needs' : 'need'} a restart`);
1770
+ }
1771
+ const what = batch.other ?? settingsName;
1362
1772
  say(`${what} changed; restarting`);
1363
1773
  const previous = app;
1364
1774
  app = undefined;
1365
- await halt(previous);
1775
+ await halt(previous, undefined, true);
1366
1776
  if (!exiting)
1367
1777
  await run();
1368
1778
  })
@@ -1373,27 +1783,62 @@ export async function dev(root, say) {
1373
1783
  }, 300);
1374
1784
  };
1375
1785
  built = (label) => changed(`${label} build`);
1786
+ // Events restart the app only for paths that really changed since they were recorded (see
1787
+ // dev-watch.ts: macOS also reports writes made before the watch started). An event without a
1788
+ // name cannot be checked and restarts.
1789
+ const source = join(root, 'src');
1790
+ // Styles and components reach the app through Vite (HMR, or the watch builds that restart it).
1791
+ const viewFile = (name) => (hot && VIEW_FILE.test(name)) ||
1792
+ (helpers.length > 0 && /\.(vue|css|scss|sass|less)$/.test(name));
1793
+ const watched = new WatchedState({ skip: (path) => viewFile(relative(source, path)) });
1376
1794
  const watchers = [
1377
- watch(join(root, 'src'), { recursive: true }, (_event, name) => {
1378
- // Styles and components reach the app through the Vite builds (restart after the build).
1379
- if (hot && name && VIEW_FILE.test(name))
1795
+ watch(source, { recursive: true }, (_event, name) => {
1796
+ if (name && viewFile(name))
1380
1797
  return;
1381
- if (helpers.length && name && /\.(vue|css|scss|sass|less)$/.test(name))
1798
+ if (name && !watched.changedIn(source, name))
1382
1799
  return;
1383
1800
  changed(`src/${name ?? ''}`);
1384
1801
  }),
1385
1802
  watch(root, (_event, name) => {
1386
- if (name === 'dokebi.yaml' || name === '.env')
1387
- changed(name);
1803
+ if (!name)
1804
+ return;
1805
+ if ((name === CONFIG_FILE && !settings.fromEnvironment) || name === '.env') {
1806
+ if (watched.changed(join(root, name)))
1807
+ changed(name);
1808
+ }
1388
1809
  // A running Vite reloads its own config; a host that failed to start tries again.
1389
- else if (viteHost && name && /^vite\.config\.[cm]?[jt]s$/.test(name) && !viteHost.running)
1390
- changed(name);
1810
+ else if (viteHost && /^vite\.config\.[cm]?[jt]s$/.test(name) && !viteHost.running) {
1811
+ if (watched.changed(join(root, name)))
1812
+ changed(name);
1813
+ }
1391
1814
  }),
1392
1815
  ];
1816
+ // A settings file DOKEBI_CONFIG names: its own folder is watched for it (any folder, also one
1817
+ // outside the project: dev only reads it).
1818
+ if (settings.fromEnvironment)
1819
+ watchers.push(watch(dirname(settings.path), (_event, name) => {
1820
+ if (name === basename(settings.path) && watched.changed(settings.path))
1821
+ changed(settingsName);
1822
+ }));
1823
+ // After the watchers start and before the app starts: a write after this point is an event
1824
+ // with a new state; a write before it is already what the app reads.
1825
+ watched.recordTree(source);
1826
+ for (const path of [settings.path, join(root, '.env')])
1827
+ watched.record(path);
1828
+ for (const extension of ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'])
1829
+ watched.record(join(root, `vite.config.${extension}`));
1830
+ restarting = run().catch((error) => {
1831
+ // Keep the loop alive: the next change tries again.
1832
+ reportError('DEV_START_FAILED', 'Starting the app failed', error);
1833
+ });
1393
1834
  return await new Promise((resolve) => {
1394
1835
  const finish = async () => {
1395
1836
  if (exiting) {
1396
- app?.kill('SIGKILL');
1837
+ for (const target of new Set([...(app ? [app] : []), ...halting])) {
1838
+ target.kill('SIGKILL');
1839
+ if (target.pid !== undefined && PROCESS_GROUPS)
1840
+ killGroup(target.pid, 'SIGKILL');
1841
+ }
1397
1842
  for (const helper of helpers)
1398
1843
  helper.kill('SIGKILL');
1399
1844
  await viteHost?.stop(0, true);
@@ -1406,7 +1851,10 @@ export async function dev(root, say) {
1406
1851
  for (const watcher of watchers)
1407
1852
  watcher.close();
1408
1853
  await restarting;
1409
- await Promise.all([halt(app), ...helpers.map((helper) => halt(helper, 5000))]);
1854
+ await Promise.all([
1855
+ halt(app, undefined, true),
1856
+ ...helpers.map((helper) => halt(helper, 5000)),
1857
+ ]);
1410
1858
  // After the app: its requests and server renders go to the host until it has drained.
1411
1859
  await viteHost?.stop();
1412
1860
  resolve(0);
@@ -1415,3 +1863,242 @@ export async function dev(root, say) {
1415
1863
  process.on('SIGTERM', () => void finish());
1416
1864
  });
1417
1865
  }
1866
+ /** The variable that tells the app where to write its reload outcomes (core RELOAD_STATUS_ENV). */
1867
+ const RELOAD_STATUS_ENV = 'DOKEBI_RELOAD_STATUS';
1868
+ /** The app's last reload outcome (RELOAD_STATUS_ENV), or undefined. */
1869
+ async function reloadStatusOf(file) {
1870
+ try {
1871
+ return JSON.parse(await readFile(file, 'utf8'));
1872
+ }
1873
+ catch (error) {
1874
+ // Not written yet (no reload so far), or being replaced: no outcome to read now.
1875
+ if (isMissing(error) || error instanceof SyntaxError)
1876
+ return undefined;
1877
+ throw error;
1878
+ }
1879
+ }
1880
+ /** The log codes a reload ends with; the app writes one after every SIGHUP (D-0154). */
1881
+ const RELOAD_OUTCOMES = new Set([
1882
+ 'CONFIG_RELOADED',
1883
+ 'CONFIG_RELOAD_UNCHANGED',
1884
+ 'CONFIG_RELOAD_INVALID',
1885
+ 'CONFIG_RELOAD_RESTART_REQUIRED',
1886
+ 'CONFIG_RELOAD_FAILED',
1887
+ 'CONFIG_RELOAD_TIMEOUT',
1888
+ 'CONFIG_RELOAD_PARTIAL',
1889
+ 'CONFIG_RELOAD_UNAVAILABLE',
1890
+ ]);
1891
+ /** Versions of @dokebi/core whose Application reads dokebi.yaml again on SIGHUP. */
1892
+ const RELOAD_CORE = [0, 4, 2];
1893
+ function newer(version, than) {
1894
+ for (let index = 0; index < 3; index++) {
1895
+ const a = version[index] ?? 0;
1896
+ const b = than[index] ?? 0;
1897
+ if (a !== b)
1898
+ return a > b;
1899
+ }
1900
+ return true;
1901
+ }
1902
+ /**
1903
+ * Whether the background app reads dokebi.yaml again on SIGHUP: core 0.4.2 or newer and a
1904
+ * src/main.ts that gives its Application (or @dokebi/cluster runner) `configFile`, unchanged since
1905
+ * the app started. Without that a SIGHUP would end the app, so the command refuses.
1906
+ */
1907
+ async function reloadSupport(root, record) {
1908
+ const core = installedVersion(root, '@dokebi/core');
1909
+ if (!core || !newer(core, RELOAD_CORE))
1910
+ return `@dokebi/core ${core ? core.join('.') : '(not installed)'} cannot read dokebi.yaml again; it needs 0.4.2 or newer`;
1911
+ const main = paths(root).entry;
1912
+ const [text, info] = await Promise.all([readFile(main, 'utf8'), stat(main)]);
1913
+ if (!/\bconfigFile\s*:/.test(text))
1914
+ return `${ENTRY} gives the app no configFile, so a SIGHUP would end it instead of reloading`;
1915
+ if (info.mtimeMs > Date.parse(record.startedAt) + 1000)
1916
+ return `${ENTRY} changed after the app started; the running app may not read dokebi.yaml again`;
1917
+ return undefined;
1918
+ }
1919
+ /** The first reload outcome the app logged after `offset` of app.log (a SIGHUP's, `trigger: signal`). */
1920
+ async function reloadOutcome(log, offset) {
1921
+ let text;
1922
+ try {
1923
+ const handle = await open(log, 'r');
1924
+ try {
1925
+ const { size } = await handle.stat();
1926
+ // A rotation since the signal starts a new file: read it from the beginning.
1927
+ const from = size < offset ? 0 : offset;
1928
+ const length = Math.min(size - from, 4 * MB);
1929
+ const buffer = Buffer.alloc(Math.max(0, length));
1930
+ if (length > 0)
1931
+ await handle.read(buffer, 0, length, from);
1932
+ text = buffer.toString('utf8');
1933
+ }
1934
+ finally {
1935
+ await handle.close();
1936
+ }
1937
+ }
1938
+ catch (error) {
1939
+ if (isMissing(error))
1940
+ return undefined;
1941
+ throw error;
1942
+ }
1943
+ for (const line of text.split('\n')) {
1944
+ if (!line.includes('CONFIG_RELOAD'))
1945
+ continue;
1946
+ let record;
1947
+ try {
1948
+ record = JSON.parse(line);
1949
+ }
1950
+ catch {
1951
+ // dokebi-check: allow empty-catch a log line that is not JSON (a readable line, a cut line) is not the outcome
1952
+ continue;
1953
+ }
1954
+ if (typeof record.code === 'string' &&
1955
+ RELOAD_OUTCOMES.has(record.code) &&
1956
+ record.trigger === 'signal')
1957
+ return record;
1958
+ }
1959
+ return undefined;
1960
+ }
1961
+ /**
1962
+ * `dokebi reload` (D-0154): asks the background app to read dokebi.yaml again (SIGHUP to the pid
1963
+ * `start` recorded, proven like `stop`; through its log writer when this shell may not signal
1964
+ * it), then reads its outcome from .dokebi/run/app.log. Options marked reloadable apply without
1965
+ * a restart; an option that needs one (`RELOAD_RESTART_REQUIRED`), an invalid file
1966
+ * (`RELOAD_INVALID`) or a plugin that could not prepare (`RELOAD_FAILED`) change nothing.
1967
+ */
1968
+ export async function reload(root, timeoutMs, read = readProcess, kill = signalProcess) {
1969
+ daemonOnly();
1970
+ const record = await readRecord(root);
1971
+ if (!record)
1972
+ return { running: false };
1973
+ let proof = 'process';
1974
+ let found;
1975
+ try {
1976
+ found = Boolean(await live(record, read));
1977
+ }
1978
+ catch (error) {
1979
+ if (!processInfoFailure(error))
1980
+ throw error;
1981
+ const how = await withoutProcessInfo(root, record, error, kill);
1982
+ found = how !== 'gone';
1983
+ if (how !== 'gone')
1984
+ proof = how;
1985
+ }
1986
+ if (!found)
1987
+ return { running: false, pid: record.pid };
1988
+ const unsupported = await reloadSupport(root, record);
1989
+ if (unsupported)
1990
+ throw new CliError('RELOAD_UNSUPPORTED', unsupported, {
1991
+ details: {
1992
+ pid: record.pid,
1993
+ fix: `give the Application configFile in ${ENTRY} (new Application({ config: document.options, configFile: configPath, … }); the generated ${ENTRY} does since @dokebi/cli 0.4.26), then restart: npx dokebi stop && npx dokebi start`,
1994
+ },
1995
+ });
1996
+ const wait = timeoutMs ?? (await devReloadTimeout(root));
1997
+ const { log, reloadStatus } = paths(root);
1998
+ // The outcome file of the app (whatever its log level) is preferred; the log is the fallback.
1999
+ const before = await reloadStatusOf(reloadStatus);
2000
+ const offset = await stat(log).then((info) => info.size, (error) => {
2001
+ if (isMissing(error))
2002
+ return 0;
2003
+ throw error;
2004
+ });
2005
+ const began = performance.now();
2006
+ let via = 'signal';
2007
+ const throughWriter = async (refused) => {
2008
+ const asked = await askWriter(record, { op: 'reload' });
2009
+ if (!asked.reached)
2010
+ throw new CliError('RELOAD_FAILED', `Unable to signal pid ${record.pid}: this shell may not signal it and its log writer could not be asked (${asked.reason})`, {
2011
+ cause: refused,
2012
+ details: {
2013
+ pid: record.pid,
2014
+ fix: 'run npx dokebi reload from a shell that may signal the app (outside the sandbox), or save dokebi.yaml (the app watches it unless options.reload.watch is false)',
2015
+ },
2016
+ });
2017
+ await asked.closed(0);
2018
+ via = 'log-writer';
2019
+ };
2020
+ debug('Sending SIGHUP to reload the configuration', { pid: record.pid, proof });
2021
+ if (proof === 'log-writer')
2022
+ await throughWriter();
2023
+ else
2024
+ try {
2025
+ kill(record.pid, 'SIGHUP');
2026
+ }
2027
+ catch (error) {
2028
+ if (error.code !== 'EPERM')
2029
+ throw error;
2030
+ debug('SIGHUP refused; asking the log writer', { pid: record.pid });
2031
+ await throughWriter(error);
2032
+ }
2033
+ const until = performance.now() + wait;
2034
+ let outcome;
2035
+ while (performance.now() < until) {
2036
+ const status = await reloadStatusOf(reloadStatus);
2037
+ if (status &&
2038
+ (status.pid !== before?.pid || status.sequence !== before?.sequence) &&
2039
+ typeof status.code === 'string') {
2040
+ outcome = status;
2041
+ break;
2042
+ }
2043
+ outcome = await reloadOutcome(log, offset);
2044
+ if (outcome)
2045
+ break;
2046
+ await delay(100);
2047
+ }
2048
+ const ms = Math.round(performance.now() - began);
2049
+ const list = (value) => Array.isArray(value) ? value.slice(0, 64).map((item) => String(item)) : undefined;
2050
+ if (!outcome)
2051
+ return {
2052
+ running: true,
2053
+ pid: record.pid,
2054
+ outcome: 'unknown',
2055
+ ms,
2056
+ proof,
2057
+ via,
2058
+ warnings: [
2059
+ `no reload outcome in ${log} within ${wait} ms: the app may log above info (options.logging.level), or be busy; npx dokebi logs shows what it said`,
2060
+ ],
2061
+ };
2062
+ const code = String(outcome.code);
2063
+ const paths_ = list(outcome.paths);
2064
+ if (code === 'CONFIG_RELOADED' || code === 'CONFIG_RELOAD_UNCHANGED')
2065
+ return {
2066
+ running: true,
2067
+ pid: record.pid,
2068
+ outcome: code === 'CONFIG_RELOADED' ? 'applied' : 'unchanged',
2069
+ code,
2070
+ ...(typeof outcome.revision === 'number' ? { revision: outcome.revision } : {}),
2071
+ ...(paths_ ? { paths: paths_ } : {}),
2072
+ ms,
2073
+ proof,
2074
+ via,
2075
+ };
2076
+ const details = {
2077
+ pid: record.pid,
2078
+ code,
2079
+ ...(paths_ ? { paths: paths_ } : {}),
2080
+ ...(list(outcome.restartRequired) ? { restartRequired: list(outcome.restartRequired) } : {}),
2081
+ ...(list(outcome.issues) ? { issues: list(outcome.issues) } : {}),
2082
+ ...(typeof outcome.line === 'number' ? { line: outcome.line, column: outcome.column } : {}),
2083
+ ...(typeof outcome.plugin === 'string' ? { plugin: outcome.plugin } : {}),
2084
+ ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
2085
+ ...(typeof outcome.fix === 'string' ? { fix: outcome.fix } : {}),
2086
+ };
2087
+ if (code === 'CONFIG_RELOAD_RESTART_REQUIRED')
2088
+ throw new CliError('RELOAD_RESTART_REQUIRED', `The change needs a restart (${(list(outcome.restartRequired) ?? []).join(', ')}); nothing was applied`, {
2089
+ details: {
2090
+ ...details,
2091
+ fix: 'restart the app to apply these options: npx dokebi stop && npx dokebi start',
2092
+ },
2093
+ });
2094
+ if (code === 'CONFIG_RELOAD_INVALID')
2095
+ throw new CliError('RELOAD_INVALID', 'dokebi.yaml is invalid; the app keeps its current settings', {
2096
+ details: {
2097
+ ...details,
2098
+ fix: 'correct the named paths or line of dokebi.yaml (npx dokebi doctor checks it), then run npx dokebi reload again',
2099
+ },
2100
+ });
2101
+ throw new CliError('RELOAD_FAILED', `The app did not reload (${code}); nothing was applied`, {
2102
+ details,
2103
+ });
2104
+ }