@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  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 +638 -37
  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 +15 -0
  46. package/dist/check/shared-state-rules.js +117 -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 +381 -36
  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-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
package/dist/server.js CHANGED
@@ -30,17 +30,19 @@ import { existsSync, readFileSync, watch } from 'node:fs';
30
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
  });
@@ -434,15 +438,18 @@ function pidState(pid, kill) {
434
438
  return 'unknown';
435
439
  }
436
440
  }
437
- /** 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
+ */
438
445
  async function configuredUrl(root) {
439
446
  let source;
440
447
  try {
441
- source = await readFile(join(root, CONFIG_FILE), 'utf8');
448
+ source = (await readProjectSettings(root)).source;
442
449
  }
443
450
  catch (error) {
444
451
  if (!isMissing(error))
445
- 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);
446
453
  return undefined;
447
454
  }
448
455
  let web;
@@ -603,7 +610,7 @@ async function withoutProcessInfo(root, record, failure, kill) {
603
610
  },
604
611
  });
605
612
  }
606
- async function checkProject(root) {
613
+ export async function checkProject(root) {
607
614
  const { entry } = paths(root);
608
615
  if (!existsSync(entry))
609
616
  throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
@@ -664,6 +671,42 @@ async function tail(file, lines, current) {
664
671
  }
665
672
  return result.length ? `${result.join('\n')}\n` : '';
666
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
+ }
667
710
  /** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
668
711
  function logSettings(options) {
669
712
  const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
@@ -689,8 +732,26 @@ check = checkProject) {
689
732
  const lock = await StartLock.acquire(files.lock);
690
733
  if (!lock)
691
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;
692
749
  const warnings = [];
693
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;
694
755
  try {
695
756
  const current = await statusOf(root, readProcess, signalProcess, true);
696
757
  if (current.running)
@@ -698,12 +759,19 @@ check = checkProject) {
698
759
  details: current,
699
760
  });
700
761
  const entry = await check(root);
762
+ interrupted();
701
763
  // The project's .env under the CLI's environment (D-0073); a malformed file stops here.
702
764
  const token = randomBytes(24).toString('base64url');
703
765
  const env = appEnvironment(root, 'production', {
704
766
  ...options.env,
705
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,
706
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}`);
707
775
  // Lines this start writes begin here (the web listening line is parsed from them).
708
776
  const logOffset = await stat(files.log).then((info) => info.size, (error) => {
709
777
  if (!isMissing(error))
@@ -733,13 +801,16 @@ check = checkProject) {
733
801
  const writerPid = writer.pid;
734
802
  if (writerPid === undefined)
735
803
  throw new CliError('START_FAILED', 'The log writer process could not be spawned');
804
+ leftover = writerPid;
736
805
  const state = {};
737
806
  writer.on('message', (message) => {
738
807
  // The writer sends its control port before it spawns the app (IPC keeps the order).
739
808
  if (typeof message?.control === 'number')
740
809
  state.control = message.control;
741
- if (typeof message?.spawned === 'number')
810
+ if (typeof message?.spawned === 'number') {
742
811
  state.pid = message.spawned;
812
+ appGroup = message.spawned;
813
+ }
743
814
  if (message?.ready)
744
815
  state.ready = true;
745
816
  if (message && 'exited' in message)
@@ -757,11 +828,15 @@ check = checkProject) {
757
828
  debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
758
829
  const failure = async (message) => {
759
830
  await rm(files.pid, { force: true });
760
- 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, {
761
836
  details: {
762
837
  log: files.log,
763
838
  // App output in a result: credentials masked like npm output (logs prints it as is).
764
- tail: redactNpmOutput(await tail(files.log, 30)),
839
+ tail: redactNpmOutput(recent.slice(Math.max(mark + 1, recent.length - 31)).join('\n')),
765
840
  logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
766
841
  },
767
842
  });
@@ -771,11 +846,13 @@ check = checkProject) {
771
846
  await rm(`${files.pid}.tmp`, { force: true });
772
847
  await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
773
848
  await rename(`${files.pid}.tmp`, files.pid);
849
+ wrote = true;
774
850
  };
775
851
  const deadline = performance.now() + waitMs;
776
852
  let recorded;
777
853
  while (performance.now() < deadline) {
778
854
  await delay(50);
855
+ interrupted();
779
856
  if (state.pid !== undefined && recorded === undefined) {
780
857
  recorded = {
781
858
  pid: state.pid,
@@ -791,27 +868,29 @@ check = checkProject) {
791
868
  await save(recorded);
792
869
  debug('App process spawned; waiting for the start line', { pid: state.pid });
793
870
  }
794
- 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;
795
874
  throw await failure(`The application exited with ${state.exited}`);
875
+ }
796
876
  if (state.failed !== undefined)
797
877
  throw await failure(`The application could not be started: ${state.failed}`);
798
878
  if (state.ready && recorded !== undefined)
799
879
  break;
800
880
  }
801
- if (recorded === undefined) {
802
- // The writer never started the app: do not leave it (or a late app) behind. The writer
803
- // leads its own process group (detached), so the group signal reaches both.
804
- process.kill(-writerPid, 'SIGKILL');
881
+ if (recorded === undefined)
805
882
  throw await failure(`The log writer did not start the application within ${waitMs} ms`);
806
- }
807
883
  const ready = state.ready === true;
808
884
  if (!ready)
809
- warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
810
- 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) : [];
811
887
  // Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
812
888
  // listening address (the proof does not depend on the Host header).
813
889
  if (listen.length)
814
890
  await save({ ...recorded, urls: listen });
891
+ const urls = appUrls(listen, await appHostsOrNone(root));
892
+ interrupted();
893
+ returned = true;
815
894
  return {
816
895
  pid: recorded.pid,
817
896
  log: files.log,
@@ -819,18 +898,45 @@ check = checkProject) {
819
898
  logMaxBytes: maxBytes,
820
899
  logFiles: keep,
821
900
  ready,
822
- urls: appUrls(listen, await appHostsOrNone(root)),
901
+ urls,
823
902
  listen,
824
903
  ms: Math.round(performance.now() - began),
825
904
  warnings,
826
905
  };
827
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
+ }
828
924
  finally {
829
925
  // The writer and the app outlive this command.
830
926
  if (writer?.connected)
831
927
  writer.disconnect();
832
928
  writer?.unref();
833
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();
834
940
  }
835
941
  }
836
942
  /**
@@ -885,6 +991,38 @@ export async function appHostsOrNone(root) {
885
991
  return { hosted: false, domains: [], answersUnknown: false };
886
992
  }
887
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
+ }
888
1026
  async function startedUrls(log, offset) {
889
1027
  try {
890
1028
  const bytes = await readFile(log);
@@ -1115,7 +1253,9 @@ export async function stopStarted(root, started, timeoutMs = 30_000, force = fal
1115
1253
  * it finishes its file, then reads each rotated file newer than it (found by inode, oldest
1116
1254
  * first) before following the new app.log, so several rotations between two polls skip no line.
1117
1255
  */
1118
- 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) {
1119
1259
  const file = paths(root).log;
1120
1260
  if (!follow) {
1121
1261
  if (!existsSync(file))
@@ -1332,7 +1472,7 @@ export async function foreground(root) {
1332
1472
  }
1333
1473
  }
1334
1474
  /** The installed version of a package, looked up like Node does (node_modules up the tree). */
1335
- function installedVersion(root, name) {
1475
+ export function installedVersion(root, name) {
1336
1476
  for (let directory = root;; directory = dirname(directory)) {
1337
1477
  try {
1338
1478
  const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
@@ -1358,16 +1498,87 @@ function inertiaDevServer(root) {
1358
1498
  }
1359
1499
  /** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
1360
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
+ }
1361
1561
  /**
1362
1562
  * Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
1363
- * src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
1364
- * (HMR, D-0045) when template-inertia supports it, otherwise from watch builds that restart the
1365
- * app. With template-inertia 0.4.0 that Vite is one host process beside the app, shared by every
1366
- * 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.
1367
1571
  * The first Ctrl+C drains everything; a second one kills.
1368
1572
  */
1369
1573
  export async function dev(root, say) {
1370
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;
1371
1582
  // The project's .env (D-0073), read again before every (re)start: a change restarts the app
1372
1583
  // with the new values. A malformed file fails here and, later, fails that restart only.
1373
1584
  let fileEnv = readEnvFile(root);
@@ -1386,10 +1597,14 @@ export async function dev(root, say) {
1386
1597
  : `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
1387
1598
  }
1388
1599
  const guard = new URL('./dev-guard.js', import.meta.url).href;
1389
- // stdio fd 3 is the IPC channel the guard watches.
1390
- 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], {
1391
1605
  cwd: root,
1392
1606
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
1607
+ detached: group && PROCESS_GROUPS,
1393
1608
  // DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
1394
1609
  env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
1395
1610
  });
@@ -1415,24 +1630,52 @@ export async function dev(root, say) {
1415
1630
  return;
1416
1631
  extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
1417
1632
  }
1418
- app = child([entry], extra);
1633
+ app = child([entry], extra, true);
1419
1634
  const current = app;
1420
1635
  current.once('exit', (code, signal) => {
1421
1636
  if (app === current && !exiting)
1422
1637
  say(`app exited (${signal ?? code}); waiting for a change to restart`);
1638
+ if (current.pid !== undefined)
1639
+ void leftovers(current.pid);
1423
1640
  });
1424
1641
  };
1425
- const halt = async (target, limitMs = 15_000) => {
1426
- 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)
1427
1663
  return;
1428
- const exited = new Promise((resolve) => target.once('exit', resolve));
1429
- target.kill('SIGTERM');
1430
- const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
1431
- await exited;
1432
- 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);
1433
1676
  };
1434
- // Vite builds in watch mode beside the app. The app starts after every first build (a build
1435
- // 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.
1436
1679
  const helpers = [];
1437
1680
  const firstBuilds = [];
1438
1681
  let built = () => { };
@@ -1484,23 +1727,52 @@ export async function dev(root, say) {
1484
1727
  throw error;
1485
1728
  }
1486
1729
  let timer;
1487
- let restarting = run().catch((error) => {
1488
- // Keep the loop alive: the next change tries again.
1489
- reportError('DEV_START_FAILED', 'Starting the app failed', error);
1490
- });
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 };
1491
1734
  const changed = (what) => {
1492
1735
  if (exiting)
1493
1736
  return;
1737
+ if (what === settingsName)
1738
+ pending.config = true;
1739
+ else
1740
+ pending.other ??= what;
1494
1741
  clearTimeout(timer);
1495
1742
  timer = setTimeout(() => {
1743
+ const batch = pending;
1744
+ pending = { config: false, other: undefined };
1496
1745
  restarting = restarting
1497
1746
  .then(async () => {
1498
1747
  if (exiting)
1499
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;
1500
1772
  say(`${what} changed; restarting`);
1501
1773
  const previous = app;
1502
1774
  app = undefined;
1503
- await halt(previous);
1775
+ await halt(previous, undefined, true);
1504
1776
  if (!exiting)
1505
1777
  await run();
1506
1778
  })
@@ -1511,27 +1783,62 @@ export async function dev(root, say) {
1511
1783
  }, 300);
1512
1784
  };
1513
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)) });
1514
1794
  const watchers = [
1515
- watch(join(root, 'src'), { recursive: true }, (_event, name) => {
1516
- // Styles and components reach the app through the Vite builds (restart after the build).
1517
- if (hot && name && VIEW_FILE.test(name))
1795
+ watch(source, { recursive: true }, (_event, name) => {
1796
+ if (name && viewFile(name))
1518
1797
  return;
1519
- if (helpers.length && name && /\.(vue|css|scss|sass|less)$/.test(name))
1798
+ if (name && !watched.changedIn(source, name))
1520
1799
  return;
1521
1800
  changed(`src/${name ?? ''}`);
1522
1801
  }),
1523
1802
  watch(root, (_event, name) => {
1524
- if (name === 'dokebi.yaml' || name === '.env')
1525
- 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
+ }
1526
1809
  // A running Vite reloads its own config; a host that failed to start tries again.
1527
- else if (viteHost && name && /^vite\.config\.[cm]?[jt]s$/.test(name) && !viteHost.running)
1528
- 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
+ }
1529
1814
  }),
1530
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
+ });
1531
1834
  return await new Promise((resolve) => {
1532
1835
  const finish = async () => {
1533
1836
  if (exiting) {
1534
- 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
+ }
1535
1842
  for (const helper of helpers)
1536
1843
  helper.kill('SIGKILL');
1537
1844
  await viteHost?.stop(0, true);
@@ -1544,7 +1851,10 @@ export async function dev(root, say) {
1544
1851
  for (const watcher of watchers)
1545
1852
  watcher.close();
1546
1853
  await restarting;
1547
- 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
+ ]);
1548
1858
  // After the app: its requests and server renders go to the host until it has drained.
1549
1859
  await viteHost?.stop();
1550
1860
  resolve(0);
@@ -1553,3 +1863,242 @@ export async function dev(root, say) {
1553
1863
  process.on('SIGTERM', () => void finish());
1554
1864
  });
1555
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
+ }