@dokebi/cli 0.4.2 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +518 -35
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +2 -0
  46. package/dist/check/shared-state-rules.js +114 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +307 -31
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +20 -2
  110. package/dist/install-module.js +45 -8
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +17 -2
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +40 -4
  124. package/dist/npm-client.js +152 -15
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +195 -16
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3006 -333
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +43 -4
  170. package/dist/server.js +601 -52
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -1,7 +1,7 @@
1
1
  import { path as paths } from '@dokebi/core/libraries';
2
+ import { validateDefaults } from './config-values.js';
2
3
  import { CliError } from './errors.js';
3
4
  import { object, relativePath, templateTarget } from './filesystem.js';
4
- import { validateDefaults } from './merge-install-defaults.js';
5
5
  /**
6
6
  * Who edits a written file (D-0065 §11): `user` — a scaffold written once for the developer to
7
7
  * change (layouts, views, examples); doctor never reports an edit, upgrade keeps an edited copy.
@@ -71,8 +71,10 @@ export function readInstallPlan(value, legacy, extended = true) {
71
71
  try {
72
72
  validateDefaults(plan.configDefaults);
73
73
  }
74
- catch {
75
- throw new CliError('INVALID_MANIFEST', 'Invalid install configuration defaults');
74
+ catch (cause) {
75
+ throw new CliError('INVALID_MANIFEST', 'Invalid install configuration defaults', {
76
+ cause,
77
+ });
76
78
  }
77
79
  configDefaults = plan.configDefaults;
78
80
  }
package/dist/layout.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { InstallPlan } from './install-plan.js';
2
2
  /**
3
3
  * Project layout (D-0031, D-0032). Plugins write only into
4
- * - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
4
+ * - `src/shared/<area>/…` — shared business code: models, services, modules (D-0032), views,
5
+ * mail templates and locales (the consumer-facing rule is "Files and folders" in
6
+ * capability-guide.ts, D-0092 §3)
5
7
  * - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
6
8
  * Files sit inside such a folder; plugins may also create the folder itself.
7
9
  * The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
@@ -16,3 +18,12 @@ export declare function assertLayout(plan: Pick<InstallPlan, 'files' | 'director
16
18
  }, pluginId: string, hosts?: readonly string[]): void;
17
19
  /** Installed entry paths from before D-0031 keep working; doctor reports them once. */
18
20
  export declare function legacyLayout(path: string, hosts?: readonly string[]): boolean;
21
+ /**
22
+ * Source files older recipes wrote outside the "Files and folders" rule (the src root holds only
23
+ * main.ts and dokebi.generated.ts), mapped to where the current recipe writes them. Such a file
24
+ * keeps working where it is: doctor reports it as a LEGACY_LAYOUT warning that names the new
25
+ * place, and a re-run of the recipe keeps using the old file instead of writing a second copy.
26
+ * Moving it stays the developer's step because the file is user code (its relative imports and
27
+ * the import in src/main.ts change with it).
28
+ */
29
+ export declare const MOVED_RECIPE_FILES: Readonly<Record<string, string>>;
package/dist/layout.js CHANGED
@@ -2,7 +2,9 @@ import { path as paths } from '@dokebi/core/libraries';
2
2
  import { CliError } from './errors.js';
3
3
  /**
4
4
  * Project layout (D-0031, D-0032). Plugins write only into
5
- * - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
5
+ * - `src/shared/<area>/…` — shared business code: models, services, modules (D-0032), views,
6
+ * mail templates and locales (the consumer-facing rule is "Files and folders" in
7
+ * capability-guide.ts, D-0092 §3)
6
8
  * - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
7
9
  * Files sit inside such a folder; plugins may also create the folder itself.
8
10
  * The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
@@ -10,11 +12,11 @@ import { CliError } from './errors.js';
10
12
  */
11
13
  export function layoutAllowed(path, hosts = [], kind = 'file') {
12
14
  const key = paths.portablePathKey(path);
13
- // Files live inside a named folder: src/shared/<area>/… (models, modules, views — D-0032) or
14
- // src/infra/<name>/…
15
+ // Files live inside a named folder: src/shared/<area>/… (models, services, modules, views, mail,
16
+ // locales — D-0032, D-0092) or src/infra/<name>/…
15
17
  const area = kind === 'file'
16
- ? /^src\/(shared\/(models|modules|views)|infra\/[^/]+)\/.+$/
17
- : /^src\/(shared\/(models|modules|views)|infra\/[^/]+)(\/.+)?$/;
18
+ ? /^src\/(shared\/(models|services|modules|views|mail|locales)|infra\/[^/]+)\/.+$/
19
+ : /^src\/(shared\/(models|services|modules|views|mail|locales)|infra\/[^/]+)(\/.+)?$/;
18
20
  if (area.test(key))
19
21
  return true;
20
22
  return hosts.includes('apps') && (key === 'src/apps' || key.startsWith('src/apps/'));
@@ -28,9 +30,20 @@ export function assertLayout(plan, pluginId, hosts = []) {
28
30
  ];
29
31
  for (const [target, kind] of targets)
30
32
  if (!layoutAllowed(target, hosts, kind))
31
- throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,modules,views}/ and src/infra/<name>/: ${target}`);
33
+ throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,services,modules,views,mail,locales}/ and src/infra/<name>/: ${target}`);
32
34
  }
33
35
  /** Installed entry paths from before D-0031 keep working; doctor reports them once. */
34
36
  export function legacyLayout(path, hosts = []) {
35
37
  return !layoutAllowed(path, hosts);
36
38
  }
39
+ /**
40
+ * Source files older recipes wrote outside the "Files and folders" rule (the src root holds only
41
+ * main.ts and dokebi.generated.ts), mapped to where the current recipe writes them. Such a file
42
+ * keeps working where it is: doctor reports it as a LEGACY_LAYOUT warning that names the new
43
+ * place, and a re-run of the recipe keeps using the old file instead of writing a second copy.
44
+ * Moving it stays the developer's step because the file is user code (its relative imports and
45
+ * the import in src/main.ts change with it).
46
+ */
47
+ export const MOVED_RECIPE_FILES = Object.freeze({
48
+ 'src/site-worker.ts': 'src/infra/runtime/site-worker.ts',
49
+ });
@@ -28,6 +28,8 @@ export type ControlRequest = {
28
28
  op: 'stop';
29
29
  timeoutMs: number;
30
30
  force: boolean;
31
+ } | {
32
+ op: 'reload';
31
33
  };
32
34
  /** The writer answered: its proven identity and its reply to the request. */
33
35
  export interface ControlReached {
@@ -13,10 +13,12 @@
13
13
  // client → { challenge: C, answer: HMAC(token, 'dokebi-log-writer:client:' + W) }
14
14
  // writer → { ok: false, code: 'UNAUTHORIZED' } and closes, or
15
15
  // { ok: true, answer: HMAC(token, 'dokebi-log-writer:writer:' + C), writer, app }
16
- // client → { op: 'status' } | { op: 'stop', timeoutMs, force } (only after checking answer)
16
+ // client → { op: 'status' } | { op: 'stop', timeoutMs, force } | { op: 'reload' }
17
+ // (only after checking answer)
17
18
  // writer → status: { ok: true, app: { pid, running } }
18
19
  // stop: { ok: true, stopped, forced, ms } | { ok: false, code: 'STOP_TIMEOUT', message }
19
- // Only `status` and `stop` exist. The writer closes the connection when it exits, so a client
20
+ // reload: { ok: true, signalled } (D-0154: SIGHUP to the app; it reads dokebi.yaml again)
21
+ // Only `status`, `stop` and `reload` exist. The writer closes the connection when it exits, so a client
20
22
  // that asked for a stop knows the writer finished (its log closed) when the connection ends.
21
23
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
22
24
  import { createConnection, createServer } from 'node:net';
@@ -278,6 +280,19 @@ export class ControlServer {
278
280
  lines.socket.end();
279
281
  return;
280
282
  }
283
+ if (request.op === 'reload') {
284
+ // D-0154: the CLI checked that the app reads dokebi.yaml again on SIGHUP before asking.
285
+ const running = app.running();
286
+ if (running)
287
+ app.signal('SIGHUP');
288
+ debug('Signalled the app to reload its configuration on a control request', {
289
+ pid: app.pid,
290
+ running,
291
+ });
292
+ lines.send({ ok: true, signalled: running });
293
+ lines.socket.end();
294
+ return;
295
+ }
281
296
  const timeoutMs = request.timeoutMs;
282
297
  if (request.op !== 'stop' ||
283
298
  !Number.isSafeInteger(timeoutMs) ||
@@ -17,6 +17,7 @@ import { spawn } from 'node:child_process';
17
17
  import { setTimeout as delay } from 'node:timers/promises';
18
18
  import { debug, describeError, reportError } from './diagnostics.js';
19
19
  import { ControlServer } from './log-writer-control.js';
20
+ import { endLeftovers, PROCESS_GROUPS } from './process-group.js';
20
21
  import { forwardOutput, LineBuffer, RotatingLog } from './rotating-log.js';
21
22
  /** Written by the generated `src/main.ts` once `app.start()` resolved. */
22
23
  const STARTED = 'Dokebi application started';
@@ -24,6 +25,11 @@ const STARTED = 'Dokebi application started';
24
25
  const DRAIN_MS = 3000;
25
26
  /** An unfinished line is written after this much quiet. */
26
27
  const PARTIAL_MS = 200;
28
+ /**
29
+ * How long processes the app left in its group get after SIGTERM before SIGKILL (less than
30
+ * DRAIN_MS, so one that holds the pipes is gone before the writer stops reading them).
31
+ */
32
+ const LEFTOVER_MS = 2000;
27
33
  function send(message, then) {
28
34
  if (!process.connected) {
29
35
  then?.();
@@ -79,13 +85,33 @@ function run(file, maxBytes, keep, entry, control) {
79
85
  return;
80
86
  }
81
87
  log.write(Buffer.from(`\n--- dokebi start ${new Date().toISOString()} ---\n`));
82
- const app = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
83
- // `ready` goes to the CLI only after the start line is in app.log (forwardOutput).
84
- const write = forwardOutput(log, STARTED, () => {
85
- debug('Start line written to the app log', { pid: app.pid });
88
+ // DOKEBI_LOG_WRITER (D-0151): this writer rotates app.log, so @dokebi/logger skips its file
89
+ // targets here (no second set of rotated files) unless a target sets withDokebiStart.
90
+ // The IPC channel carries the generated main's `{ dokebi: 'started' }` (AI-M43): a
91
+ // logging.level above info hides the start line, and the app is still up.
92
+ // The app leads a process group of its own (POSIX): what it starts and leaves behind when it
93
+ // exits (stopped, crashed or killed) is found there and ended, never left holding a port.
94
+ const app = spawn(process.execPath, [entry], {
95
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
96
+ env: { ...process.env, DOKEBI_LOG_WRITER: '1' },
97
+ detached: PROCESS_GROUPS,
98
+ });
99
+ let announced = false;
100
+ const ready = (how) => {
101
+ if (announced)
102
+ return;
103
+ announced = true;
104
+ debug('App ready', { pid: app.pid, how });
86
105
  send({ ready: true });
106
+ };
107
+ app.on('message', (message) => {
108
+ if (message?.dokebi === 'started')
109
+ ready('ipc message');
87
110
  });
111
+ // `ready` goes to the CLI after the start line is in app.log (forwardOutput), or on the message.
112
+ const write = forwardOutput(log, STARTED, () => ready('start line'));
88
113
  let exit;
114
+ let leftovers = Promise.resolve('none');
89
115
  let open = 2;
90
116
  let finished = false;
91
117
  let drainExpired = false;
@@ -93,13 +119,18 @@ function run(file, maxBytes, keep, entry, control) {
93
119
  if (finished || !exit || (open > 0 && !drainExpired))
94
120
  return;
95
121
  finished = true;
96
- const how = exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`;
97
- log.write(Buffer.from(`--- dokebi: app exited (${how}) ---\n`));
98
- log.close();
99
- debug('Log writer finished', { pid: app.pid, code: exit.code, signal: exit.signal });
100
122
  const { code, signal } = exit;
101
- // A client waiting on the control port learns that the writer is done when it closes.
102
- void (control?.close() ?? Promise.resolve()).then(() => send({ exited: code, signal }, () => process.exit(0)));
123
+ const how = signal ? `signal ${signal}` : `code ${code}`;
124
+ log.write(Buffer.from(`--- dokebi: app exited (${how}) ---\n`));
125
+ void leftovers.then((ended) => {
126
+ // Said in app.log, next to the app's own last lines.
127
+ if (ended !== 'none')
128
+ log.write(Buffer.from(`--- dokebi: processes the app started were still running; ended them (${ended === 'killed' ? 'SIGKILL' : 'SIGTERM'}) ---\n`));
129
+ log.close();
130
+ debug('Log writer finished', { pid: app.pid, code, signal, leftovers: ended });
131
+ // A client waiting on the control port learns that the writer is done when it closes.
132
+ return (control?.close() ?? Promise.resolve()).then(() => send({ exited: code, signal }, () => process.exit(0)));
133
+ });
103
134
  };
104
135
  app.once('error', (error) => {
105
136
  // spawn failed (the app never ran): there is no exit event to wait for.
@@ -117,6 +148,8 @@ function run(file, maxBytes, keep, entry, control) {
117
148
  app.once('exit', (code, signal) => {
118
149
  exit = { code, signal };
119
150
  debug('App process exited; draining its output', { pid: app.pid, code, signal });
151
+ if (app.pid !== undefined)
152
+ leftovers = endLeftovers(app.pid, LEFTOVER_MS);
120
153
  setTimeout(() => {
121
154
  if (finished)
122
155
  return;
@@ -1,7 +1,7 @@
1
1
  /** Levels accepted by core for `options.logging` (D-0047). */
2
2
  export declare const LOG_LEVELS: readonly ['debug', 'info', 'warn', 'error', 'silent'];
3
3
  export interface LoggingDiagnostic {
4
- code: 'INVALID_LOGGING_CONFIG' | 'LOGGING_PLUGIN_UNKNOWN';
4
+ code: 'INVALID_LOGGING_CONFIG' | 'LOGGING_PLUGIN_UNKNOWN' | 'LOGGING_OPTIONS_UNUSED';
5
5
  path: string;
6
6
  plugin: string | null;
7
7
  severity: 'error' | 'warning';
@@ -9,6 +9,11 @@ export interface LoggingDiagnostic {
9
9
  }
10
10
  /**
11
11
  * Checks `options.logging` the way core does at startup (an invalid value is an error there),
12
- * and warns about per-plugin levels for plugins this project does not have.
12
+ * and warns about per-plugin levels for plugins this project does not have. D-0151: keys other
13
+ * than level and plugins belong to the logger host (@dokebi/logger), whose options schema the
14
+ * plugin checks validate; without one installed they are a warning (core's console logger does
15
+ * not read them and warns LOGGING_OPTIONS_UNUSED at startup).
13
16
  */
14
- export declare function loggingDiagnostics(options: Record<string, unknown>, plugins: ReadonlySet<string>): LoggingDiagnostic[];
17
+ export declare function loggingDiagnostics(options: Record<string, unknown>, plugins: ReadonlySet<string>, loggerHost?: string,
18
+ /** The settings file the options come from (R4CA-10: the one DOKEBI_CONFIG names, M50). */
19
+ file?: string): LoggingDiagnostic[];
@@ -5,13 +5,18 @@ const isLevel = (value) => typeof value === 'string' && LOG_LEVELS.includes(valu
5
5
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
6
6
  /**
7
7
  * Checks `options.logging` the way core does at startup (an invalid value is an error there),
8
- * and warns about per-plugin levels for plugins this project does not have.
8
+ * and warns about per-plugin levels for plugins this project does not have. D-0151: keys other
9
+ * than level and plugins belong to the logger host (@dokebi/logger), whose options schema the
10
+ * plugin checks validate; without one installed they are a warning (core's console logger does
11
+ * not read them and warns LOGGING_OPTIONS_UNUSED at startup).
9
12
  */
10
- export function loggingDiagnostics(options, plugins) {
13
+ export function loggingDiagnostics(options, plugins, loggerHost,
14
+ /** The settings file the options come from (R4CA-10: the one DOKEBI_CONFIG names, M50). */
15
+ file = CONFIG_FILE) {
11
16
  const value = options.logging;
12
17
  if (value === undefined || value === null)
13
18
  return [];
14
- const base = `${CONFIG_FILE}#options.logging`;
19
+ const base = `${file}#options.logging`;
15
20
  const invalid = (path) => ({
16
21
  code: 'INVALID_LOGGING_CONFIG',
17
22
  path,
@@ -19,9 +24,18 @@ export function loggingDiagnostics(options, plugins) {
19
24
  severity: 'error',
20
25
  hint: `use one of: ${LOG_LEVELS.join(', ')}`,
21
26
  });
22
- if (!isObject(value) || Object.keys(value).some((key) => key !== 'level' && key !== 'plugins'))
23
- return [{ ...invalid(base), hint: 'logging takes only level and plugins' }];
27
+ if (!isObject(value))
28
+ return [{ ...invalid(base), hint: 'logging is an object' }];
24
29
  const result = [];
30
+ const others = Object.keys(value).filter((key) => key !== 'level' && key !== 'plugins');
31
+ if (others.length && loggerHost === undefined)
32
+ result.push({
33
+ code: 'LOGGING_OPTIONS_UNUSED',
34
+ path: base,
35
+ plugin: null,
36
+ severity: 'warning',
37
+ hint: `core reads only level and plugins; ${others.sort().join(', ')} need @dokebi/logger (npx dokebi add @dokebi/logger) or can be removed`,
38
+ });
25
39
  if (value.level !== undefined && !isLevel(value.level))
26
40
  result.push(invalid(`${base}.level`));
27
41
  if (value.plugins === undefined || value.plugins === null)
@@ -1,3 +1,4 @@
1
+ import { CliError } from './errors.js';
1
2
  import { type FileOwner } from './install-plan.js';
2
3
  export interface Dependency {
3
4
  name: string;
@@ -43,9 +44,19 @@ export declare const POINT: RegExp;
43
44
  * (`domains`, `apps`) and ships the app skeletons (`templates/app*`, `templates/inertia-build`).
44
45
  * - `modules`: receives the modules of `dokebi module add|remove` (src/shared/modules/<name>/
45
46
  * module.ts appended to `entry.modules`) and ships their skeletons (`templates/module*`).
47
+ * - `logger` (D-0151): provides the application logger. Its package exports
48
+ * `createLogger({ options })`; the generated registration exports a `createLogger` that calls
49
+ * it (src/main.ts passes the result to `new Application({ logger })`) and hands the logger to
50
+ * the plugin as `entry.logger`. Its options are `options.logging` (the block core reads for
51
+ * levels), not `options.<id>`. It ships no templates.
46
52
  */
47
- export declare const HOST_ROLES: readonly ['apps', 'modules'];
53
+ export declare const HOST_ROLES: readonly ['apps', 'modules', 'logger'];
48
54
  export type HostRole = (typeof HOST_ROLES)[number];
55
+ /**
56
+ * Where a plugin's options live in dokebi.yaml: `options.<id>`, except the logger host's, which
57
+ * are `options.logging` (D-0151).
58
+ */
59
+ export declare function optionsKey(plugin: Pick<PluginManifest, 'id' | 'hosts'>): string;
49
60
  /**
50
61
  * The installed plugin that hosts `role` (D-0070). More than one is HOST_CONFLICT; none is
51
62
  * HOST_MISSING, whose fix names the command that installs one.
@@ -66,6 +77,23 @@ export declare function readManifest(value: unknown, options?: {
66
77
  }): PluginManifest;
67
78
  /** Reads a manifest previously normalized into .dokebi/state.json. */
68
79
  export declare function readStoredManifest(value: unknown): PluginManifest;
80
+ /** Why a missing dependency was not installed with the change (D-0116). */
81
+ export type MissingReason = 'not-installed' | 'no-package' | 'needs-choice' | 'needs-settings';
82
+ /**
83
+ * MISSING_DEPENDENCY (D-0116): `plugin` (at its version) requires `dependency`, which the project
84
+ * does not have. The message names both and the version; `details.fix` is the command that adds
85
+ * it (`npx dokebi add <package>`, then `afterwards` when given). Raised before anything is written.
86
+ */
87
+ export declare function missingDependency(plugin: Pick<PluginManifest, 'id' | 'version'>, dependency: Dependency, options?: {
88
+ reason?: MissingReason;
89
+ /** The npm package, when the manifest does not name it (the catalog's). */
90
+ package?: string;
91
+ /** Sentence after the first one of the message: why it was not installed. */
92
+ why?: string;
93
+ /** The command to run after the add (for example the same upgrade again). */
94
+ afterwards?: string;
95
+ extra?: Record<string, unknown>;
96
+ }): CliError;
69
97
  export declare function orderPlugins(plugins: PluginManifest[], coreVersion: string, requiredPlugins?: ReadonlyArray<{
70
98
  id: string;
71
99
  }>): PluginManifest[];
package/dist/manifest.js CHANGED
@@ -1,5 +1,7 @@
1
+ import lt from 'semver/functions/lt.js';
1
2
  import satisfies from 'semver/functions/satisfies.js';
2
3
  import valid from 'semver/functions/valid.js';
4
+ import minVersion from 'semver/ranges/min-version.js';
3
5
  import validRange from 'semver/ranges/valid.js';
4
6
  import { CliError } from './errors.js';
5
7
  import { object, relativePath } from './filesystem.js';
@@ -16,8 +18,20 @@ export const POINT = /^[a-z0-9][a-z0-9._/-]{0,95}:[a-z0-9][a-z0-9._-]{0,63}$/;
16
18
  * (`domains`, `apps`) and ships the app skeletons (`templates/app*`, `templates/inertia-build`).
17
19
  * - `modules`: receives the modules of `dokebi module add|remove` (src/shared/modules/<name>/
18
20
  * module.ts appended to `entry.modules`) and ships their skeletons (`templates/module*`).
21
+ * - `logger` (D-0151): provides the application logger. Its package exports
22
+ * `createLogger({ options })`; the generated registration exports a `createLogger` that calls
23
+ * it (src/main.ts passes the result to `new Application({ logger })`) and hands the logger to
24
+ * the plugin as `entry.logger`. Its options are `options.logging` (the block core reads for
25
+ * levels), not `options.<id>`. It ships no templates.
19
26
  */
20
- export const HOST_ROLES = ['apps', 'modules'];
27
+ export const HOST_ROLES = ['apps', 'modules', 'logger'];
28
+ /**
29
+ * Where a plugin's options live in dokebi.yaml: `options.<id>`, except the logger host's, which
30
+ * are `options.logging` (D-0151).
31
+ */
32
+ export function optionsKey(plugin) {
33
+ return plugin.hosts?.includes('logger') ? 'logging' : plugin.id;
34
+ }
21
35
  /** At most this many points per list (core's definePlugin limit). */
22
36
  const MAX_POINTS = 64;
23
37
  /**
@@ -71,7 +85,13 @@ function readRoles(metadata) {
71
85
  }
72
86
  function field(value, label) {
73
87
  if (typeof value !== 'string' || !value)
74
- throw new CliError('INVALID_MANIFEST', label);
88
+ throw new CliError('INVALID_MANIFEST', `The plugin manifest field ${label} must be a non-empty string`, {
89
+ details: {
90
+ field: label,
91
+ fix: 'the package was published with a broken dokebi manifest: use another version, or report it to its authors',
92
+ },
93
+ phase: 'plan',
94
+ });
75
95
  return value;
76
96
  }
77
97
  function readDependencies(value, extended) {
@@ -197,6 +217,35 @@ export function readStoredManifest(value) {
197
217
  },
198
218
  }, { checkLayout: false });
199
219
  }
220
+ /** Whether `installed` is below every version of `range` (an upgrade of it can satisfy it). */
221
+ function providerTooOld(installed, range) {
222
+ const lowest = minVersion(range);
223
+ return lowest !== null && lt(installed, lowest);
224
+ }
225
+ /**
226
+ * MISSING_DEPENDENCY (D-0116): `plugin` (at its version) requires `dependency`, which the project
227
+ * does not have. The message names both and the version; `details.fix` is the command that adds
228
+ * it (`npx dokebi add <package>`, then `afterwards` when given). Raised before anything is written.
229
+ */
230
+ export function missingDependency(plugin, dependency, options = {}) {
231
+ const pkg = dependency.package ?? options.package;
232
+ const add = pkg ? `npx dokebi add ${pkg}` : `npx dokebi plugins search ${dependency.name} --json`;
233
+ return new CliError('MISSING_DEPENDENCY', `${plugin.id} ${plugin.version} requires ${dependency.name} ${dependency.version}, which is not installed${options.why ? `; ${options.why}` : ''}`, {
234
+ details: {
235
+ plugin: plugin.id,
236
+ version: plugin.version,
237
+ dependency: dependency.name,
238
+ range: dependency.version,
239
+ package: pkg ?? null,
240
+ reason: options.reason ?? 'not-installed',
241
+ ...options.extra,
242
+ fix: pkg
243
+ ? `${add}${options.afterwards ? `, then ${options.afterwards}` : ''}`
244
+ : `${add} (find the package of ${dependency.name}), add it with npx dokebi add <package>${options.afterwards ? `, then ${options.afterwards}` : ''}`,
245
+ },
246
+ phase: 'plan',
247
+ });
248
+ }
200
249
  export function orderPlugins(plugins, coreVersion, requiredPlugins = []) {
201
250
  const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
202
251
  if (byId.size !== plugins.length)
@@ -208,9 +257,26 @@ export function orderPlugins(plugins, coreVersion, requiredPlugins = []) {
208
257
  if (visited.has(plugin.id))
209
258
  return;
210
259
  if (visiting.has(plugin.id))
211
- throw new CliError('DEPENDENCY_CYCLE', plugin.id);
260
+ throw new CliError('DEPENDENCY_CYCLE', `The plugin dependencies form a cycle through ${plugin.id}: ${[...visiting, plugin.id].join(' -> ')}`, {
261
+ details: {
262
+ plugin: plugin.id,
263
+ cycle: [...visiting, plugin.id],
264
+ fix: `the manifests of these packages depend on each other; install versions that do not (npx dokebi plugins info ${plugin.id} --json), or report it to the package authors`,
265
+ },
266
+ phase: 'plan',
267
+ });
212
268
  if (!satisfies(coreVersion, plugin.core))
213
- throw new CliError('INCOMPATIBLE_VERSION', plugin.id);
269
+ throw new CliError('INCOMPATIBLE_VERSION', `${plugin.id} ${plugin.version} needs @dokebi/core ${plugin.core}, but the project has core ${coreVersion}`, {
270
+ details: {
271
+ plugin: plugin.id,
272
+ version: plugin.version,
273
+ requires: '@dokebi/core',
274
+ range: plugin.core,
275
+ installed: coreVersion,
276
+ fix: `use a version of ${plugin.packageName} made for core ${coreVersion} (npx dokebi plugins info ${plugin.id} --json lists the compatible one)`,
277
+ },
278
+ phase: 'plan',
279
+ });
214
280
  visiting.add(plugin.id);
215
281
  for (const dep of plugin.dependencies) {
216
282
  if (requiredPlugins.some((required) => required.id === dep.name))
@@ -219,10 +285,22 @@ export function orderPlugins(plugins, coreVersion, requiredPlugins = []) {
219
285
  if (!provider) {
220
286
  if (dep.optional)
221
287
  continue;
222
- throw new CliError('MISSING_DEPENDENCY', dep.name);
288
+ throw missingDependency(plugin, dep);
223
289
  }
224
290
  if (!satisfies(provider.version, dep.version))
225
- throw new CliError('INCOMPATIBLE_VERSION', dep.name);
291
+ throw new CliError('INCOMPATIBLE_VERSION', `${plugin.id} ${plugin.version} requires ${dep.name} ${dep.version}, but ${provider.version} is installed`, {
292
+ details: {
293
+ plugin: plugin.id,
294
+ version: plugin.version,
295
+ requires: dep.name,
296
+ range: dep.version,
297
+ installed: provider.version,
298
+ fix: providerTooOld(provider.version, dep.version)
299
+ ? `npx dokebi upgrade ${dep.name} first (a version in ${dep.version}), then repeat this command`
300
+ : `use a version of ${plugin.packageName} made for ${dep.name} ${provider.version} (npx dokebi plugins info ${plugin.id} --json)`,
301
+ },
302
+ phase: 'plan',
303
+ });
226
304
  visit(provider);
227
305
  }
228
306
  visiting.delete(plugin.id);
@@ -1,2 +1 @@
1
- export declare function validateDefaults(value: unknown): asserts value is Record<string, unknown>;
2
1
  export declare function mergeInstallDefaults(source: string, id: string, defaults: unknown): string;
@@ -1,49 +1,9 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { isMap, parseDocument } from 'yaml';
2
+ import { isMap, isScalar, parseDocument } from 'yaml';
3
+ import { isPlainRecord, validateDefaults } from './config-values.js';
3
4
  import { parseProjectConfig, renderConfig } from './configuration.js';
5
+ import { debug } from './diagnostics.js';
4
6
  import { CliError } from './errors.js';
5
- function invalid() {
6
- throw new CliError('INVALID_INSTALL_DEFAULTS', 'Plugin defaults must be bounded JSON values within their own configuration namespace.');
7
- }
8
- function isRecord(value) {
9
- return (typeof value === 'object' &&
10
- value !== null &&
11
- !Array.isArray(value) &&
12
- Object.getPrototypeOf(value) === Object.prototype);
13
- }
14
- export function validateDefaults(value) {
15
- if (!isRecord(value))
16
- invalid();
17
- let count = 0;
18
- const seen = new WeakSet();
19
- const visit = (item, depth) => {
20
- count += 1;
21
- if (count > 2000 || depth > 16)
22
- invalid();
23
- if (item === null || typeof item === 'string' || typeof item === 'boolean')
24
- return;
25
- if (typeof item === 'number' && Number.isFinite(item))
26
- return;
27
- if (typeof item !== 'object' || item === null || seen.has(item))
28
- invalid();
29
- seen.add(item);
30
- if (Array.isArray(item)) {
31
- for (const child of item)
32
- visit(child, depth + 1);
33
- return;
34
- }
35
- if (!isRecord(item))
36
- invalid();
37
- for (const [key, child] of Object.entries(item)) {
38
- if (['__proto__', 'constructor', 'prototype', '<<'].includes(key))
39
- invalid();
40
- visit(child, depth + 1);
41
- }
42
- };
43
- visit(value, 0);
44
- if (Buffer.byteLength(JSON.stringify(value)) > 1024 * 1024)
45
- invalid();
46
- }
47
7
  export function mergeInstallDefaults(source, id, defaults) {
48
8
  validateDefaults(defaults);
49
9
  const before = parseProjectConfig(source);
@@ -56,14 +16,17 @@ export function mergeInstallDefaults(source, id, defaults) {
56
16
  target.set(key, document.createNode(value));
57
17
  else {
58
18
  const current = target.get(key, true);
59
- if (isMap(current) && isRecord(value))
19
+ if (isMap(current) && isPlainRecord(value))
60
20
  merge(current, value);
61
21
  }
62
22
  }
63
23
  };
64
24
  if (!document.hasIn(['options']))
65
25
  document.setIn(['options'], document.createNode({}));
66
- if (!document.hasIn(['options', id]))
26
+ const section = document.getIn(['options', id], true);
27
+ // An empty section (`dokebi-web:` alone is null) takes the defaults like a missing one; before,
28
+ // they were dropped without a word.
29
+ if (!document.hasIn(['options', id]) || (isScalar(section) && section.value === null))
67
30
  document.setIn(['options', id], document.createNode(defaults));
68
31
  else {
69
32
  const target = document.getIn(['options', id], true);
@@ -73,7 +36,10 @@ export function mergeInstallDefaults(source, id, defaults) {
73
36
  const result = renderConfig(document);
74
37
  const after = parseProjectConfig(result);
75
38
  for (const [key, value] of Object.entries(before.options))
76
- if (key !== id && !isDeepStrictEqual(value, after.options[key]))
77
- invalid();
39
+ if (key !== id && !isDeepStrictEqual(value, after.options[key])) {
40
+ debug('Plugin defaults changed another namespace', { plugin: id, namespace: key });
41
+ throw new CliError('INVALID_INSTALL_DEFAULTS', 'Plugin defaults must be bounded JSON values within their own configuration namespace.', { details: { reason: `changes options.${key}`, plugin: id } });
42
+ }
43
+ debug('Plugin defaults merged', { plugin: id, keys: Object.keys(defaults).length });
78
44
  return result;
79
45
  }
@@ -1,10 +1,44 @@
1
+ /** How long one npm install, pack or workspace install may take by default (2 minutes). */
2
+ export declare const NPM_TIMEOUT_MS = 120000;
3
+ /** The environment variable that sets that limit in milliseconds (D-0110 §4). */
4
+ export declare const NPM_TIMEOUT_ENV = "DOKEBI_NPM_TIMEOUT_MS";
5
+ /** The shortest and the longest limit the variable may set: 1 second and 24 hours. */
6
+ export declare const NPM_TIMEOUT_RANGE: {
7
+ readonly min: 1000;
8
+ readonly max: 86400000;
9
+ };
10
+ /**
11
+ * The time limit of the npm commands NpmClient runs: `DOKEBI_NPM_TIMEOUT_MS` (a whole number of
12
+ * milliseconds within NPM_TIMEOUT_RANGE) or NPM_TIMEOUT_MS. A slow registry or a large first
13
+ * install may need more; anything else in the variable is INVALID_ENV, never a silent default.
14
+ */
15
+ export declare function npmTimeoutMs(env?: NodeJS.ProcessEnv): number;
16
+ /**
17
+ * D-0138: what NPM_FAILED says when npm's code is ERESOLVE. The newest catalog packages fit each
18
+ * other (scripts/catalog/peers.ts), but a newer one may need a newer version of a package the
19
+ * project has (session 0.4.4 needs web ^0.4.3); add and upgrade without a version pick one that
20
+ * fits, and a named version gets details.fix (project.ts #peerConflict). Otherwise the conflict
21
+ * comes from another package or from a registry that is not the one the project was installed
22
+ * from; neither is fixed by forcing npm.
23
+ */
24
+ export declare const ERESOLVE_HINT = "npm could not fit the packages' peer ranges (stderr names them: 'Found: <installed>' and 'peer <package>@<range> from <new package>'). Do not retry with --legacy-peer-deps or --force: they install a combination the packages do not support. When the new package needs a newer version of one the project has, upgrade that one first (npx dokebi upgrade <its plugin id>) and repeat the command. Otherwise check that npm config get @dokebi:registry names the registry in package-lock.json's resolved URLs, and that the package named in stderr is a version this project's core supports.";
1
25
  export declare function packageSpec(spec: string, cwd: string): string;
2
26
  export declare function dependencyMap(pkg: Record<string, unknown>): Record<string, string>;
27
+ /**
28
+ * The project-relative path of a `file:<path>.tgz` dependency that stays inside the project
29
+ * (`file:vendor/web-0.4.3.tgz`, as npm writes a tarball installed from the project), else undefined.
30
+ * The staging install gets a copy at the same path, so package.json and package-lock.json keep it
31
+ * relative and the project can move (2026-10-28).
32
+ */
33
+ export declare function projectTarball(value: string): string | undefined;
3
34
  export declare function validateProjectPackage(pkg: Record<string, unknown>): void;
4
35
  export declare class NpmClient {
5
36
  #private;
6
- /** Installs into a standalone directory (the project or the CLI's staging). */
7
- install(directory: string, spec?: string): Promise<void>;
37
+ /**
38
+ * Installs into a standalone directory (the project or the CLI's staging): the package.json as
39
+ * it is, plus `spec` (one spec, or several in one npm run).
40
+ */
41
+ install(directory: string, spec?: string | readonly string[]): Promise<void>;
8
42
  /** `npm pack` of a workspace package into `destination`; returns the archive path (D-0035). */
9
43
  pack(directory: string, destination: string): Promise<string>;
10
44
  /** `npm install` at a workspace root: brings its lock and node_modules in line (D-0035). */
@@ -74,7 +108,9 @@ export declare class NpmFailureNotes {
74
108
  };
75
109
  }
76
110
  /**
77
- * Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
78
- * `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
111
+ * Removes credentials npm may print: `user:password@` in URLs (core's rule, which also takes a
112
+ * raw `/` inside a password: `//app:Zm9v/YmFy@db` kept the part after the slash before),
113
+ * `_authToken=`/`_auth=`/`_password=` values (npmrc lines), bearer tokens and npm access tokens
114
+ * (`npm_…`).
79
115
  */
80
116
  export declare function redactNpmOutput(text: string): string;