@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
@@ -0,0 +1,649 @@
1
+ // `dokebi describe --openapi` (D-0077 C): one OpenAPI 3.1 document per app, built from the
2
+ // description `dokebi describe` makes (the routes dokebi-web recorded, with their JSON Schemas,
3
+ // the login rule of dokebi-auth, the csrf settings and the app's error codes). No external
4
+ // OpenAPI library: OpenAPI 3.1 Schema Objects are JSON Schema 2020-12, so a route schema is
5
+ // copied with the few draft-07 keywords Fastify's Ajv accepts rewritten to their 2020-12 form.
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { debug, describeError } from './diagnostics.js';
9
+ import { CliError } from './errors.js';
10
+ export const OPENAPI_VERSION = '3.1.0';
11
+ /** The HTTP methods an OpenAPI Path Item has. */
12
+ const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
13
+ /** Methods @dokebi/csrf checks (D-0076 §4). */
14
+ const UNSAFE = new Set(['post', 'put', 'patch', 'delete']);
15
+ /** Headers OpenAPI describes elsewhere (a header parameter with these names is ignored, §4.8.12). */
16
+ const RESERVED_HEADERS = new Set(['accept', 'content-type', 'authorization', 'cookie']);
17
+ /** Vite dev routes (template-inertia in development); describe mode registers none. */
18
+ const VITE_ROUTE = /\/(?:@vite|@id|@fs|__vite|node_modules\/\.vite)(?:\/|$)/;
19
+ /** Host names that resolve to this machine (D-0030 local domains). */
20
+ const LOOPBACK = /^(?:localhost|[^.]+(?:\.[^.]+)*\.localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
21
+ const ERROR_SCHEMA = '#/components/schemas/DokebiError';
22
+ /** The D-0074 error body. */
23
+ export const ERROR_BODY_SCHEMA = Object.freeze({
24
+ type: 'object',
25
+ description: 'A Dokebi error answer (D-0074). `fix` and `details` are sent outside production only; a 5xx answer in production carries only `error`.',
26
+ required: ['error'],
27
+ properties: {
28
+ error: { type: 'string', description: 'The error code, for example VALIDATION_FAILED' },
29
+ message: { type: 'string' },
30
+ hint: { type: 'string', description: 'What the user can do' },
31
+ data: { type: 'object', description: 'Data the client may use' },
32
+ fields: {
33
+ type: 'array',
34
+ description: '422 VALIDATION_FAILED: every refused field, in order',
35
+ items: {
36
+ type: 'object',
37
+ required: ['field', 'message'],
38
+ properties: { field: { type: 'string' }, message: { type: 'string' } },
39
+ },
40
+ },
41
+ errors: {
42
+ type: 'object',
43
+ description: '422 VALIDATION_FAILED: the first message per field (Inertia useForm().errors)',
44
+ additionalProperties: { type: 'string' },
45
+ },
46
+ fix: { type: 'string', description: 'For developers; outside production only' },
47
+ details: { description: 'For developers; outside production only' },
48
+ },
49
+ });
50
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
51
+ const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
52
+ /** A components key: `^[a-zA-Z0-9.\-_]+$`. */
53
+ function componentName(text) {
54
+ const name = text.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
55
+ return name || 'schema';
56
+ }
57
+ // ── JSON Schema (as Fastify takes it) → OpenAPI 3.1 Schema Object ─────────────────────────────
58
+ /**
59
+ * Converts one route schema. Local `$ref`s (`#/definitions/x`, `#/$defs/x`) move to
60
+ * `components.schemas` under `<prefix>.<name>` (a ref is resolved against the document there);
61
+ * other local pointers are inlined; a ref to a schema added with Fastify's `addSchema` is not in
62
+ * the description and becomes an open schema with `x-dokebi-ref`.
63
+ */
64
+ class SchemaConverter {
65
+ #root;
66
+ #prefix;
67
+ #components;
68
+ #inlining = new Set();
69
+ constructor(root, prefix, components) {
70
+ this.#root = root;
71
+ this.#prefix = prefix;
72
+ this.#components = components;
73
+ }
74
+ convert(schema = this.#root, depth = 0) {
75
+ if (typeof schema === 'boolean')
76
+ return schema;
77
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema))
78
+ return {};
79
+ if (depth > 64)
80
+ return { description: 'Nested too deep to describe' };
81
+ const input = schema;
82
+ const output = {};
83
+ for (const [key, value] of Object.entries(input)) {
84
+ switch (key) {
85
+ // The OpenAPI document is the base: no own dialect or base URI inside it.
86
+ case '$schema':
87
+ case '$id':
88
+ case 'definitions':
89
+ case '$defs':
90
+ break;
91
+ case '$ref':
92
+ Object.assign(output, this.#ref(String(value), depth));
93
+ break;
94
+ case 'nullable':
95
+ break;
96
+ case 'items':
97
+ if (Array.isArray(value)) {
98
+ // draft-07 tuple: prefixItems, and additionalItems becomes items.
99
+ output.prefixItems = value.map((item) => this.convert(item, depth + 1));
100
+ if (input.additionalItems !== undefined)
101
+ output.items = this.convert(input.additionalItems, depth + 1);
102
+ }
103
+ else
104
+ output.items = this.convert(value, depth + 1);
105
+ break;
106
+ case 'additionalItems':
107
+ if (!Array.isArray(input.items))
108
+ output.items ??= this.convert(value, depth + 1);
109
+ break;
110
+ case 'dependencies': {
111
+ const required = {};
112
+ const schemas = {};
113
+ for (const [name, item] of Object.entries(record(value)))
114
+ if (Array.isArray(item))
115
+ required[name] = item;
116
+ else
117
+ schemas[name] = this.convert(item, depth + 1);
118
+ if (Object.keys(required).length)
119
+ output.dependentRequired = required;
120
+ if (Object.keys(schemas).length)
121
+ output.dependentSchemas = schemas;
122
+ break;
123
+ }
124
+ case 'properties':
125
+ case 'patternProperties':
126
+ case 'dependentSchemas':
127
+ output[key] = Object.fromEntries(Object.entries(record(value)).map(([name, item]) => [
128
+ name,
129
+ this.convert(item, depth + 1),
130
+ ]));
131
+ break;
132
+ case 'additionalProperties':
133
+ case 'unevaluatedProperties':
134
+ case 'unevaluatedItems':
135
+ case 'propertyNames':
136
+ case 'contains':
137
+ case 'not':
138
+ case 'if':
139
+ case 'then':
140
+ case 'else':
141
+ output[key] = this.convert(value, depth + 1);
142
+ break;
143
+ case 'allOf':
144
+ case 'anyOf':
145
+ case 'oneOf':
146
+ case 'prefixItems':
147
+ output[key] = Array.isArray(value)
148
+ ? value.map((item) => this.convert(item, depth + 1))
149
+ : value;
150
+ break;
151
+ default:
152
+ output[key] = value;
153
+ }
154
+ }
155
+ // Ajv's (and OpenAPI 3.0's) nullable: a type list with null in 3.1.
156
+ if (input.nullable === true) {
157
+ if (typeof output.type === 'string')
158
+ output.type = [output.type, 'null'];
159
+ else if (Array.isArray(output.type) && !output.type.includes('null'))
160
+ output.type = [...output.type, 'null'];
161
+ else if (output.type === undefined &&
162
+ Array.isArray(output.enum) &&
163
+ !output.enum.includes(null))
164
+ output.enum = [...output.enum, null];
165
+ }
166
+ return output;
167
+ }
168
+ #ref(ref, depth) {
169
+ const definition = /^#\/(definitions|\$defs)\/([^/]+)$/.exec(ref);
170
+ if (definition) {
171
+ const [, group, raw] = definition;
172
+ const name = decodePointer(raw);
173
+ const target = record(record(this.#root)[group])[name];
174
+ if (target === undefined)
175
+ return unresolved(ref);
176
+ const key = componentName(`${this.#prefix}.${name}`);
177
+ if (!Object.hasOwn(this.#components, key)) {
178
+ // Reserved first: a definition that refers to itself finds its own name.
179
+ this.#components[key] = {};
180
+ this.#components[key] = this.convert(target, depth + 1);
181
+ }
182
+ return { $ref: `#/components/schemas/${key}` };
183
+ }
184
+ if (ref === '#' || ref.startsWith('#/')) {
185
+ if (this.#inlining.has(ref))
186
+ return unresolved(ref);
187
+ const target = ref === '#' ? this.#root : pointer(this.#root, ref.slice(2));
188
+ if (target === undefined)
189
+ return unresolved(ref);
190
+ this.#inlining.add(ref);
191
+ try {
192
+ return record(this.convert(target, depth + 1));
193
+ }
194
+ finally {
195
+ this.#inlining.delete(ref);
196
+ }
197
+ }
198
+ return unresolved(ref);
199
+ }
200
+ }
201
+ function decodePointer(token) {
202
+ return decodeURIComponent(token).replace(/~1/g, '/').replace(/~0/g, '~');
203
+ }
204
+ function pointer(root, path) {
205
+ let current = root;
206
+ for (const token of path.split('/')) {
207
+ if (current === null || typeof current !== 'object')
208
+ return undefined;
209
+ current = current[decodePointer(token)];
210
+ }
211
+ return current;
212
+ }
213
+ function unresolved(ref) {
214
+ return {
215
+ description: `A schema this description cannot resolve (${ref}); see the route's code`,
216
+ 'x-dokebi-ref': ref,
217
+ };
218
+ }
219
+ // ── paths ─────────────────────────────────────────────────────────────────────────────────────
220
+ /** `/posts/:id(^\\d+)/*` → `/posts/{id}/{wildcard}` and the path parameter names. */
221
+ export function openApiPath(url) {
222
+ const names = [];
223
+ let path = '';
224
+ for (let index = 0; index < url.length; index++) {
225
+ const char = url[index];
226
+ if (char === ':' && url[index + 1] === ':') {
227
+ // `::` is a literal colon in Fastify.
228
+ path += ':';
229
+ index++;
230
+ continue;
231
+ }
232
+ if (char === ':') {
233
+ const match = /^[A-Za-z0-9_]+/.exec(url.slice(index + 1));
234
+ if (!match) {
235
+ path += char;
236
+ continue;
237
+ }
238
+ names.push(match[0]);
239
+ path += `{${match[0]}}`;
240
+ index += match[0].length;
241
+ // A regular expression after the name: `(…)` with nested parentheses.
242
+ if (url[index + 1] === '(') {
243
+ let level = 0;
244
+ for (index++; index < url.length; index++) {
245
+ if (url[index] === '\\')
246
+ index++;
247
+ else if (url[index] === '(')
248
+ level++;
249
+ else if (url[index] === ')' && --level === 0)
250
+ break;
251
+ }
252
+ }
253
+ continue;
254
+ }
255
+ if (char === '*' && index === url.length - 1) {
256
+ names.push('*');
257
+ path += '{wildcard}';
258
+ continue;
259
+ }
260
+ path += char;
261
+ }
262
+ return { path, names };
263
+ }
264
+ /** `get_posts_id`: the deterministic name of a route without `config.id`. */
265
+ export function operationName(method, url) {
266
+ const words = openApiPath(url)
267
+ .path.replace(/[{}]/g, '')
268
+ .split(/[^A-Za-z0-9]+/)
269
+ .filter(Boolean)
270
+ .map((word) => word.toLowerCase());
271
+ return `${method.toLowerCase()}_${words.join('_') || 'root'}`;
272
+ }
273
+ /** The app names of the project (dokebi-app), or one name for a project without apps. */
274
+ function appNames(result) {
275
+ const apps = Array.isArray(result.app.apps) ? result.app.apps : [];
276
+ const names = apps
277
+ .map((app) => app.name)
278
+ .filter((name) => typeof name === 'string');
279
+ return names;
280
+ }
281
+ /** OpenAPI documents of the described project (one per app). */
282
+ export function openApi(result, options = {}) {
283
+ const names = appNames(result);
284
+ const single = names.length === 0;
285
+ const projectName = typeof result.app.name === 'string' ? result.app.name : 'app';
286
+ if (options.app !== undefined &&
287
+ (single ? options.app !== projectName : !names.includes(options.app)))
288
+ throw new CliError('UNKNOWN_APP', `The project has no app ${options.app}`, {
289
+ details: {
290
+ app: options.app,
291
+ apps: single ? [projectName] : names,
292
+ fix: `Pass one of: ${(single ? [projectName] : names).join(', ')} (npx dokebi describe --json lists the apps)`,
293
+ },
294
+ });
295
+ if (single)
296
+ return document(result, undefined, projectName, options);
297
+ if (options.app !== undefined)
298
+ return document(result, options.app, options.app, options);
299
+ if (names.length === 1)
300
+ return document(result, names[0], names[0], options);
301
+ return {
302
+ apps: Object.fromEntries(names.map((name) => [name, document(result, name, name, options)])),
303
+ };
304
+ }
305
+ function servers(result, app) {
306
+ const web = record(result.app.web);
307
+ const port = typeof web.port === 'number' ? web.port : undefined;
308
+ const domains = (Array.isArray(result.app.domains) ? result.app.domains : []).filter((domain) => app === undefined || domain.app === app);
309
+ const hosts = domains.map((domain) => ({
310
+ host: String(domain.host),
311
+ seal: domain.seal === true,
312
+ }));
313
+ if (hosts.length === 0) {
314
+ const configured = typeof web.host === 'string' ? web.host : 'localhost';
315
+ hosts.push({
316
+ host: configured === '0.0.0.0' || configured === '::' ? 'localhost' : configured,
317
+ seal: false,
318
+ });
319
+ }
320
+ return hosts.map(({ host, seal }) => {
321
+ const local = LOOPBACK.test(host);
322
+ return {
323
+ url: local ? `http://${host}${port === undefined ? '' : `:${port}`}` : `https://${host}`,
324
+ description: local ? `${host} (this machine)` : host,
325
+ ...(seal ? { 'x-dokebi-seal': true } : {}),
326
+ };
327
+ });
328
+ }
329
+ /** The parameters of one schema part (`querystring`, `params`, `headers`). */
330
+ function parameters(part, where, convert) {
331
+ const schema = record(part);
332
+ const properties = record(schema.properties);
333
+ const required = new Set(strings(schema.required));
334
+ if (Object.keys(properties).length === 0) {
335
+ if (where !== 'query' || Object.keys(schema).length === 0)
336
+ return [];
337
+ // An object query without properties (anyOf, a $ref): one form-style parameter.
338
+ return [{ name: 'query', in: 'query', style: 'form', explode: true, schema: convert(schema) }];
339
+ }
340
+ const list = [];
341
+ for (const [name, item] of Object.entries(properties)) {
342
+ if (where === 'header' && RESERVED_HEADERS.has(name.toLowerCase()))
343
+ continue;
344
+ const converted = record(convert(item));
345
+ list.push({
346
+ name,
347
+ in: where,
348
+ ...(where === 'path' || required.has(name) ? { required: true } : {}),
349
+ ...(typeof converted.description === 'string' ? { description: converted.description } : {}),
350
+ schema: converted,
351
+ });
352
+ }
353
+ return list;
354
+ }
355
+ /** A Fastify response map → OpenAPI responses (`2xx` → `2XX`); each code is its own schema root. */
356
+ function responses(part, converterFor, prefix) {
357
+ const out = {};
358
+ for (const [code, value] of Object.entries(record(part))) {
359
+ const key = /^[1-5]xx$/i.test(code) ? code.toUpperCase() : code;
360
+ if (!/^(?:[1-5](?:\d\d|XX)|default)$/.test(key))
361
+ continue;
362
+ const given = record(value);
363
+ const description = typeof given.description === 'string' ? given.description : 'Response';
364
+ if (given.content !== undefined && given.type === undefined) {
365
+ const content = {};
366
+ for (const [media, entry] of Object.entries(record(given.content))) {
367
+ const schema = record(entry).schema;
368
+ content[media] = { schema: converterFor(schema, `${prefix}.${key}.${media}`)(schema) };
369
+ }
370
+ out[key] = { description, content };
371
+ }
372
+ else if (typeof value === 'boolean' || Object.keys(given).length > 0)
373
+ out[key] = {
374
+ description,
375
+ content: {
376
+ 'application/json': { schema: converterFor(value, `${prefix}.${key}`)(value) },
377
+ },
378
+ };
379
+ else
380
+ out[key] = { description };
381
+ }
382
+ return out;
383
+ }
384
+ function errorResponse(components, use) {
385
+ const responsesStore = components.responses;
386
+ const key = use.codes.join('_OR_');
387
+ if (!Object.hasOwn(responsesStore, key))
388
+ responsesStore[key] = {
389
+ description: use.description,
390
+ content: {
391
+ 'application/json': {
392
+ schema: {
393
+ allOf: [
394
+ { $ref: ERROR_SCHEMA },
395
+ {
396
+ properties: {
397
+ error: use.codes.length === 1 ? { const: use.codes[0] } : { enum: [...use.codes] },
398
+ },
399
+ },
400
+ ],
401
+ },
402
+ },
403
+ },
404
+ };
405
+ return { $ref: `#/components/responses/${key}` };
406
+ }
407
+ /** One app's document (`app` undefined: a project without dokebi-app, every route). */
408
+ function document(result, app, title, options) {
409
+ const components = {
410
+ schemas: { DokebiError: ERROR_BODY_SCHEMA },
411
+ responses: {},
412
+ securitySchemes: {},
413
+ };
414
+ const schemas = components.schemas;
415
+ const schemes = components.securitySchemes;
416
+ const web = record(result.app.web);
417
+ const session = record(result.app.session);
418
+ const csrf = result.app.csrf === undefined ? undefined : record(result.app.csrf);
419
+ const authSection = record(result.app.auth);
420
+ const loginPath = typeof authSection.loginPath === 'string' ? authSection.loginPath : '/login';
421
+ const forms = web.formBody === true;
422
+ // Routes of this app and routes outside apps (Fastify serves those on every host).
423
+ const routes = result.routes.filter((route) => route.health !== true &&
424
+ !VITE_ROUTE.test(route.url) &&
425
+ (app === undefined || route.app === undefined || route.app === app));
426
+ const seen = new Set(routes.map((route) => `${route.method} ${route.url} ${route.id ?? ''}`));
427
+ const paths = {};
428
+ const taken = new Set();
429
+ const operations = [];
430
+ const skipped = [];
431
+ // Ids first (they are the names asked for), then the generated names, in route order. One rule
432
+ // for an id: it names one operation as it is; an id on several operations (a route of several
433
+ // methods, or several routes of one plugin route key, D-0142) gives each `<id>_<method>`; a
434
+ // name still taken gets `_2`, `_3`, … (two GET routes under one id).
435
+ const candidates = [];
436
+ for (const route of routes) {
437
+ // dokebi-app registers a module's root as /board and /board/: one operation.
438
+ if (route.url.length > 1 &&
439
+ route.url.endsWith('/') &&
440
+ seen.has(`${route.method} ${route.url.slice(0, -1)} ${route.id ?? ''}`))
441
+ continue;
442
+ const methods = route.method.split(',').map((method) => method.trim().toLowerCase());
443
+ for (const method of methods) {
444
+ if (!METHODS.has(method)) {
445
+ skipped.push(`${method.toUpperCase()} ${route.url}`);
446
+ continue;
447
+ }
448
+ candidates.push({ route, method });
449
+ }
450
+ }
451
+ const perId = new Map();
452
+ for (const { route } of candidates)
453
+ if (route.id !== undefined)
454
+ perId.set(route.id, (perId.get(route.id) ?? 0) + 1);
455
+ const planned = candidates.map(({ route, method }) => ({
456
+ route,
457
+ method,
458
+ wanted: route.id === undefined
459
+ ? operationName(method, route.url)
460
+ : (perId.get(route.id) ?? 0) > 1
461
+ ? `${route.id}_${method}`
462
+ : route.id,
463
+ }));
464
+ const unique = (wanted) => {
465
+ let name = wanted;
466
+ for (let index = 2; taken.has(name); index++)
467
+ name = `${wanted}_${index}`;
468
+ taken.add(name);
469
+ return name;
470
+ };
471
+ const names = new Map();
472
+ for (const item of planned)
473
+ if (item.route.id !== undefined)
474
+ names.set(item, unique(item.wanted));
475
+ for (const item of planned)
476
+ if (item.route.id === undefined)
477
+ names.set(item, unique(item.wanted));
478
+ for (const item of planned)
479
+ operations.push({ route: item.route, method: item.method, name: names.get(item) });
480
+ for (const { route, method, name } of operations) {
481
+ const { path, names: pathNames } = openApiPath(route.url);
482
+ const given = record(route.schema);
483
+ const converterFor = (root, prefix) => (schema) => new SchemaConverter(root, prefix, schemas).convert(schema);
484
+ const convert = (part) => converterFor(given[part], `${name}.${part}`);
485
+ const operation = { operationId: name };
486
+ const params = [];
487
+ const pathParams = parameters(given.params, 'path', convert('params'));
488
+ for (const param of pathNames) {
489
+ const declared = pathParams.find((item) => item.name === param);
490
+ params.push(declared
491
+ ? { ...declared, name: param === '*' ? 'wildcard' : param }
492
+ : {
493
+ name: param === '*' ? 'wildcard' : param,
494
+ in: 'path',
495
+ required: true,
496
+ ...(param === '*'
497
+ ? { description: "The rest of the path (Fastify params['*'])" }
498
+ : {}),
499
+ schema: { type: 'string' },
500
+ });
501
+ }
502
+ params.push(...parameters(given.querystring, 'query', convert('querystring')));
503
+ params.push(...parameters(given.headers, 'header', convert('headers')));
504
+ const config = record(route.config);
505
+ const csrfChecked = csrf !== undefined &&
506
+ UNSAFE.has(method) &&
507
+ config.csrf !== false &&
508
+ !strings(csrf.exclude).some((prefix) => route.url.startsWith(prefix));
509
+ if (csrfChecked && typeof csrf.header === 'string')
510
+ params.push({
511
+ name: csrf.header,
512
+ in: 'header',
513
+ description: `The CSRF token (or the ${String(csrf.field)} form field); not needed with Authorization: Bearer and no session cookie`,
514
+ schema: { type: 'string' },
515
+ });
516
+ if (params.length)
517
+ operation.parameters = params;
518
+ if (given.body !== undefined) {
519
+ const body = record(given.body);
520
+ const content = {};
521
+ if (body.content !== undefined && body.type === undefined) {
522
+ for (const [media, entry] of Object.entries(record(body.content))) {
523
+ const schema = record(entry).schema;
524
+ content[media] = { schema: converterFor(schema, `${name}.body.${media}`)(schema) };
525
+ }
526
+ }
527
+ else {
528
+ const schema = convert('body')(given.body);
529
+ content['application/json'] = { schema };
530
+ if (forms)
531
+ content['application/x-www-form-urlencoded'] = { schema };
532
+ }
533
+ operation.requestBody = { required: true, content };
534
+ }
535
+ const answers = { ...responses(given.response, converterFor, `${name}.response`) };
536
+ if (!Object.keys(answers).some((code) => /^2/.test(code)))
537
+ answers['200'] = { description: 'Success' };
538
+ if (given.body !== undefined || given.querystring !== undefined)
539
+ answers['422'] ??= errorResponse(components, {
540
+ codes: ['VALIDATION_FAILED'],
541
+ description: 'VALIDATION_FAILED: the route schema refused the body or the query (fields, errors)',
542
+ });
543
+ if (given.params !== undefined || given.headers !== undefined)
544
+ answers['400'] ??= errorResponse(components, {
545
+ codes: ['INVALID_REQUEST'],
546
+ description: 'INVALID_REQUEST: the route schema refused the path parameters or headers',
547
+ });
548
+ const auth = route.auth === undefined ? undefined : record(route.auth);
549
+ const mode = auth?.mode;
550
+ const via = typeof auth?.via === 'string' ? auth.via : 'session';
551
+ const roles = strings(auth?.roles ?? config.roles);
552
+ const can = typeof auth?.can === 'string' ? auth.can : undefined;
553
+ if (auth !== undefined) {
554
+ operation['x-dokebi-auth'] = {
555
+ mode,
556
+ realm: auth.realm,
557
+ via,
558
+ ...(roles.length ? { roles } : {}),
559
+ ...(can === undefined ? {} : { can }),
560
+ };
561
+ if (mode === 'required' || mode === 'optional') {
562
+ const choices = [];
563
+ if (via === 'token' || via === 'any') {
564
+ schemes.bearerAuth = {
565
+ type: 'http',
566
+ scheme: 'bearer',
567
+ description: 'An API token of @dokebi/auth (opaque, not a JWT)',
568
+ };
569
+ choices.push({ bearerAuth: [] });
570
+ }
571
+ if (via === 'session' || via === 'any') {
572
+ schemes.cookieAuth = {
573
+ type: 'apiKey',
574
+ in: 'cookie',
575
+ name: typeof record(session.cookie).name === 'string'
576
+ ? record(session.cookie).name
577
+ : 'dokebi.sid',
578
+ description: 'The session cookie of @dokebi/session after a login',
579
+ };
580
+ choices.push({ cookieAuth: [] });
581
+ }
582
+ if (mode === 'optional')
583
+ choices.push({});
584
+ operation.security = choices;
585
+ }
586
+ // Required, or a token that was presented and is not valid (optional token routes).
587
+ if (mode === 'required' || (mode === 'optional' && via !== 'session'))
588
+ answers['401'] ??= errorResponse(components, {
589
+ codes: ['UNAUTHENTICATED'],
590
+ description: `UNAUTHENTICATED: sign in first (JSON clients; a browser page or form gets 303 to ${loginPath})`,
591
+ });
592
+ }
593
+ const forbidden = [];
594
+ if (mode === 'required' && (roles.length > 0 || can !== undefined))
595
+ forbidden.push('FORBIDDEN');
596
+ if (csrfChecked)
597
+ forbidden.push('CSRF_FAILED');
598
+ if (forbidden.length)
599
+ answers['403'] ??= errorResponse(components, {
600
+ codes: forbidden,
601
+ description: forbidden
602
+ .map((code) => code === 'FORBIDDEN'
603
+ ? `FORBIDDEN: the user lacks ${roles.length ? `one of the roles ${roles.join(', ')}` : ''}${roles.length && can ? ' or ' : ''}${can ? `the ability ${can}` : ''}`
604
+ : 'CSRF_FAILED: the CSRF token is missing or wrong')
605
+ .join('; '),
606
+ });
607
+ operation.responses = answers;
608
+ if (paths[path] === undefined)
609
+ paths[path] = {};
610
+ const item = paths[path];
611
+ if (item[method] !== undefined) {
612
+ // Two routes on one path and method (other hosts): the first one is described.
613
+ skipped.push(`${method.toUpperCase()} ${route.url} (${name})`);
614
+ continue;
615
+ }
616
+ item[method] = operation;
617
+ }
618
+ for (const key of ['responses', 'securitySchemes'])
619
+ if (Object.keys(components[key]).length === 0)
620
+ delete components[key];
621
+ if (skipped.length)
622
+ debug('OpenAPI routes left out', { app: title, skipped });
623
+ return {
624
+ openapi: OPENAPI_VERSION,
625
+ info: {
626
+ title,
627
+ version: options.version ?? '0.0.0',
628
+ description: `Routes of ${app === undefined ? 'the project' : `the app ${app}`}${typeof result.app.name === 'string' && result.app.name !== title
629
+ ? ` of ${result.app.name}`
630
+ : ''}, generated by dokebi describe --openapi`,
631
+ },
632
+ servers: servers(result, app),
633
+ paths,
634
+ components,
635
+ 'x-dokebi-errors': result.errors.map((item) => ({ ...item })),
636
+ ...(skipped.length ? { 'x-dokebi-skipped': skipped } : {}),
637
+ };
638
+ }
639
+ /** The project's package.json version, for `info.version`. */
640
+ export function projectVersion(root) {
641
+ try {
642
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
643
+ return typeof version === 'string' ? version : undefined;
644
+ }
645
+ catch (error) {
646
+ debug('Unable to read the project version for OpenAPI', { error: describeError(error) });
647
+ return undefined;
648
+ }
649
+ }
@@ -0,0 +1,39 @@
1
+ /** Issue shape of the options validator (@dokebi/config `ConfigIssue`). */
2
+ export interface OptionIssueLike {
3
+ readonly instancePath: string;
4
+ readonly keyword: string;
5
+ /** For `additionalProperties`: the unknown key (a name from the configuration, not a value). */
6
+ readonly additionalProperty?: string;
7
+ /** For `required`: the missing key (config init, D-0163, generates it when it is a secret). */
8
+ readonly missingProperty?: string;
9
+ }
10
+ export interface MovedOption {
11
+ /** Plugin id and option path that no longer exist there. */
12
+ readonly plugin: string;
13
+ readonly path: readonly string[];
14
+ /** Where the settings live now (`<plugin>.<path>`), the package and the decision. */
15
+ readonly to: string;
16
+ readonly package: string;
17
+ readonly decision: string;
18
+ /** The commands that write the new settings, with placeholders for what the user decides. */
19
+ readonly set: string;
20
+ }
21
+ /** Options that moved to another plugin, newest last. */
22
+ export declare const MOVED_OPTIONS: readonly MovedOption[];
23
+ /** `<id>.<path>` of every key the plugin does not know, when the path can be unset. */
24
+ export declare function unknownOptionPaths(id: string, issues: readonly OptionIssueLike[]): {
25
+ readonly paths: string[];
26
+ readonly unnamed: boolean;
27
+ };
28
+ /** The moved option a path is, or is inside of. */
29
+ export declare function movedOption(id: string, path: readonly string[]): MovedOption | undefined;
30
+ /**
31
+ * The fix of INVALID_PLUGIN_OPTIONS for plugin `id`: the moved keys first (where they went, then
32
+ * show, unset, add, set), then `npx dokebi config unset` for the other unknown keys, then
33
+ * `fallback` (how to correct the remaining issues), which is the whole fix when no key is unknown.
34
+ */
35
+ export declare function optionsFix(id: string, issues: readonly OptionIssueLike[], fallback: string,
36
+ /** The section of dokebi.yaml the commands name (options.logging for the logger host). */
37
+ section?: string,
38
+ /** The plugin's options schema: the fix then says what the refused field expects. */
39
+ schema?: unknown): string;