@dokebi/cli 0.2.5 → 0.4.1

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 (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2203 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +5 -4
  31. package/dist/config-edit.js +9 -8
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +142 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +835 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +801 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
package/dist/server.js CHANGED
@@ -4,17 +4,46 @@
4
4
  // `.dokebi/run/app.log` and rotates it by size (`app.log.1` … `app.log.<files>`). A pid is only
5
5
  // acted on when `ps` (/proc on Linux) shows it runs this project's entry and started when the pid
6
6
  // file says, so a reused pid is never signalled.
7
+ //
8
+ // Two ways to stop (D-0065 §11):
9
+ // - `stop` (the `dokebi stop` command) acts on a pid file that may be days old: its pid may have
10
+ // been reused by an unrelated process, so it signals only after `ps` (/proc) confirms the
11
+ // command and start time. Without process information (a sandbox that blocks `ps`, D-0073) it
12
+ // asks the app instead: `start` gives every app a random token (DOKEBI_INSTANCE_TOKEN, kept in
13
+ // the pid file) and @dokebi/web answers a request carrying a challenge in `x-dokebi-instance`
14
+ // with HMAC-SHA256(token, challenge). Only a correct answer at the recorded address proves the
15
+ // pid is ours; otherwise it refuses with PROCESS_INFO_FAILED and keeps the pid file. `status`
16
+ // reports which proof it used (`proof: process | http | log-writer`).
17
+ // An agent sandbox per command (Codex CLI, AI-M03) may refuse even the signal: the app was
18
+ // started in another sandbox instance. The log writer, the app's parent from that instance,
19
+ // listens on a loopback control port (log-writer-control.ts; `control` in the pid file); after
20
+ // both sides prove the instance token, it drain-stops the app (`via: 'log-writer'`). Without ps it is asked first
21
+ // (`proof: 'log-writer'`), then the HTTP proof; an unproven instance is never signalled or asked.
22
+ // - `stopStarted` (used by `dokebi verify`) stops the process this same CLI run started seconds
23
+ // ago through the pids `start` returned: identity is known from the handle, so it needs no
24
+ // `ps` (liveness is `kill(pid, 0)`), and it works in agent sandboxes that block `ps`.
25
+ //
26
+ // Every app the CLI runs gets the project's `.env` under its own environment (D-0073).
7
27
  import { execFile, spawn } from 'node:child_process';
28
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
8
29
  import { existsSync, readFileSync, watch } from 'node:fs';
9
30
  import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
31
+ import { request as httpRequest } from 'node:http';
32
+ import { request as httpsRequest } from 'node:https';
10
33
  import { basename, dirname, join } from 'node:path';
11
34
  import { setTimeout as delay } from 'node:timers/promises';
12
35
  import { fileURLToPath } from 'node:url';
13
36
  import { promisify } from 'node:util';
14
- import { debug, debugEnabled, isMissing, reportError } from './diagnostics.js';
37
+ import { appUrls, readAppHosts } from './app-urls.js';
38
+ import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
39
+ import { debug, debugEnabled, describeError, isMissing, reportError } from './diagnostics.js';
40
+ import { appEnvironment, readEnvFile } from './env-file.js';
15
41
  import { CliError } from './errors.js';
42
+ import { askLogWriter, CONTROL_HOST } from './log-writer-control.js';
43
+ import { redactNpmOutput } from './npm-client.js';
16
44
  import { ProjectInspector } from './project-inspector.js';
17
45
  import { logFiles } from './rotating-log.js';
46
+ import { ViteHostProcess, viteHostScript } from './vite-host-process.js';
18
47
  const exec = promisify(execFile);
19
48
  const ENTRY = 'src/main.ts';
20
49
  const RUN = '.dokebi/run';
@@ -26,6 +55,9 @@ export const LOG_LIMITS = {
26
55
  files: { default: 5, min: 1, max: 100 },
27
56
  };
28
57
  const MB = 1024 * 1024;
58
+ const signalProcess = (pid, signal) => {
59
+ process.kill(pid, signal);
60
+ };
29
61
  const paths = (root) => ({
30
62
  run: join(root, RUN),
31
63
  pid: join(root, RUN, 'app.pid'),
@@ -60,6 +92,18 @@ async function readRecord(root) {
60
92
  }
61
93
  return undefined;
62
94
  }
95
+ function processInfoFailed(pid, source, cause) {
96
+ return new CliError('PROCESS_INFO_FAILED', `Unable to read process information of pid ${pid} with ${source}; the pid file is kept because the process may still run`, {
97
+ cause,
98
+ details: {
99
+ pid,
100
+ source,
101
+ // A sandbox that blocks ps (or /proc) is the usual cause: say how to go on (AI-M01).
102
+ hint: 'run this command outside the sandbox, or allow ps (macOS) or /proc (Linux) for it',
103
+ manualStop: `check the process yourself (ps -p ${pid} -o command=); if it runs this project's src/main.ts, stop it with kill -TERM ${pid} (drain), then remove .dokebi/run/app.pid`,
104
+ },
105
+ });
106
+ }
63
107
  /**
64
108
  * Linux reads /proc: slim container images (node:*-slim) have no `ps`. Start time is the boot
65
109
  * time plus the process start in clock ticks (USER_HZ, 100 on Linux).
@@ -77,7 +121,7 @@ async function procInfo(pid) {
77
121
  const ticks = Number(fields[19]);
78
122
  const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
79
123
  if (!Number.isFinite(ticks) || !Number.isFinite(boot))
80
- return undefined;
124
+ throw processInfoFailed(pid, '/proc', new Error('Unexpected /proc stat content'));
81
125
  return {
82
126
  startedAt: (boot + ticks / 100) * 1000,
83
127
  rssKb: Number(/^VmRSS:\s+(\d+)/m.exec(status)?.[1] ?? 0),
@@ -85,37 +129,46 @@ async function procInfo(pid) {
85
129
  };
86
130
  }
87
131
  catch (error) {
88
- // ENOENT: no such process. Anything else (permissions, a changed /proc) is reported.
89
- if (!isMissing(error))
90
- reportError('PROCESS_INFO_FAILED', 'Unable to read process information from /proc', error);
91
- return undefined;
132
+ // ENOENT: no such process. Anything else (permissions, a changed /proc) is a failure.
133
+ if (error instanceof CliError)
134
+ throw error;
135
+ if (isMissing(error))
136
+ return undefined;
137
+ throw processInfoFailed(pid, '/proc', error);
92
138
  }
93
139
  }
94
- async function processInfo(pid) {
140
+ const readProcess = async (pid) => {
95
141
  if (process.platform === 'linux')
96
142
  return procInfo(pid);
143
+ let stdout;
97
144
  try {
98
- const { stdout } = await exec('ps', ['-ww', '-o', 'lstart=,rss=,command=', '-p', String(pid)], {
145
+ ({ stdout } = await exec('ps', ['-ww', '-o', 'lstart=,rss=,command=', '-p', String(pid)], {
99
146
  env: { ...process.env, LC_ALL: 'C' },
100
- });
101
- // lstart is a fixed 24-character date: "Thu Oct 15 00:21:26 2026".
102
- const line = stdout.trim();
103
- const startedAt = Date.parse(line.slice(0, 24));
104
- const [rss = '', ...command] = line.slice(24).trim().split(/\s+/);
105
- if (!Number.isFinite(startedAt))
106
- return undefined;
107
- return { startedAt, rssKb: Number(rss), command: command.join(' ') };
147
+ }));
108
148
  }
109
149
  catch (error) {
110
- // ps exits 1 when the pid does not exist; anything else (no ps, killed) is reported.
111
- if (error.code !== 1)
112
- reportError('PROCESS_INFO_FAILED', 'Unable to read process information with ps', error);
113
- return undefined;
150
+ // ps exits 1 with no output when the pid does not exist; anything else (no ps, a sandbox
151
+ // that refuses it, killed) means the process state is unknown.
152
+ const failed = error;
153
+ if (failed.code === 1 &&
154
+ !String(failed.stdout ?? '').trim() &&
155
+ !String(failed.stderr ?? '').trim())
156
+ return undefined;
157
+ throw processInfoFailed(pid, 'ps', error);
114
158
  }
115
- }
159
+ // lstart is a fixed 24-character date: "Thu Oct 15 00:21:26 2026".
160
+ const line = stdout.trim();
161
+ if (!line)
162
+ return undefined;
163
+ const startedAt = Date.parse(line.slice(0, 24));
164
+ const [rss = '', ...command] = line.slice(24).trim().split(/\s+/);
165
+ if (!Number.isFinite(startedAt))
166
+ throw processInfoFailed(pid, 'ps', new Error(`Unexpected ps output: ${line.slice(0, 200)}`));
167
+ return { startedAt, rssKb: Number(rss), command: command.join(' ') };
168
+ };
116
169
  /** The recorded process, when it is still alive and still this project's process. */
117
- async function live(record) {
118
- const info = await processInfo(record.pid);
170
+ async function live(record, read) {
171
+ const info = await read(record.pid);
119
172
  if (!info?.command.includes(record.entry)) {
120
173
  if (info)
121
174
  debug('Recorded pid runs another command; treating it as stale', { pid: record.pid });
@@ -129,10 +182,10 @@ async function live(record) {
129
182
  return info;
130
183
  }
131
184
  /** Whether the record's log writer still runs (its command names the writer and the entry). */
132
- async function writerAlive(record) {
185
+ async function writerAlive(record, read) {
133
186
  if (record.logWriter === undefined)
134
187
  return false;
135
- const info = await processInfo(record.logWriter);
188
+ const info = await read(record.logWriter);
136
189
  return Boolean(info?.command.includes('log-writer') && info.command.includes(record.entry));
137
190
  }
138
191
  async function logInfo(root) {
@@ -152,14 +205,29 @@ async function logInfo(root) {
152
205
  const writerBytes = await sizes(writerLog);
153
206
  return { logFiles: files, ...(writerBytes ? { logWriterLog: writerLog } : {}) };
154
207
  }
155
- export async function status(root) {
208
+ export async function status(root,
209
+ /** Tests replace how a process is read; the CLI uses ps (/proc on Linux). */
210
+ read = readProcess,
211
+ /** Tests replace how a process is signalled; the CLI uses process.kill. */
212
+ kill = signalProcess) {
156
213
  const { pid, log } = paths(root);
157
214
  const record = await readRecord(root);
158
215
  const logs = await logInfo(root);
159
216
  if (!record)
160
217
  return { running: false, log, ...logs };
161
- const info = await live(record);
162
- if (!info) {
218
+ let info;
219
+ let proof = 'process';
220
+ try {
221
+ info = await live(record, read);
222
+ }
223
+ catch (error) {
224
+ if (!processInfoFailure(error))
225
+ throw error;
226
+ const found = await withoutProcessInfo(root, record, error, kill);
227
+ if (found !== 'gone')
228
+ proof = found;
229
+ }
230
+ if (!info && proof === 'process') {
163
231
  await rm(pid, { force: true });
164
232
  return {
165
233
  running: false,
@@ -175,14 +243,212 @@ export async function status(root) {
175
243
  pid: record.pid,
176
244
  startedAt: record.startedAt,
177
245
  uptimeSec: Math.round((Date.now() - Date.parse(record.startedAt)) / 1000),
178
- rssMb: Math.round(info.rssKb / 1024),
246
+ ...(info ? { rssMb: Math.round(info.rssKb / 1024) } : {}),
247
+ proof,
179
248
  log,
180
249
  ...logs,
181
250
  ...(record.logWriter !== undefined
182
- ? { logWriter: { pid: record.logWriter, running: await writerAlive(record) } }
251
+ ? {
252
+ logWriter: {
253
+ pid: record.logWriter,
254
+ running: proof === 'process'
255
+ ? await writerAlive(record, read)
256
+ : proof === 'log-writer' || pidState(record.logWriter, kill) === 'alive',
257
+ },
258
+ }
183
259
  : {}),
184
260
  };
185
261
  }
262
+ /** The variable `start` gives the app (D-0073); @dokebi/web answers the challenge with it. */
263
+ export const INSTANCE_TOKEN_ENV = 'DOKEBI_INSTANCE_TOKEN';
264
+ /** The challenge request header and the answer response header of @dokebi/web. */
265
+ export const INSTANCE_HEADER = 'x-dokebi-instance';
266
+ /** The answer to a challenge: base64url HMAC-SHA256 keyed with the instance token. */
267
+ export function instanceAnswer(token, challenge) {
268
+ return createHmac('sha256', token).update(challenge).digest('base64url');
269
+ }
270
+ /** `kill(pid, 0)`: ESRCH is gone; EPERM (or a sandbox refusing signals) says nothing for sure. */
271
+ function pidState(pid, kill) {
272
+ try {
273
+ kill(pid, 0);
274
+ return 'alive';
275
+ }
276
+ catch (error) {
277
+ if (error.code === 'ESRCH')
278
+ return 'gone';
279
+ debug('kill(pid, 0) failed; the pid state is unknown', { pid, error: describeError(error) });
280
+ return 'unknown';
281
+ }
282
+ }
283
+ /** The web address dokebi.yaml configures, for pid files that recorded none. */
284
+ async function configuredUrl(root) {
285
+ let source;
286
+ try {
287
+ source = await readFile(join(root, CONFIG_FILE), 'utf8');
288
+ }
289
+ catch (error) {
290
+ if (!isMissing(error))
291
+ reportError('CONFIG_READ_FAILED', `Unable to read ${CONFIG_FILE}`, error);
292
+ return undefined;
293
+ }
294
+ let web;
295
+ try {
296
+ web = (parseProjectConfig(source).options['dokebi-web'] ?? {});
297
+ }
298
+ catch (error) {
299
+ // doctor reports an invalid dokebi.yaml; here it only means there is no address to try.
300
+ debug('dokebi.yaml could not be parsed; no configured address', {
301
+ error: describeError(error),
302
+ });
303
+ return undefined;
304
+ }
305
+ if (typeof web.port !== 'number' || web.port <= 0)
306
+ return undefined;
307
+ const host = typeof web.host !== 'string' || ['0.0.0.0', '::', '[::]'].includes(web.host)
308
+ ? '127.0.0.1'
309
+ : web.host;
310
+ return `http://${host.includes(':') ? `[${host}]` : host}:${web.port}`;
311
+ }
312
+ /** One challenge request; resolves with the answer header (undefined when there is none). */
313
+ function challenge(url, value, timeoutMs) {
314
+ return new Promise((resolve, reject) => {
315
+ const target = new URL(url);
316
+ const https = target.protocol === 'https:';
317
+ const req = (https ? httpsRequest : httpRequest)({
318
+ host: target.hostname.replace(/^\[|\]$/g, ''),
319
+ port: target.port,
320
+ path: '/',
321
+ method: 'GET',
322
+ headers: { [INSTANCE_HEADER]: value },
323
+ timeout: timeoutMs,
324
+ // The HMAC answer is the proof; a development certificate need not be trusted for it.
325
+ ...(https ? { rejectUnauthorized: false } : {}),
326
+ }, (response) => {
327
+ response.once('error', reject);
328
+ response.once('end', () => {
329
+ const answer = response.headers[INSTANCE_HEADER];
330
+ resolve(typeof answer === 'string' ? answer : undefined);
331
+ });
332
+ response.resume();
333
+ });
334
+ req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
335
+ req.once('error', reject);
336
+ req.end();
337
+ });
338
+ }
339
+ /**
340
+ * Whether the app at the recorded address knows the record's instance token (D-0073): a random
341
+ * challenge must come back as HMAC-SHA256(token, challenge). The token never goes on the wire.
342
+ */
343
+ export async function proveInstance(root, record, timeoutMs = 2000) {
344
+ if (!record.token)
345
+ return {
346
+ proven: false,
347
+ tried: [],
348
+ reason: 'the pid file has no instance token (started by an older CLI)',
349
+ };
350
+ const recorded = record.urls ?? [];
351
+ const configured = recorded.length ? undefined : await configuredUrl(root);
352
+ const urls = [
353
+ ...recorded.filter((url) => url.includes('127.0.0.1')),
354
+ ...recorded.filter((url) => !url.includes('127.0.0.1')),
355
+ ...(configured ? [configured] : []),
356
+ ].slice(0, 4);
357
+ if (!urls.length)
358
+ return {
359
+ proven: false,
360
+ tried: [],
361
+ reason: 'no address to ask: the app logged no web listening line and dokebi.yaml has no dokebi-web.port',
362
+ };
363
+ const reasons = [];
364
+ for (const url of urls) {
365
+ const value = randomBytes(24).toString('base64url');
366
+ const expected = Buffer.from(instanceAnswer(record.token, value));
367
+ try {
368
+ const answer = await challenge(url, value, timeoutMs);
369
+ const given = Buffer.from(answer ?? '');
370
+ if (given.length === expected.length && timingSafeEqual(given, expected)) {
371
+ debug('The app proved its instance token', { url });
372
+ return { proven: true, url, tried: urls };
373
+ }
374
+ reasons.push(`${url}: ${answer === undefined ? `answered without ${INSTANCE_HEADER}` : 'wrong answer'}`);
375
+ }
376
+ catch (error) {
377
+ reasons.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
378
+ }
379
+ }
380
+ debug('No instance proof', { tried: urls, reasons });
381
+ return { proven: false, tried: urls, reason: reasons.join('; ') };
382
+ }
383
+ const processInfoFailure = (error) => error instanceof CliError && error.code === 'PROCESS_INFO_FAILED';
384
+ /**
385
+ * Asks the record's log writer (its control socket) to do `request` once both sides proved the
386
+ * record's instance token, and checks that the writer's app is the recorded pid.
387
+ */
388
+ async function askWriter(record, request) {
389
+ if (!record.token)
390
+ return {
391
+ reached: false,
392
+ reason: 'the pid file has no instance token (started by an older CLI)',
393
+ };
394
+ if (record.control === undefined)
395
+ return {
396
+ reached: false,
397
+ reason: 'the pid file names no control port (started by an older CLI, or the log writer could not listen: see .dokebi/run/log-writer.log)',
398
+ };
399
+ const asked = await askLogWriter(record.control, record.token, request);
400
+ if (!asked.reached)
401
+ return asked;
402
+ if (asked.app.pid !== record.pid) {
403
+ await asked.closed(0);
404
+ return {
405
+ reached: false,
406
+ reason: `the log writer runs pid ${asked.app.pid}, not the recorded pid ${record.pid}`,
407
+ };
408
+ }
409
+ return { reached: true, running: asked.app.running, reply: asked.reply, closed: asked.closed };
410
+ }
411
+ /**
412
+ * Without process information: `gone` (no such pid, or its log writer saw it exit),
413
+ * `log-writer` (the app's log writer proved the token on its control socket), `http` (the app
414
+ * proved the token over HTTP), or the original PROCESS_INFO_FAILED with what the proofs found,
415
+ * so an unproven pid is never used.
416
+ */
417
+ async function withoutProcessInfo(root, record, failure, kill) {
418
+ const state = pidState(record.pid, kill);
419
+ if (state === 'gone') {
420
+ debug('No process information, but the recorded pid does not exist', { pid: record.pid });
421
+ return 'gone';
422
+ }
423
+ // The log writer first: it needs no web plugin, and it can stop the app when this shell may
424
+ // not signal it (an agent sandbox per command, AI-M03).
425
+ const writer = await askWriter(record, { op: 'status' });
426
+ if (writer.reached) {
427
+ debug('The log writer proved the instance token', { pid: record.pid, running: writer.running });
428
+ return writer.running ? 'log-writer' : 'gone';
429
+ }
430
+ const tried = record.control !== undefined ? [`tcp://${CONTROL_HOST}:${record.control}`] : [];
431
+ const reasons = [`log writer: ${writer.reason}`];
432
+ if (state === 'alive') {
433
+ const proof = await proveInstance(root, record);
434
+ if (proof.proven)
435
+ return 'http';
436
+ tried.push(...proof.tried);
437
+ if (proof.reason)
438
+ reasons.push(`http: ${proof.reason}`);
439
+ }
440
+ // kill(pid, 0) refused (EPERM): a sandbox or another user. The HTTP proof would not help:
441
+ // the stop signal itself would be refused too (AI-M03).
442
+ else
443
+ reasons.push(`pid ${record.pid} cannot be signalled from this shell (a sandbox or another user): stopping needs its log writer or a shell that may signal it`);
444
+ throw new CliError(failure.code, failure.message, {
445
+ cause: failure.cause ?? failure,
446
+ details: {
447
+ ...failure.details,
448
+ instanceProof: { tried, reason: reasons.join('; ') },
449
+ },
450
+ });
451
+ }
186
452
  async function checkProject(root) {
187
453
  const { entry } = paths(root);
188
454
  if (!existsSync(entry))
@@ -196,10 +462,6 @@ async function checkProject(root) {
196
462
  });
197
463
  return entry;
198
464
  }
199
- const environment = (mode) => ({
200
- ...process.env,
201
- NODE_ENV: process.env.NODE_ENV ?? mode,
202
- });
203
465
  /**
204
466
  * The last `lines` lines of one file (reading at most its last `maxBytes`). An open handle is
205
467
  * read up to `end` (the size it had when opened) and left open.
@@ -272,8 +534,20 @@ check = checkProject) {
272
534
  details: current,
273
535
  });
274
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
+ });
275
543
  await mkdir(files.run, { recursive: true });
276
544
  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
+ });
277
551
  // One start at a time; a lock left by a start that died more than a minute ago is stale.
278
552
  try {
279
553
  await writeFile(files.lock, String(process.pid), { flag: 'wx' });
@@ -309,7 +583,8 @@ check = checkProject) {
309
583
  cwd: root,
310
584
  detached: true,
311
585
  stdio: ['ignore', 'ignore', writerLog.fd, 'ipc'],
312
- env: environment('production'),
586
+ // The writer starts the app with its own environment.
587
+ env,
313
588
  });
314
589
  }
315
590
  finally {
@@ -320,6 +595,9 @@ check = checkProject) {
320
595
  throw new CliError('START_FAILED', 'The log writer process could not be spawned');
321
596
  const state = {};
322
597
  writer.on('message', (message) => {
598
+ // The writer sends its control port before it spawns the app (IPC keeps the order).
599
+ if (typeof message?.control === 'number')
600
+ state.control = message.control;
323
601
  if (typeof message?.spawned === 'number')
324
602
  state.pid = message.spawned;
325
603
  if (message?.ready)
@@ -342,18 +620,24 @@ check = checkProject) {
342
620
  return new CliError('START_FAILED', message, {
343
621
  details: {
344
622
  log: files.log,
345
- tail: await tail(files.log, 30),
346
- logWriterLog: (await lastLines(files.writerLog, 30)).join('\n'),
623
+ // App output in a result: credentials masked like npm output (logs prints it as is).
624
+ tail: redactNpmOutput(await tail(files.log, 30)),
625
+ logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
347
626
  },
348
627
  });
349
628
  };
629
+ // The pid file holds the instance token: only the owner reads it (0600).
630
+ const save = async (record) => {
631
+ await rm(`${files.pid}.tmp`, { force: true });
632
+ await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
633
+ await rename(`${files.pid}.tmp`, files.pid);
634
+ };
350
635
  const deadline = performance.now() + waitMs;
351
636
  let recorded;
352
637
  while (performance.now() < deadline) {
353
638
  await delay(50);
354
639
  if (state.pid !== undefined && recorded === undefined) {
355
- recorded = state.pid;
356
- const record = {
640
+ recorded = {
357
641
  pid: state.pid,
358
642
  startedAt: new Date().toISOString(),
359
643
  entry,
@@ -361,9 +645,10 @@ check = checkProject) {
361
645
  logWriter: writerPid,
362
646
  logMaxBytes: maxBytes,
363
647
  logFiles: keep,
648
+ token,
649
+ ...(state.control !== undefined ? { control: state.control } : {}),
364
650
  };
365
- await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`);
366
- await rename(`${files.pid}.tmp`, files.pid);
651
+ await save(recorded);
367
652
  debug('App process spawned; waiting for the start line', { pid: state.pid });
368
653
  }
369
654
  if (state.exited !== undefined)
@@ -382,13 +667,20 @@ check = checkProject) {
382
667
  const ready = state.ready === true;
383
668
  if (!ready)
384
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) : [];
671
+ // Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
672
+ // listening address (the proof does not depend on the Host header).
673
+ if (listen.length)
674
+ await save({ ...recorded, urls: listen });
385
675
  return {
386
- pid: recorded,
676
+ pid: recorded.pid,
387
677
  log: files.log,
388
678
  logWriter: writerPid,
389
679
  logMaxBytes: maxBytes,
390
680
  logFiles: keep,
391
681
  ready,
682
+ urls: appUrls(listen, await appHostsOrNone(root)),
683
+ listen,
392
684
  ms: Math.round(performance.now() - began),
393
685
  warnings,
394
686
  };
@@ -401,16 +693,115 @@ check = checkProject) {
401
693
  await rm(files.lock, { force: true });
402
694
  }
403
695
  }
404
- export async function stop(root, timeoutMs = 30_000, force = false) {
696
+ /**
697
+ * HTTP URLs from `Web server listening` log lines (D-0065): `address`, `addresses` or `urls`
698
+ * fields as strings (`http://…`) or `{ address, port }` objects. Unknown shapes are skipped.
699
+ */
700
+ export function listeningUrls(text) {
701
+ const urls = new Set();
702
+ const add = (value) => {
703
+ if (typeof value === 'string' && /^https?:\/\//.test(value))
704
+ urls.add(value.replace(/\/$/, ''));
705
+ else if (value && typeof value === 'object') {
706
+ const { address, port } = value;
707
+ if (typeof address === 'string' && typeof port === 'number')
708
+ urls.add(`http://${address.includes(':') ? `[${address}]` : address}:${port}`);
709
+ }
710
+ };
711
+ for (const line of text.split('\n')) {
712
+ if (!/listening/i.test(line))
713
+ continue;
714
+ let record;
715
+ try {
716
+ record = JSON.parse(line);
717
+ }
718
+ catch (error) {
719
+ // Plain output of the app, not a JSON log line: it carries no address.
720
+ debug('Skipped a non-JSON log line while looking for addresses', {
721
+ error: error instanceof Error ? error.message : String(error),
722
+ });
723
+ continue;
724
+ }
725
+ if (typeof record.message !== 'string' || !/web server listening/i.test(record.message))
726
+ continue;
727
+ for (const key of ['address', 'addresses', 'url', 'urls']) {
728
+ const value = record[key];
729
+ for (const item of Array.isArray(value) ? value : [value])
730
+ add(item);
731
+ }
732
+ }
733
+ return [...urls];
734
+ }
735
+ /**
736
+ * The app host settings for the shown URLs; when they cannot be read the listening addresses are
737
+ * shown instead (the app already runs: this is no reason to fail the command).
738
+ */
739
+ export async function appHostsOrNone(root) {
740
+ try {
741
+ return await readAppHosts(root);
742
+ }
743
+ catch (error) {
744
+ reportError('APP_HOSTS_FAILED', 'Unable to read the app domains; the listening addresses are shown instead', error);
745
+ return { hosted: false, domains: [], answersUnknown: false };
746
+ }
747
+ }
748
+ async function startedUrls(log, offset) {
749
+ try {
750
+ const bytes = await readFile(log);
751
+ // A rotation since the start moved the lines: read the whole current file then.
752
+ return listeningUrls((bytes.length >= offset ? bytes.subarray(offset) : bytes).toString('utf8'));
753
+ }
754
+ catch (error) {
755
+ if (!isMissing(error))
756
+ reportError('LOG_READ_FAILED', 'Unable to read the app log', error);
757
+ return [];
758
+ }
759
+ }
760
+ export async function stop(root, timeoutMs = 30_000, force = false,
761
+ /** Tests replace how a process is read; the CLI uses ps (/proc on Linux). */
762
+ read = readProcess,
763
+ /** Tests replace how a process is signalled; the CLI uses process.kill. */
764
+ kill = signalProcess) {
405
765
  daemonOnly();
406
766
  const files = paths(root);
407
767
  const record = await readRecord(root);
408
768
  if (!record)
409
769
  return { stopped: false };
410
- if (!(await live(record))) {
770
+ // Without process information the app's log writer or the app itself must prove the instance
771
+ // token (D-0073); otherwise PROCESS_INFO_FAILED propagates and the pid file stays (a live
772
+ // server is not orphaned, an unproven pid is never signalled).
773
+ let proof = 'process';
774
+ let found;
775
+ try {
776
+ found = Boolean(await live(record, read));
777
+ }
778
+ catch (error) {
779
+ if (!processInfoFailure(error))
780
+ throw error;
781
+ const how = await withoutProcessInfo(root, record, error, kill);
782
+ found = how !== 'gone';
783
+ if (how !== 'gone')
784
+ proof = how;
785
+ }
786
+ if (!found) {
787
+ debug('Removing a stale pid file', { pid: record.pid });
411
788
  await rm(files.pid, { force: true });
412
789
  return { stopped: false, pid: record.pid, stale: true };
413
790
  }
791
+ if (proof === 'log-writer')
792
+ return stopThroughWriter(root, record, timeoutMs, force, proof);
793
+ // SIGTERM goes to the app (core drain); the log writer exits once the app's output ends.
794
+ debug('Sending SIGTERM', { pid: record.pid, timeoutMs, proof });
795
+ try {
796
+ kill(record.pid, 'SIGTERM');
797
+ }
798
+ catch (error) {
799
+ if (error.code !== 'EPERM')
800
+ throw error;
801
+ // The app runs in another sandbox instance (AI-M03): its log writer may signal it.
802
+ debug('SIGTERM refused; asking the log writer', { pid: record.pid, proof });
803
+ return stopThroughWriter(root, record, timeoutMs, force, proof, error);
804
+ }
414
805
  const began = performance.now();
415
806
  const gone = async (alive, limit) => {
416
807
  const until = performance.now() + limit;
@@ -421,30 +812,160 @@ export async function stop(root, timeoutMs = 30_000, force = false) {
421
812
  }
422
813
  return false;
423
814
  };
424
- const app = async () => Boolean(await live(record));
425
- // SIGTERM goes to the app (core drain); the log writer exits once the app's output ends.
426
- debug('Sending SIGTERM', { pid: record.pid, timeoutMs });
427
- process.kill(record.pid, 'SIGTERM');
815
+ // Proven over HTTP: liveness is kill(pid, 0) from here on, as in stopStarted.
816
+ const app = proof === 'process'
817
+ ? async () => Boolean(await live(record, read))
818
+ : async () => pidExists(record.pid, kill);
819
+ const writer = proof === 'process'
820
+ ? () => writerAlive(record, read)
821
+ : async () => record.logWriter !== undefined && pidExists(record.logWriter, kill);
428
822
  let forced = false;
429
823
  if (!(await gone(app, timeoutMs))) {
430
824
  if (!force)
431
825
  throw new CliError('STOP_TIMEOUT', `pid ${record.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`);
432
826
  debug('Stop timed out; sending SIGKILL', { pid: record.pid });
433
- process.kill(record.pid, 'SIGKILL');
827
+ kill(record.pid, 'SIGKILL');
434
828
  forced = true;
435
829
  if (!(await gone(app, 5000)))
436
830
  throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
437
831
  }
438
832
  await rm(files.pid, { force: true });
439
833
  const warnings = [];
440
- if (record.logWriter !== undefined && !(await gone(() => writerAlive(record), 5000))) {
834
+ // The app is stopped and its pid file removed: a failure to read the writer is a warning.
835
+ const writerGone = await gone(writer, 5000).catch((error) => {
836
+ reportError('PROCESS_INFO_FAILED', 'Unable to check the log writer after stop', error);
837
+ warnings.push(`could not check the log writer (pid ${record.logWriter}): ${error instanceof Error ? error.message : String(error)}`);
838
+ return true;
839
+ });
840
+ if (record.logWriter !== undefined && !writerGone) {
841
+ debug('Log writer still running after the app stopped', { writer: record.logWriter });
842
+ warnings.push(`the log writer (pid ${record.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
843
+ }
844
+ return {
845
+ stopped: true,
846
+ pid: record.pid,
847
+ ms: Math.round(performance.now() - began),
848
+ forced,
849
+ proof,
850
+ via: 'signal',
851
+ ...(warnings.length ? { warnings } : {}),
852
+ };
853
+ }
854
+ /**
855
+ * The stop through the app's log writer (D-0073 addendum): it sends SIGTERM, waits `timeoutMs`
856
+ * and, with `force`, SIGKILL. Then the pid file goes and the writer's exit is awaited (it closes
857
+ * the connection). An unreachable or unproven writer leaves everything as it was.
858
+ */
859
+ async function stopThroughWriter(root, record, timeoutMs, force, proof, refused) {
860
+ const files = paths(root);
861
+ const began = performance.now();
862
+ const asked = await askWriter(record, { op: 'stop', timeoutMs, force });
863
+ if (!asked.reached)
864
+ throw new CliError('STOP_FAILED', `Unable to stop pid ${record.pid}: this shell may not signal it (a sandbox or another user) and its log writer could not be asked (${asked.reason})`, {
865
+ cause: refused,
866
+ details: {
867
+ pid: record.pid,
868
+ logWriter: asked.reason,
869
+ hint: 'run dokebi stop from a shell that may signal the app (outside the sandbox)',
870
+ manualStop: `kill -TERM ${record.pid} from such a shell (drain), then remove .dokebi/run/app.pid`,
871
+ },
872
+ });
873
+ const { reply } = asked;
874
+ if (reply.ok !== true) {
875
+ await asked.closed(0);
876
+ throw new CliError(reply.code === 'STOP_TIMEOUT' ? 'STOP_TIMEOUT' : 'STOP_FAILED', typeof reply.message === 'string'
877
+ ? reply.message
878
+ : `The log writer could not stop pid ${record.pid} (${String(reply.code)})`);
879
+ }
880
+ await rm(files.pid, { force: true });
881
+ if (reply.stopped !== true) {
882
+ debug('The log writer reports that the app had already exited', { pid: record.pid });
883
+ await asked.closed(0);
884
+ return { stopped: false, pid: record.pid, stale: true };
885
+ }
886
+ const warnings = [];
887
+ // The writer closes the connection once it finished the log and exits.
888
+ if (!(await asked.closed(5000))) {
441
889
  debug('Log writer still running after the app stopped', { writer: record.logWriter });
442
890
  warnings.push(`the log writer (pid ${record.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
443
891
  }
892
+ debug('The log writer stopped the app', { pid: record.pid, forced: reply.forced });
444
893
  return {
445
894
  stopped: true,
446
895
  pid: record.pid,
447
896
  ms: Math.round(performance.now() - began),
897
+ forced: reply.forced === true,
898
+ proof,
899
+ via: 'log-writer',
900
+ ...(warnings.length ? { warnings } : {}),
901
+ };
902
+ }
903
+ /** Whether a pid exists, by `kill(pid, 0)` (no `ps`): ESRCH is gone, anything else throws. */
904
+ function pidExists(pid, kill = signalProcess) {
905
+ try {
906
+ kill(pid, 0);
907
+ return true;
908
+ }
909
+ catch (error) {
910
+ if (error.code === 'ESRCH')
911
+ return false;
912
+ throw new CliError('STOP_FAILED', `Unable to signal pid ${pid}`, {
913
+ cause: error,
914
+ details: { pid },
915
+ });
916
+ }
917
+ }
918
+ /**
919
+ * Stops the app this CLI run started (`start`'s result), without `ps` (see the file comment):
920
+ * SIGTERM, wait for the drain, SIGKILL after `timeoutMs` when `force`, then the pid file (when it
921
+ * still names this process) and a wait for the log writer. An app that already exited by itself
922
+ * is `{ stopped: false, stale: true }`.
923
+ */
924
+ export async function stopStarted(root, started, timeoutMs = 30_000, force = false) {
925
+ daemonOnly();
926
+ const files = paths(root);
927
+ const forget = async () => {
928
+ // 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 });
931
+ };
932
+ const gone = async (pid, limit) => {
933
+ const until = performance.now() + limit;
934
+ while (performance.now() < until) {
935
+ if (!pidExists(pid))
936
+ return true;
937
+ await delay(100);
938
+ }
939
+ return !pidExists(pid);
940
+ };
941
+ if (!pidExists(started.pid)) {
942
+ debug('The started app had already exited', { pid: started.pid });
943
+ await forget();
944
+ return { stopped: false, pid: started.pid, stale: true };
945
+ }
946
+ const began = performance.now();
947
+ debug('Sending SIGTERM to the started app', { pid: started.pid, timeoutMs });
948
+ process.kill(started.pid, 'SIGTERM');
949
+ let forced = false;
950
+ if (!(await gone(started.pid, timeoutMs))) {
951
+ if (!force)
952
+ throw new CliError('STOP_TIMEOUT', `pid ${started.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck`);
953
+ debug('Stop timed out; sending SIGKILL', { pid: started.pid });
954
+ process.kill(started.pid, 'SIGKILL');
955
+ forced = true;
956
+ if (!(await gone(started.pid, 5000)))
957
+ throw new CliError('STOP_TIMEOUT', `pid ${started.pid} survived SIGKILL`);
958
+ }
959
+ await forget();
960
+ const warnings = [];
961
+ if (!(await gone(started.logWriter, 5000))) {
962
+ debug('Log writer still running after the app stopped', { writer: started.logWriter });
963
+ warnings.push(`the log writer (pid ${started.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
964
+ }
965
+ return {
966
+ stopped: true,
967
+ pid: started.pid,
968
+ ms: Math.round(performance.now() - began),
448
969
  forced,
449
970
  ...(warnings.length ? { warnings } : {}),
450
971
  };
@@ -656,7 +1177,8 @@ export async function foreground(root) {
656
1177
  const child = spawn(process.execPath, [entry], {
657
1178
  cwd: root,
658
1179
  stdio: 'inherit',
659
- env: environment('production'),
1180
+ // The project's .env under the CLI's environment (D-0073).
1181
+ env: appEnvironment(root, 'production'),
660
1182
  });
661
1183
  const forward = (signal) => () => child.kill(signal);
662
1184
  const onInt = forward('SIGINT');
@@ -701,31 +1223,61 @@ const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less
701
1223
  /**
702
1224
  * Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
703
1225
  * src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
704
- * inside the app (HMR, D-0045) when template-inertia supports it, otherwise from watch builds
705
- * that restart the app. The first Ctrl+C drains everything; a second one kills.
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.
1229
+ * The first Ctrl+C drains everything; a second one kills.
706
1230
  */
707
1231
  export async function dev(root, say) {
708
1232
  const entry = await checkProject(root);
1233
+ // The project's .env (D-0073), read again before every (re)start: a change restarts the app
1234
+ // with the new values. A malformed file fails here and, later, fails that restart only.
1235
+ let fileEnv = readEnvFile(root);
709
1236
  let app;
710
1237
  let exiting = false;
711
1238
  const hasVite = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'].some((name) => existsSync(join(root, name)));
712
1239
  const hot = hasVite && inertiaDevServer(root);
713
1240
  if (hot)
714
1241
  say('Inertia views: Vite dev server with hot module replacement');
1242
+ // Where to open the app (AI-M03): its domains, not the listening IP the app logs.
1243
+ const configured = await configuredUrl(root);
1244
+ if (configured) {
1245
+ const urls = appUrls([configured], await appHostsOrNone(root));
1246
+ say(urls.length
1247
+ ? `the app answers at ${urls.join(' ')}`
1248
+ : `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
1249
+ }
715
1250
  const guard = new URL('./dev-guard.js', import.meta.url).href;
716
1251
  // stdio fd 3 is the IPC channel the guard watches.
717
- const child = (args) => spawn(process.execPath, ['--import', guard, ...args], {
1252
+ const child = (args, extra = {}) => spawn(process.execPath, ['--import', guard, ...args], {
718
1253
  cwd: root,
719
1254
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
720
1255
  // DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
721
- env: {
722
- ...environment('development'),
723
- DOKEBI_DEV: '1',
724
- ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}),
725
- },
1256
+ env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
726
1257
  });
727
- const run = () => {
728
- app = child([entry]);
1258
+ // One Vite for the session (D-0045 addendum 3) when template-inertia has the host script.
1259
+ const hostScript = hot ? viteHostScript(root) : undefined;
1260
+ const viteHost = hostScript ? new ViteHostProcess(() => child([hostScript]), say) : undefined;
1261
+ debug('dev Vite mode', { hot, host: hostScript !== undefined });
1262
+ const run = async () => {
1263
+ try {
1264
+ fileEnv = readEnvFile(root);
1265
+ }
1266
+ catch (error) {
1267
+ if (error instanceof CliError)
1268
+ say(`${error.message}; fix it and save again`);
1269
+ // Reported by the caller (DEV_START_FAILED / DEV_RESTART_FAILED); the next change retries.
1270
+ throw error;
1271
+ }
1272
+ let extra = {};
1273
+ if (viteHost) {
1274
+ const address = await viteHost.ensure();
1275
+ // Said by the host process; the next change starts it again.
1276
+ if (!address || exiting)
1277
+ return;
1278
+ extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
1279
+ }
1280
+ app = child([entry], extra);
729
1281
  const current = app;
730
1282
  current.once('exit', (code, signal) => {
731
1283
  if (app === current && !exiting)
@@ -793,9 +1345,11 @@ export async function dev(root, say) {
793
1345
  await Promise.all(helpers.map((helper) => halt(helper, 5000)));
794
1346
  throw error;
795
1347
  }
796
- run();
797
1348
  let timer;
798
- let restarting = Promise.resolve();
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
+ });
799
1353
  const changed = (what) => {
800
1354
  if (exiting)
801
1355
  return;
@@ -810,7 +1364,7 @@ export async function dev(root, say) {
810
1364
  app = undefined;
811
1365
  await halt(previous);
812
1366
  if (!exiting)
813
- run();
1367
+ await run();
814
1368
  })
815
1369
  .catch((error) => {
816
1370
  // Keep the loop alive: the next change tries again.
@@ -831,6 +1385,9 @@ export async function dev(root, say) {
831
1385
  watch(root, (_event, name) => {
832
1386
  if (name === 'dokebi.yaml' || name === '.env')
833
1387
  changed(name);
1388
+ // 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);
834
1391
  }),
835
1392
  ];
836
1393
  return await new Promise((resolve) => {
@@ -839,6 +1396,7 @@ export async function dev(root, say) {
839
1396
  app?.kill('SIGKILL');
840
1397
  for (const helper of helpers)
841
1398
  helper.kill('SIGKILL');
1399
+ await viteHost?.stop(0, true);
842
1400
  resolve(130);
843
1401
  return;
844
1402
  }
@@ -849,6 +1407,8 @@ export async function dev(root, say) {
849
1407
  watcher.close();
850
1408
  await restarting;
851
1409
  await Promise.all([halt(app), ...helpers.map((helper) => halt(helper, 5000))]);
1410
+ // After the app: its requests and server renders go to the host until it has drained.
1411
+ await viteHost?.stop();
852
1412
  resolve(0);
853
1413
  };
854
1414
  process.on('SIGINT', () => void finish());