@dokebi/cli 0.4.1 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +527 -35
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +2 -0
  46. package/dist/check/shared-state-rules.js +114 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +307 -31
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +29 -3
  110. package/dist/install-module.js +142 -13
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +106 -27
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +59 -4
  124. package/dist/npm-client.js +220 -16
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +206 -20
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3016 -336
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +50 -4
  170. package/dist/server.js +790 -103
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +67 -0
  180. package/dist/transaction.js +428 -46
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +113 -4
  184. package/dist/verify.js +326 -28
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -1,27 +1,143 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { realpath } from 'node:fs/promises';
3
- import { dirname } from 'node:path';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { basename, dirname, join, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { debug, debugEnabled, describeError } from './diagnostics.js';
5
+ import { debug, debugEnabled, describeError, isMissing } from './diagnostics.js';
6
+ import { redactNpmOutput } from './npm-client.js';
6
7
  export class InstallPlanningError extends Error {
7
8
  code;
8
- constructor(code) {
9
- super('Plugin installation planning failed (' + code + ').');
9
+ /**
10
+ * What the module said: the message of the error it threw, or the end of its stderr when the
11
+ * process failed (at most DETAIL_CHARS, `user:password@` and tokens masked). Without it a
12
+ * failing planInstall was only INSTALL_MODULE_FAILED, and its author could not see why.
13
+ */
14
+ detail;
15
+ constructor(code, options) {
16
+ super(`Plugin installation planning failed (${code}).`, options);
10
17
  this.code = code;
11
18
  this.name = 'InstallPlanningError';
19
+ // Masked before it is cut (R4CB-7): a cut inside a password left its first part without the
20
+ // `@` the mask looks for.
21
+ const detail = options?.detail ? redactNpmOutput(options.detail).trim() : undefined;
22
+ this.detail = detail ? detail.slice(0, DETAIL_CHARS) : undefined;
12
23
  }
13
24
  }
25
+ const DETAIL_CHARS = 2000;
26
+ /** The end of stderr kept for the detail: more than it shows, so the mask sees whole values. */
27
+ const STDERR_CHARS = DETAIL_CHARS * 2;
14
28
  /** Returned when the module does not export the requested phase. */
15
29
  export const PHASE_MISSING = Symbol('dokebi.install.phase-missing');
16
30
  const MAX_BYTES = 1024 * 1024;
17
31
  const TIMEOUT_MS = 5000;
18
- const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code});\n}\n";
32
+ const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const failure = (error) => ({ok:false,code:error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED',message:String(error && error.stack || error).slice(0, 4000)});\n // D-0162: one child checks the options of every listed plugin (context.entries: {url, options}) with\n // the main entry's createPlugin.checkOptions; each plugin's result or failure is its own.\n if (phase === 'checkOptions') {\n const results = [];\n for (const entry of context.entries) {\n try {\n const factory = (await import(entry.url)).createPlugin;\n results.push(typeof factory?.checkOptions === 'function' ? {ok:true,result:factory.checkOptions(entry.options)} : {ok:true,missing:true});\n } catch (error) { results.push(failure(error)); }\n }\n finish({ok:true,plan:results});\n } else {\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code,message:String(error && error.stack || error).slice(0, 4000)});\n}\n";
33
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
34
+ /** Packages an install module may read (its own and its dependencies, transitively). */
35
+ const MAX_READ_PACKAGES = 512;
36
+ async function dependencyNames(directory) {
37
+ let text;
38
+ try {
39
+ text = await readFile(join(directory, 'package.json'), 'utf8');
40
+ }
41
+ catch (error) {
42
+ // A folder without package.json (a bare install module) declares no dependencies.
43
+ if (!isMissing(error))
44
+ throw error;
45
+ debug('Install module package has no package.json', { directory });
46
+ return [];
47
+ }
48
+ const pkg = JSON.parse(text);
49
+ const names = new Set();
50
+ for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
51
+ const value = pkg[field];
52
+ if (value && typeof value === 'object' && !Array.isArray(value))
53
+ for (const name of Object.keys(value))
54
+ if (PACKAGE_NAME.test(name))
55
+ names.add(name);
56
+ }
57
+ return [...names];
58
+ }
59
+ /** Where Node finds `name` from `directory`: `node_modules/<name>` in it or a parent folder. */
60
+ async function findPackage(directory, name) {
61
+ for (let current = directory;; current = dirname(current)) {
62
+ if (basename(current) !== 'node_modules') {
63
+ const candidate = join(current, 'node_modules', name);
64
+ try {
65
+ await stat(candidate);
66
+ return candidate;
67
+ }
68
+ catch (error) {
69
+ if (!isMissing(error))
70
+ throw error;
71
+ }
72
+ }
73
+ if (dirname(current) === current)
74
+ return undefined;
75
+ }
76
+ }
77
+ /**
78
+ * The folders an install module may read (R9): its own package and the packages it depends on
79
+ * (`dependencies`, `optionalDependencies`, `peerDependencies`, transitively), each found the way
80
+ * Node resolves it from the importer's real folder, and allowed both where Node finds it (a
81
+ * `node_modules/<name>` link) and at its real path. Never the project, the workspace root or
82
+ * the rest of `node_modules`, so `.env` files and other projects' files stay unreadable.
83
+ */
84
+ export async function installReadRoots(packageDirectory) {
85
+ const own = await realpath(packageDirectory);
86
+ const roots = new Set([own]);
87
+ const visited = new Set([own]);
88
+ const queue = [own];
89
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
90
+ for (const name of await dependencyNames(next)) {
91
+ const found = await findPackage(next, name);
92
+ if (!found) {
93
+ // Optional and peer dependencies may be absent; an import of them fails on its own.
94
+ debug('Install module dependency not found', { package: next, dependency: name });
95
+ continue;
96
+ }
97
+ const real = await realpath(found);
98
+ roots.add(found);
99
+ roots.add(real);
100
+ if (visited.has(real))
101
+ continue;
102
+ visited.add(real);
103
+ if (visited.size > MAX_READ_PACKAGES)
104
+ throw new InstallPlanningError('INSTALL_DEPENDENCIES_LIMIT');
105
+ queue.push(real);
106
+ }
107
+ }
108
+ // A folder inside another allowed folder adds nothing; the own package stays first (cwd).
109
+ const sorted = [...roots].sort();
110
+ const result = [own];
111
+ for (const root of sorted)
112
+ if (root !== own &&
113
+ !sorted.some((other) => other !== root && root.startsWith(other + sep)) &&
114
+ !root.startsWith(own + sep))
115
+ result.push(root);
116
+ debug('Install module read roots', { package: own, roots: result });
117
+ return result;
118
+ }
19
119
  /**
20
120
  * Runs one lifecycle phase of a plugin installation module in a child process.
21
121
  * The child uses the Node permission model: it may read only `readRoots`
22
- * (real paths), cannot write files or spawn processes, and receives no
122
+ * (at their real and link paths), cannot write files or spawn processes, and receives no
23
123
  * parent environment variables. Node 24 does not restrict network access.
24
124
  */
125
+ /**
126
+ * The `--allow-fs-read` flags of the restricted child. Node 24.15's permission model denies a
127
+ * granted folder when a sibling folder whose name starts with it is granted too (with
128
+ * `node_modules/@dokebi/web` and `node_modules/@dokebi/websocket`, `realpath` of the first is
129
+ * ERR_ACCESS_DENIED, so `import('@dokebi/web')` fails: seen with D-0162's options check of
130
+ * dokebi-websocket). Such a folder is granted as `<folder>*`, which Node handles; it adds only
131
+ * the siblings that start with the same name.
132
+ */
133
+ export function readPermissions(roots) {
134
+ return roots.map((root) => {
135
+ const parent = dirname(root);
136
+ const name = basename(root);
137
+ const shadowed = roots.some((other) => other !== root && dirname(other) === parent && basename(other).startsWith(name));
138
+ return `--allow-fs-read=${root}${shadowed ? '*' : ''}`;
139
+ });
140
+ }
25
141
  export async function runInstallModule(modulePath, phase, context, readRoots = [dirname(modulePath)]) {
26
142
  const input = JSON.stringify(context);
27
143
  if (Buffer.byteLength(input) > MAX_BYTES)
@@ -29,16 +145,22 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
29
145
  let roots;
30
146
  let moduleUrl;
31
147
  try {
32
- roots = await Promise.all(readRoots.map((root) => realpath(root)));
148
+ // Each root at its real path and, when it is a link (a package in node_modules), also at the
149
+ // link: Node checks the path it resolves through (node_modules/<name>/package.json) as well.
150
+ const resolved = await Promise.all(readRoots.map(async (root) => [
151
+ await realpath(root),
152
+ join(await realpath(dirname(root)), basename(root)),
153
+ ]));
154
+ roots = [...new Set(resolved.flat())];
33
155
  moduleUrl = pathToFileURL(await realpath(modulePath)).href;
34
156
  }
35
- catch {
36
- throw new InstallPlanningError('INSTALL_MODULE_MISSING');
157
+ catch (cause) {
158
+ throw new InstallPlanningError('INSTALL_MODULE_MISSING', { cause });
37
159
  }
38
160
  return new Promise((resolve, reject) => {
39
161
  const child = spawn(process.execPath, [
40
162
  '--permission',
41
- ...roots.map((root) => `--allow-fs-read=${root}`),
163
+ ...readPermissions(roots),
42
164
  '--input-type=module',
43
165
  '--eval',
44
166
  RUNNER,
@@ -46,6 +168,7 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
46
168
  phase,
47
169
  ], { stdio: ['pipe', 'pipe', 'pipe'], env: {}, cwd: roots[0] });
48
170
  const chunks = [];
171
+ let stderr = '';
49
172
  let size = 0;
50
173
  let failure;
51
174
  const stop = (code) => {
@@ -64,6 +187,8 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
64
187
  size += chunk.length;
65
188
  if (size > MAX_BYTES)
66
189
  stop('INSTALL_OUTPUT_LIMIT');
190
+ else
191
+ stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_CHARS);
67
192
  });
68
193
  // Both become the typed InstallPlanningError the caller receives; debug keeps the cause.
69
194
  child.on('error', (error) => {
@@ -84,7 +209,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
84
209
  return;
85
210
  }
86
211
  if (code !== 0) {
87
- reject(new InstallPlanningError('INSTALL_PROCESS_FAILED'));
212
+ // The end of stderr, masked while whole (R4CB-7).
213
+ const tail = redactNpmOutput(stderr).trim().slice(-DETAIL_CHARS);
214
+ reject(new InstallPlanningError('INSTALL_PROCESS_FAILED', { detail: tail }));
88
215
  return;
89
216
  }
90
217
  try {
@@ -103,7 +230,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
103
230
  if (envelope.ok === false &&
104
231
  typeof envelope.code === 'string' &&
105
232
  /^[A-Z][A-Z0-9_]{0,63}$/.test(envelope.code)) {
106
- reject(new InstallPlanningError(envelope.code));
233
+ reject(new InstallPlanningError(envelope.code, {
234
+ detail: typeof envelope.message === 'string' ? envelope.message : undefined,
235
+ }));
107
236
  return;
108
237
  }
109
238
  }
@@ -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';
@@ -26,8 +28,13 @@ import { debug, describeError, reportError } from './diagnostics.js';
26
28
  export const CONTROL_HOST = '127.0.0.1';
27
29
  const HELLO = 'dokebi-log-writer';
28
30
  const VERSION = 1;
29
- /** A longer line is not this protocol. */
31
+ /** A longer line (in bytes, without its newline) is not this protocol. */
30
32
  const MAX_LINE = 4096;
33
+ /** Messages (and their bytes) received but not yet read; the protocol needs one at a time. */
34
+ const MAX_QUEUED = 2;
35
+ const MAX_QUEUED_BYTES = MAX_QUEUED * MAX_LINE;
36
+ const NEWLINE = 0x0a;
37
+ const UTF8 = new TextDecoder('utf-8', { fatal: true });
31
38
  /** An unauthenticated connection is closed after this long. */
32
39
  const HANDSHAKE_MS = 5000;
33
40
  /** The longest stop a client may ask for (one day). */
@@ -43,17 +50,30 @@ function matches(given, expected) {
43
50
  const b = Buffer.from(expected);
44
51
  return a.length === b.length && timingSafeEqual(a, b);
45
52
  }
46
- /** Newline-delimited JSON over a socket: read one message at a time, write messages. */
53
+ /**
54
+ * Newline-delimited JSON over a socket: read one message at a time, write messages.
55
+ *
56
+ * ISSUE-0006 A05: the limits hold whatever the TCP chunking. Bytes are split on `\n` as they
57
+ * come; every line (complete or still open) is held to MAX_LINE bytes before it is decoded or
58
+ * parsed, and a line is taken only while the peer may send one: `expected` messages at first,
59
+ * then the `replies` each `send` asks for. Anything more is a protocol violation: the queue is
60
+ * dropped (nothing queued is acted on), next() rejects and the connection is destroyed.
61
+ */
47
62
  class LineSocket {
48
63
  socket;
49
- #buffer = '';
64
+ /** The bytes of the line still open (no newline yet). */
65
+ #pending = [];
66
+ #pendingBytes = 0;
50
67
  #queue = [];
68
+ #queuedBytes = 0;
69
+ /** How many more messages the peer may send at this step of the protocol. */
70
+ #allowance;
51
71
  #waiting;
52
72
  #failure;
53
73
  closed;
54
- constructor(socket) {
74
+ constructor(socket, expected) {
55
75
  this.socket = socket;
56
- socket.setEncoding('utf8');
76
+ this.#allowance = expected;
57
77
  socket.on('data', (chunk) => this.#data(chunk));
58
78
  // Reported to whoever reads next (next() rejects); the socket closes after an error.
59
79
  socket.on('error', (error) => this.#fail(error));
@@ -61,28 +81,64 @@ class LineSocket {
61
81
  void this.closed.then(() => this.#fail(new Error('connection closed')));
62
82
  }
63
83
  #data(chunk) {
64
- this.#buffer += chunk;
65
- for (let end = this.#buffer.indexOf('\n'); end >= 0; end = this.#buffer.indexOf('\n')) {
66
- const line = this.#buffer.slice(0, end);
67
- this.#buffer = this.#buffer.slice(end + 1);
84
+ if (this.#failure)
85
+ return;
86
+ let start = 0;
87
+ for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
88
+ const piece = chunk.subarray(start, end);
89
+ start = end + 1;
90
+ const bytes = this.#pendingBytes + piece.length;
91
+ if (!this.#acceptable(bytes))
92
+ return;
93
+ const line = this.#pendingBytes ? Buffer.concat([...this.#pending, piece]) : piece;
94
+ this.#pending = [];
95
+ this.#pendingBytes = 0;
96
+ if (this.#queue.length >= MAX_QUEUED || this.#queuedBytes + bytes > MAX_QUEUED_BYTES) {
97
+ this.#violation('too many messages waiting');
98
+ return;
99
+ }
68
100
  let message;
69
101
  try {
70
- message = JSON.parse(line);
102
+ message = JSON.parse(UTF8.decode(line));
71
103
  }
72
104
  catch (error) {
73
- this.#fail(new Error(`not a protocol message: ${describeError(error).split('\n')[0]}`));
74
- this.socket.destroy();
105
+ this.#violation(`not a protocol message: ${describeError(error).split('\n')[0]}`);
75
106
  return;
76
107
  }
77
- this.#queue.push(message);
108
+ this.#allowance--;
109
+ this.#queue.push({ message, bytes });
110
+ this.#queuedBytes += bytes;
78
111
  }
79
- if (this.#buffer.length > MAX_LINE) {
80
- this.#fail(new Error(`a line longer than ${MAX_LINE} characters`));
81
- this.socket.destroy();
82
- return;
112
+ const rest = chunk.subarray(start);
113
+ if (rest.length) {
114
+ if (!this.#acceptable(this.#pendingBytes + rest.length))
115
+ return;
116
+ this.#pending.push(rest);
117
+ this.#pendingBytes += rest.length;
83
118
  }
84
119
  this.#deliver();
85
120
  }
121
+ /** A line of `bytes` so far may go on: a message is expected and the line is short enough. */
122
+ #acceptable(bytes) {
123
+ if (this.#allowance <= 0) {
124
+ this.#violation('a message the protocol does not expect at this step');
125
+ return false;
126
+ }
127
+ if (bytes > MAX_LINE) {
128
+ this.#violation(`a line longer than ${MAX_LINE} bytes`);
129
+ return false;
130
+ }
131
+ return true;
132
+ }
133
+ #violation(reason) {
134
+ debug('Control connection broke the protocol', { reason });
135
+ this.#queue = [];
136
+ this.#queuedBytes = 0;
137
+ this.#pending = [];
138
+ this.#pendingBytes = 0;
139
+ this.#fail(new Error(reason));
140
+ this.socket.destroy();
141
+ }
86
142
  #fail(error) {
87
143
  this.#failure ??= error;
88
144
  this.#deliver();
@@ -92,7 +148,13 @@ class LineSocket {
92
148
  if (!waiting || (!this.#queue.length && !this.#failure))
93
149
  return;
94
150
  this.#waiting = undefined;
95
- waiting(this.#queue.length ? this.#queue.shift() : this.#failure);
151
+ const next = this.#queue.shift();
152
+ if (next) {
153
+ this.#queuedBytes -= next.bytes;
154
+ waiting(next.message);
155
+ }
156
+ else
157
+ waiting(this.#failure);
96
158
  }
97
159
  /** The next message; rejects when the connection fails or closes first, or after `ms`. */
98
160
  next(ms) {
@@ -113,7 +175,9 @@ class LineSocket {
113
175
  this.#deliver();
114
176
  });
115
177
  }
116
- send(message) {
178
+ /** Sends `message`; the peer may then send `replies` messages (the protocol's next step). */
179
+ send(message, replies = 0) {
180
+ this.#allowance += replies;
117
181
  if (!this.socket.destroyed)
118
182
  this.socket.write(`${JSON.stringify(message)}\n`);
119
183
  }
@@ -178,7 +242,8 @@ export class ControlServer {
178
242
  #connection(socket) {
179
243
  this.#sockets.add(socket);
180
244
  socket.once('close', () => this.#sockets.delete(socket));
181
- const lines = new LineSocket(socket);
245
+ // The client speaks only after the hello (which allows its proof).
246
+ const lines = new LineSocket(socket, 0);
182
247
  void this.#serve(lines).catch((error) => {
183
248
  // A client that broke the protocol or went silent: nothing was done for it.
184
249
  reportError('CONTROL_REQUEST_FAILED', 'A control request failed', error, 5000);
@@ -187,7 +252,7 @@ export class ControlServer {
187
252
  }
188
253
  async #serve(lines) {
189
254
  const challenge = randomBytes(24).toString('base64url');
190
- lines.send({ hello: HELLO, version: VERSION, challenge });
255
+ lines.send({ hello: HELLO, version: VERSION, challenge }, 1);
191
256
  const hello = await lines.next(HANDSHAKE_MS);
192
257
  if (!matches(hello.answer, controlAnswer(this.token, 'client', challenge))) {
193
258
  debug('Control client refused: wrong instance proof');
@@ -208,13 +273,26 @@ export class ControlServer {
208
273
  answer: controlAnswer(this.token, 'writer', hello.challenge),
209
274
  writer: process.pid,
210
275
  app: { pid: app.pid, running: app.running() },
211
- });
276
+ }, 1);
212
277
  const request = await lines.next(HANDSHAKE_MS);
213
278
  if (request.op === 'status') {
214
279
  lines.send({ ok: true, app: { pid: app.pid, running: app.running() } });
215
280
  lines.socket.end();
216
281
  return;
217
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
+ }
218
296
  const timeoutMs = request.timeoutMs;
219
297
  if (request.op !== 'stop' ||
220
298
  !Number.isSafeInteger(timeoutMs) ||
@@ -266,7 +344,8 @@ export async function askLogWriter(port, token, request,
266
344
  stepMs = 3000) {
267
345
  const path = `${CONTROL_HOST}:${port}`;
268
346
  const socket = createConnection({ host: CONTROL_HOST, port });
269
- const lines = new LineSocket(socket);
347
+ // The writer greets first: one message expected.
348
+ const lines = new LineSocket(socket, 1);
270
349
  const unreached = (reason) => {
271
350
  socket.destroy();
272
351
  debug('Log writer not reached', { path, reason });
@@ -291,7 +370,7 @@ stepMs = 3000) {
291
370
  if (hello.hello !== HELLO || typeof hello.challenge !== 'string')
292
371
  return unreached(`${path} is not a dokebi log writer`);
293
372
  const challenge = randomBytes(24).toString('base64url');
294
- lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) });
373
+ lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) }, 1);
295
374
  const proof = await lines.next(stepMs);
296
375
  if (proof.code === 'NOT_READY')
297
376
  return unreached('the log writer has not started the app yet');
@@ -303,7 +382,7 @@ stepMs = 3000) {
303
382
  if (typeof app?.pid !== 'number' || typeof proof.writer !== 'number')
304
383
  return unreached('the log writer sent an unexpected answer');
305
384
  debug('Log writer proved the instance token', { path, writer: proof.writer, op: request.op });
306
- lines.send(request);
385
+ lines.send(request, 1);
307
386
  const replyMs = request.op === 'stop' ? request.timeoutMs + (request.force ? 5000 : 0) + stepMs : stepMs;
308
387
  const reply = await lines.next(replyMs);
309
388
  return {