@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 +1,43 @@
1
+ /**
2
+ * The old names of an object schema (D-0071 `option.alias`, and `option.renamed`, which core
3
+ * refuses anyway) and the field each one names: `bodyLimit` → [maxBodyBytes],
4
+ * `maxReconnectAttempts` → [reconnect, maxAttempts]. Core refuses an old name and its field set
5
+ * together (INVALID_OPTIONS `conflict`), so the CLI writes, fills or shows no default of a field
6
+ * whose old name is set. Read from the keyword core puts on the old name (`x-dokebi-alias-of`);
7
+ * a schema from before the keyword by core's default description (OLD_NAME_DESCRIPTION).
8
+ */
9
+ export declare function aliasTargets(schema: unknown): Map<string, [string] | [string, string]>;
10
+ /**
11
+ * The places in `value` (an options section) where an old name and the field it sets are both
12
+ * set, which core refuses at start (INVALID_OPTIONS `conflict`): `{ alias, field }` as dotted
13
+ * paths below the section (`bodyLimit`, `maxBodyBytes`; `connections.main.reconnectAttempts`,
14
+ * `connections.main.reconnect.maxAttempts`).
15
+ */
16
+ export declare function aliasConflicts(value: unknown, schema: unknown): {
17
+ readonly alias: string;
18
+ readonly field: string;
19
+ }[];
1
20
  export declare function scaffoldConfig(source: string, id: string, schema: Record<string, unknown> | null): string;
21
+ /**
22
+ * AI-M49 D2: the options of `options.<id>` whose value is still the default the old schema gave
23
+ * them (the CLI wrote it: `settings add`'s placeholders, a scaffolded default) and whose default
24
+ * the new schema changed take the new default, at any depth of declared objects. A value set by
25
+ * hand (anything but the old default) is kept; so is one whose option has no default any more.
26
+ * `updated`: dotted paths below the section. Like pruneUndeclared for dropped options.
27
+ */
28
+ export declare function followDefaults(source: string, id: string, previous: Record<string, unknown> | null, schema: Record<string, unknown>): {
29
+ source: string;
30
+ updated: string[];
31
+ };
32
+ /**
33
+ * S-b: the options of `options.<id>` the new schema no longer declares, at any depth (`b.y` of
34
+ * an object option too, R9). One whose value is still the default the old schema gave it (the CLI
35
+ * wrote it) is removed; any other is kept and named (`kept`, dotted paths below the section), for
36
+ * the user to remove (`config unset`) or to declare again. An object that takes any key
37
+ * (`additionalProperties`, `patternProperties`) is not judged (R10).
38
+ */
39
+ export declare function pruneUndeclared(source: string, id: string, previous: Record<string, unknown> | null, schema: Record<string, unknown>): {
40
+ source: string;
41
+ kept: string[];
42
+ removed: string[];
43
+ };
@@ -1,5 +1,8 @@
1
- import { isMap, isNode, parseDocument } from 'yaml';
1
+ // ALIAS_KEYWORD (`x-dokebi-alias-of`): what core writes on an old option name, its current name.
2
+ import { ALIAS_KEYWORD } from '@dokebi/core';
3
+ import { isMap, isNode, isScalar, parseDocument } from 'yaml';
2
4
  import { parseProjectConfig, renderConfig } from './configuration.js';
5
+ import { applyReloadableMarks } from './reloadable.js';
3
6
  const SENSITIVE = /(?:password|passwd|secret|token|credential|api.?key|private.?key|connection.?string|url|uri|host|address|endpoint|servers?)/i;
4
7
  const ADDRESS = /^(?:[a-z][a-z0-9+.-]*:\/\/|localhost(?::|$)|(?:\d{1,3}\.){3}\d{1,3}(?::|$))/i;
5
8
  const FORBIDDEN = new Set(['__proto__', '<<']);
@@ -19,6 +22,98 @@ const composite = (schema) => COMPOSITE.some((key) => Object.hasOwn(schema, key)
19
22
  function record(value) {
20
23
  return value !== null && typeof value === 'object' && !Array.isArray(value);
21
24
  }
25
+ /** `name` or `parent.name`, as core's alias targets are. */
26
+ const ALIAS_TARGET = /^([A-Za-z_$][\w$-]*)(?:\.([A-Za-z_$][\w$-]*))?$/;
27
+ /**
28
+ * The fallback for schemas written before core had ALIAS_KEYWORD (plugin versions already on npm,
29
+ * such as @dokebi/web 0.4.2): core's two default descriptions of an old name, read only on a
30
+ * `deprecated: true` property without the keyword. Without it CLIA-3 (a default written beside a
31
+ * set old name, an app that does not start) comes back on every project with such a plugin.
32
+ * Remove it once no supported plugin version lacks the keyword.
33
+ */
34
+ const OLD_NAME_DESCRIPTION = /^(?:Deprecated name of|Renamed to) ([A-Za-z_$][\w$-]*(?:\.[A-Za-z_$][\w$-]*)?); use that key\.$/;
35
+ /**
36
+ * The old names of an object schema (D-0071 `option.alias`, and `option.renamed`, which core
37
+ * refuses anyway) and the field each one names: `bodyLimit` → [maxBodyBytes],
38
+ * `maxReconnectAttempts` → [reconnect, maxAttempts]. Core refuses an old name and its field set
39
+ * together (INVALID_OPTIONS `conflict`), so the CLI writes, fills or shows no default of a field
40
+ * whose old name is set. Read from the keyword core puts on the old name (`x-dokebi-alias-of`);
41
+ * a schema from before the keyword by core's default description (OLD_NAME_DESCRIPTION).
42
+ */
43
+ export function aliasTargets(schema) {
44
+ const result = new Map();
45
+ if (!record(schema) || !record(schema.properties))
46
+ return result;
47
+ for (const [key, property] of Object.entries(schema.properties)) {
48
+ if (!record(property))
49
+ continue;
50
+ const target = typeof property[ALIAS_KEYWORD] === 'string'
51
+ ? property[ALIAS_KEYWORD]
52
+ : property.deprecated === true && typeof property.description === 'string'
53
+ ? OLD_NAME_DESCRIPTION.exec(property.description)?.[1]
54
+ : undefined;
55
+ if (target === undefined)
56
+ continue;
57
+ const match = ALIAS_TARGET.exec(target);
58
+ if (!match?.[1] || match[1] === key)
59
+ continue;
60
+ result.set(key, match[2] ? [match[1], match[2]] : [match[1]]);
61
+ }
62
+ return result;
63
+ }
64
+ /**
65
+ * The places in `value` (an options section) where an old name and the field it sets are both
66
+ * set, which core refuses at start (INVALID_OPTIONS `conflict`): `{ alias, field }` as dotted
67
+ * paths below the section (`bodyLimit`, `maxBodyBytes`; `connections.main.reconnectAttempts`,
68
+ * `connections.main.reconnect.maxAttempts`).
69
+ */
70
+ export function aliasConflicts(value, schema) {
71
+ const found = [];
72
+ const forms = (node, depth = 0) => {
73
+ if (!record(node) || depth > 8)
74
+ return [];
75
+ return [
76
+ node,
77
+ ...['anyOf', 'oneOf', 'allOf'].flatMap((key) => Array.isArray(node[key])
78
+ ? node[key].flatMap((item) => forms(item, depth + 1))
79
+ : []),
80
+ ];
81
+ };
82
+ const visit = (item, nodes, path, depth) => {
83
+ if (depth > 16)
84
+ return;
85
+ if (Array.isArray(item)) {
86
+ const items = nodes.flatMap((node) => forms(node.items));
87
+ for (const [index, child] of item.entries())
88
+ visit(child, items, [...path, String(index)], depth + 1);
89
+ return;
90
+ }
91
+ if (!record(item))
92
+ return;
93
+ for (const node of nodes)
94
+ for (const [alias, [first, second]] of aliasTargets(node)) {
95
+ if (!Object.hasOwn(item, alias))
96
+ continue;
97
+ const parent = item[first];
98
+ const set = second === undefined
99
+ ? Object.hasOwn(item, first)
100
+ : record(parent) && Object.hasOwn(parent, second);
101
+ const field = [...path, first, ...(second === undefined ? [] : [second])].join('.');
102
+ if (set && !found.some((conflict) => conflict.field === field))
103
+ found.push({ alias: [...path, alias].join('.'), field });
104
+ }
105
+ for (const [key, child] of Object.entries(item)) {
106
+ const next = nodes.flatMap((node) => {
107
+ const properties = record(node.properties) ? node.properties : {};
108
+ return forms(Object.hasOwn(properties, key) ? properties[key] : node.additionalProperties);
109
+ });
110
+ if (next.length)
111
+ visit(child, next, [...path, key], depth + 1);
112
+ }
113
+ };
114
+ visit(value, forms(schema), [], 0);
115
+ return found;
116
+ }
22
117
  function description(value) {
23
118
  if (typeof value !== 'string')
24
119
  return '';
@@ -69,16 +164,33 @@ export function scaffoldConfig(source, id, schema) {
69
164
  parseProjectConfig(source);
70
165
  const document = parseDocument(source, { version: '1.2' });
71
166
  const base = ['options', id];
167
+ const empty = document.getIn(base, true);
72
168
  if (!document.hasIn(base))
73
169
  document.setIn(base, document.createNode({}));
170
+ else if (isScalar(empty) && empty.value === null) {
171
+ // An empty section (`vault:` alone) gets its defaults like a missing one; its comment stays.
172
+ const map = document.createNode({});
173
+ if (empty.commentBefore)
174
+ map.commentBefore = empty.commentBefore;
175
+ if (empty.comment)
176
+ map.comment = empty.comment;
177
+ document.setIn(base, map);
178
+ }
74
179
  const lines = [
75
180
  'Configuration for ' + id + '. Values marked required must be set before starting.',
76
181
  ];
77
182
  let visited = 0;
183
+ /** Document paths (joined by NUL) of fields an old name that is set already gives a value. */
184
+ const aliased = new Set();
78
185
  const visit = (current, path, names, depth, allowDefaults) => {
79
186
  if (depth > 12 || visited >= 200)
80
187
  return;
81
188
  const properties = record(current.properties) ? current.properties : {};
189
+ // An old name that is set holds the value of its field: no default beside it (core refuses
190
+ // both together, so an add or upgrade would leave an app that does not start).
191
+ for (const [alias, target] of aliasTargets(current))
192
+ if (document.hasIn([...path, alias]))
193
+ aliased.add([...path, ...target].join('\0'));
82
194
  const required = new Set(Array.isArray(current.required)
83
195
  ? current.required.filter((value) => typeof value === 'string')
84
196
  : []);
@@ -118,6 +230,7 @@ export function scaffoldConfig(source, id, schema) {
118
230
  if (canDefault &&
119
231
  writable &&
120
232
  !document.hasIn(keys) &&
233
+ !aliased.has(keys.join('\0')) &&
121
234
  Object.hasOwn(candidate, 'default') &&
122
235
  safeDefault(candidate.default, candidate, labels) &&
123
236
  !FORBIDDEN.has(name)) {
@@ -127,6 +240,12 @@ export function scaffoldConfig(source, id, schema) {
127
240
  document.setIn(parentPath, document.createNode({}));
128
241
  }
129
242
  document.setIn(keys, document.createNode(candidate.default));
243
+ // A default object leaves out the field an old name beside it already sets.
244
+ for (const blocked of aliased) {
245
+ const inside = blocked.split('\0');
246
+ if (inside.length > keys.length && keys.every((key, index) => inside[index] === key))
247
+ document.deleteIn(inside);
248
+ }
130
249
  }
131
250
  if (candidate.type === 'object' && record(candidate.properties)) {
132
251
  const present = document.hasIn(keys);
@@ -143,11 +262,146 @@ export function scaffoldConfig(source, id, schema) {
143
262
  const node = document.getIn(base, true);
144
263
  if (isNode(node)) {
145
264
  const marker = '[dokebi:' + id + ']';
146
- if (!node.commentBefore?.includes(marker))
147
- node.commentBefore =
148
- (node.commentBefore ? node.commentBefore + '\n' : '') + marker + '\n' + lines.join('\n');
265
+ const before = node.commentBefore ?? '';
266
+ const at = before.indexOf(marker);
267
+ // The block after the marker is the CLI's: written again from the current schema (S-b: a
268
+ // changed declaration left the descriptions of options that no longer exist). Only its own
269
+ // lines are replaced; a note the user wrote after them stays (R8).
270
+ let after = '';
271
+ if (at !== -1) {
272
+ const rest = before.slice(at + marker.length).split('\n');
273
+ let index = rest[0]?.trim() === '' ? 1 : 0;
274
+ while (index < rest.length && generatedLine(rest[index]))
275
+ index++;
276
+ after = rest.slice(index).join('\n');
277
+ }
278
+ node.commentBefore =
279
+ (at === -1 ? (before ? before + '\n' : '') : before.slice(0, at)) +
280
+ marker +
281
+ '\n' +
282
+ lines.join('\n') +
283
+ (after ? '\n' + after : '');
149
284
  }
285
+ // D-0154: the options of this plugin a running app reloads get `# reloadable`.
286
+ if (schema)
287
+ applyReloadableMarks(document, new Map([[id, schema]]));
150
288
  const result = renderConfig(document);
151
289
  parseProjectConfig(result);
152
290
  return result;
153
291
  }
292
+ /** A comment line the CLI's description block is made of. */
293
+ function generatedLine(line) {
294
+ const text = line.trim();
295
+ return (text.startsWith('Configuration for ') ||
296
+ text.startsWith('"') ||
297
+ text.startsWith('Additional fields:') ||
298
+ text.startsWith('No options schema is installed'));
299
+ }
300
+ /**
301
+ * AI-M49 D2: the options of `options.<id>` whose value is still the default the old schema gave
302
+ * them (the CLI wrote it: `settings add`'s placeholders, a scaffolded default) and whose default
303
+ * the new schema changed take the new default, at any depth of declared objects. A value set by
304
+ * hand (anything but the old default) is kept; so is one whose option has no default any more.
305
+ * `updated`: dotted paths below the section. Like pruneUndeclared for dropped options.
306
+ */
307
+ export function followDefaults(source, id, previous, schema) {
308
+ if (!previous)
309
+ return { source, updated: [] };
310
+ parseProjectConfig(source);
311
+ const document = parseDocument(source, { version: '1.2' });
312
+ const updated = [];
313
+ const json = (item) => JSON.stringify(isNode(item) ? item.toJSON() : item);
314
+ const visit = (path, current, before, depth) => {
315
+ if (depth > 12)
316
+ return;
317
+ const map = document.getIn(['options', id, ...path], true);
318
+ if (!isMap(map))
319
+ return;
320
+ const declared = record(current.properties) ? current.properties : {};
321
+ const old = record(before.properties) ? before.properties : {};
322
+ for (const pair of [...map.items]) {
323
+ const key = isScalar(pair.key) ? String(pair.key.value) : undefined;
324
+ if (key === undefined)
325
+ continue;
326
+ const now = declared[key];
327
+ const was = old[key];
328
+ if (!record(now) || !record(was) || now.writeOnly === true)
329
+ continue;
330
+ const at = [...path, key];
331
+ if (now.type === 'object' && record(now.properties) && isMap(pair.value)) {
332
+ visit(at, now, was, depth + 1);
333
+ continue;
334
+ }
335
+ if (!Object.hasOwn(was, 'default') || !Object.hasOwn(now, 'default'))
336
+ continue;
337
+ const value = document.getIn(['options', id, ...at]);
338
+ if (json(value) !== JSON.stringify(was.default))
339
+ continue;
340
+ if (JSON.stringify(now.default) === JSON.stringify(was.default))
341
+ continue;
342
+ document.setIn(['options', id, ...at], document.createNode(now.default));
343
+ updated.push(at.join('.'));
344
+ }
345
+ };
346
+ visit([], schema, previous, 0);
347
+ if (!updated.length)
348
+ return { source, updated };
349
+ const result = renderConfig(document);
350
+ parseProjectConfig(result);
351
+ return { source: result, updated };
352
+ }
353
+ /**
354
+ * S-b: the options of `options.<id>` the new schema no longer declares, at any depth (`b.y` of
355
+ * an object option too, R9). One whose value is still the default the old schema gave it (the CLI
356
+ * wrote it) is removed; any other is kept and named (`kept`, dotted paths below the section), for
357
+ * the user to remove (`config unset`) or to declare again. An object that takes any key
358
+ * (`additionalProperties`, `patternProperties`) is not judged (R10).
359
+ */
360
+ export function pruneUndeclared(source, id, previous, schema) {
361
+ parseProjectConfig(source);
362
+ const document = parseDocument(source, { version: '1.2' });
363
+ const kept = [];
364
+ const removed = [];
365
+ const json = (item) => JSON.stringify(isNode(item) ? item.toJSON() : item);
366
+ const visit = (path, current, before, depth) => {
367
+ if (depth > 12)
368
+ return;
369
+ const map = document.getIn(['options', id, ...path], true);
370
+ if (!isMap(map))
371
+ return;
372
+ const open = (current.additionalProperties !== undefined && current.additionalProperties !== false) ||
373
+ current.patternProperties !== undefined;
374
+ const declared = record(current.properties) ? current.properties : {};
375
+ const old = before && record(before.properties) ? before.properties : {};
376
+ for (const pair of [...map.items]) {
377
+ const key = isScalar(pair.key) ? String(pair.key.value) : undefined;
378
+ if (key === undefined)
379
+ continue;
380
+ const at = [...path, key];
381
+ const now = declared[key];
382
+ if (record(now)) {
383
+ if (now.type === 'object' && record(now.properties))
384
+ visit(at, now, record(old[key]) ? old[key] : undefined, depth + 1);
385
+ continue;
386
+ }
387
+ if (open)
388
+ continue;
389
+ const was = old[key];
390
+ const value = document.getIn(['options', id, ...at]);
391
+ if (record(was) &&
392
+ Object.hasOwn(was, 'default') &&
393
+ json(value) === JSON.stringify(was.default)) {
394
+ document.deleteIn(['options', id, ...at]);
395
+ removed.push(at.join('.'));
396
+ }
397
+ else
398
+ kept.push(at.join('.'));
399
+ }
400
+ };
401
+ visit([], schema, previous ?? undefined, 0);
402
+ if (!removed.length)
403
+ return { source, kept, removed };
404
+ const result = renderConfig(document);
405
+ parseProjectConfig(result);
406
+ return { source: result, kept, removed };
407
+ }
@@ -1,19 +1,79 @@
1
1
  import { type Document } from 'yaml';
2
+ import { CliError } from './errors.js';
2
3
  import { type ProjectConfig } from './project-state.js';
3
4
  export declare const CONFIG_FILE = "dokebi.yaml";
4
5
  export declare const LEGACY_CONFIG_FILE = "dokebi.json";
5
6
  export declare const CONFIG_BACKUP = ".dokebi/dokebi.json.backup";
6
- export declare const CONFIG_EXAMPLE = "# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.\n logging:\n level: info\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
7
+ export declare const CONFIG_EXAMPLE = "# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it. Options marked\n # reloadable apply while the app runs when this file is saved (or npx dokebi reload); the\n # others need a restart (D-0154).\n logging:\n level: info # reloadable\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
7
8
  /** The real dokebi.yaml written by init: same shape, its own header. */
8
- export declare const CONFIG_TEMPLATE = "# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.\n logging:\n level: info\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
9
+ export declare const CONFIG_TEMPLATE = "# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions:\n # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it. Options marked\n # reloadable apply while the app runs when this file is saved (or npx dokebi reload); the\n # others need a restart (D-0154).\n logging:\n level: info # reloadable\n # Per-plugin levels by plugin name, louder or quieter than level:\n # plugins:\n # dokebi-nats: debug\n";
10
+ /**
11
+ * D-0151: where records go with @dokebi/logger, written into options.logging when the logger
12
+ * plugin is added to a project whose logging block has no targets (init adds it by default).
13
+ */
14
+ export declare const LOGGING_TARGETS = "targets:\n # JSON lines on stdout: what dokebi start, dokebi verify, containers and systemd collect.\n - type: stdout\n when: [production, test, other]\n # Readable lines for dokebi dev (pino-pretty, loaded only for this target).\n - type: pretty\n when: [development]\n # Rotated files for a server that keeps its own log files (systemd, pm2, a VM): set enabled\n # to true there. Containers keep stdout. Skipped under dokebi start, which already rotates\n # .dokebi/run/app.log (withDokebiStart: true writes both).\n - type: file\n enabled: false\n folder: logs\n file: app.log\n rotate: { maxBytes: 52428800, frequency: daily }\n keep: { count: 10, maxAgeMs: 1209600000 }\n";
15
+ /** Adds LOGGING_TARGETS to options.logging when it has no `targets`; else returns the source. */
16
+ export declare function addLoggingTargets(source: string): string;
9
17
  /**
10
18
  * Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
11
19
  * `options` are written in block style, so schema comments and values stay readable even when the
12
20
  * file started from `options: {}`. Values are unchanged; only the layout is normalized.
13
21
  */
14
22
  export declare function renderConfig(document: Document): string;
15
- /** Never committed: real settings, the legacy backup and the generated secrets in .env (D-0073). */
23
+ /**
24
+ * Never committed: the real settings with their secrets (D-0073 addendum 2), the legacy backup,
25
+ * and .env (still passed to the app by dev, start and verify; dev-infra's database password).
26
+ */
16
27
  export declare const CONFIG_IGNORES: string[];
28
+ /** The settings file of a project the config commands read and change (M50). */
29
+ export interface ConfigTarget {
30
+ /** As the results name it: project-relative with `/` (`dokebi.yaml`), absolute outside it. */
31
+ readonly file: string;
32
+ /** Absolute path. */
33
+ readonly path: string;
34
+ /** Inside the project folder (no link out of it): a changing command may write it. */
35
+ readonly inside: boolean;
36
+ /** Named by DOKEBI_CONFIG (not the default dokebi.yaml). */
37
+ readonly fromEnvironment: boolean;
38
+ }
39
+ /**
40
+ * The file the app reads: DOKEBI_CONFIG when it is set and not empty (an empty value is no value,
41
+ * D-0154 D4), else dokebi.yaml; a relative path is from the project folder, the folder the app,
42
+ * `dokebi dev` and dokebi-migrate resolve it from. Links are followed to tell inside from outside.
43
+ */
44
+ export declare function configTarget(root: string, environment?: NodeJS.ProcessEnv): Promise<ConfigTarget>;
45
+ /**
46
+ * The settings the app reads, for every CLI reader (doctor, describe, verify, dev, start, the
47
+ * app URLs, recipes, check): the file configTarget names, bounded like dokebi.yaml and followed
48
+ * when it is a link. File system errors (ENOENT) are thrown as they are; callers keep their own
49
+ * handling of a missing file.
50
+ */
51
+ export declare function readProjectSettings(root: string): Promise<{
52
+ target: ConfigTarget;
53
+ source: string;
54
+ }>;
55
+ /**
56
+ * The source of a settings file DOKEBI_CONFIG names; one that cannot be read (also not there)
57
+ * is READ_FAILED naming the variable, never dokebi.yaml instead.
58
+ */
59
+ export declare function readNamedSettings(root: string, target: ConfigTarget): Promise<string>;
60
+ /**
61
+ * The fix of a missing dokebi.yaml (D-0163, R3 Claude T4 review): it stays out of version control,
62
+ * so a fresh copy of the project has none, and `config init` writes one from the sample.
63
+ */
64
+ export declare const FRESH_COPY_FIX = "dokebi.yaml stays out of version control, so a fresh copy of the project (a clone, a CI checkout) has none: run npx dokebi config init in the project folder (it writes dokebi.yaml from dokebi.example.yaml with new generated secrets) and follow its nextSteps";
65
+ /** NOT_A_PROJECT for a missing dokebi.yaml, with FRESH_COPY_FIX. */
66
+ export declare function missingConfig(root: string, message: string): CliError;
67
+ /**
68
+ * CONFIG_OUTSIDE (UNSAFE_PATH): a changing config command never writes a settings file outside
69
+ * the project (as with a linked dokebi.yaml), and never changes another file instead.
70
+ */
71
+ export declare function configOutsideProject(target: ConfigTarget): CliError;
72
+ /** Read-only configuration boundary: runtime also follows this explicit root YAML link.
73
+ * Never use this for mutation; Transaction and securePath must still reject linked files.
74
+ * `path`: the file DOKEBI_CONFIG names (configTarget), else dokebi.yaml of `root`.
75
+ */
76
+ export declare function readProjectConfigSource(root: string, path?: string): Promise<string>;
17
77
  export declare function parseProjectConfig(source: string): ProjectConfig;
18
78
  export declare function addConfigSection(source: string, id: string): string;
19
79
  export declare function migrateConfig(source: string): string;
@@ -1,5 +1,9 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
1
4
  import { ConfigError, parseConfig } from '@dokebi/config';
2
- import { isMap, parseDocument, stringify, visit } from 'yaml';
5
+ import { isMap, isNode, parseDocument, stringify, visit } from 'yaml';
6
+ import { debug, isMissing } from './diagnostics.js';
3
7
  import { CliError } from './errors.js';
4
8
  import { readConfig } from './project-state.js';
5
9
  export const CONFIG_FILE = 'dokebi.yaml';
@@ -7,9 +11,11 @@ export const LEGACY_CONFIG_FILE = 'dokebi.json';
7
11
  export const CONFIG_BACKUP = '.dokebi/dokebi.json.backup';
8
12
  /** The logging block of a new project (D-0047): core reads options.logging, not a plugin. */
9
13
  const LOGGING_BLOCK = `options:
10
- # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it.
14
+ # Log level: debug, info, warn, error or silent. DOKEBI_LOG_LEVEL overrides it. Options marked
15
+ # reloadable apply while the app runs when this file is saved (or npx dokebi reload); the
16
+ # others need a restart (D-0154).
11
17
  logging:
12
- level: info
18
+ level: info # reloadable
13
19
  # Per-plugin levels by plugin name, louder or quieter than level:
14
20
  # plugins:
15
21
  # dokebi-nats: debug
@@ -17,6 +23,45 @@ const LOGGING_BLOCK = `options:
17
23
  export const CONFIG_EXAMPLE = `# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\n${LOGGING_BLOCK}`;
18
24
  /** The real dokebi.yaml written by init: same shape, its own header. */
19
25
  export const CONFIG_TEMPLATE = `# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\n${LOGGING_BLOCK}`;
26
+ /**
27
+ * D-0151: where records go with @dokebi/logger, written into options.logging when the logger
28
+ * plugin is added to a project whose logging block has no targets (init adds it by default).
29
+ */
30
+ export const LOGGING_TARGETS = `targets:
31
+ # JSON lines on stdout: what dokebi start, dokebi verify, containers and systemd collect.
32
+ - type: stdout
33
+ when: [production, test, other]
34
+ # Readable lines for dokebi dev (pino-pretty, loaded only for this target).
35
+ - type: pretty
36
+ when: [development]
37
+ # Rotated files for a server that keeps its own log files (systemd, pm2, a VM): set enabled
38
+ # to true there. Containers keep stdout. Skipped under dokebi start, which already rotates
39
+ # .dokebi/run/app.log (withDokebiStart: true writes both).
40
+ - type: file
41
+ enabled: false
42
+ folder: logs
43
+ file: app.log
44
+ rotate: { maxBytes: 52428800, frequency: daily }
45
+ keep: { count: 10, maxAgeMs: 1209600000 }
46
+ `;
47
+ /** Adds LOGGING_TARGETS to options.logging when it has no `targets`; else returns the source. */
48
+ export function addLoggingTargets(source) {
49
+ const document = parseDocument(source);
50
+ if (document.hasIn(['options', 'logging', 'targets']))
51
+ return source;
52
+ if (!document.hasIn(['options']))
53
+ document.setIn(['options'], document.createNode({}));
54
+ const logging = document.getIn(['options', 'logging'], true);
55
+ if (logging === undefined || logging === null || (isNode(logging) && !isMap(logging)))
56
+ document.setIn(['options', 'logging'], document.createNode({}));
57
+ const target = document.getIn(['options', 'logging'], true);
58
+ if (!isMap(target))
59
+ return source;
60
+ const block = parseDocument(LOGGING_TARGETS);
61
+ target.set('targets', block.get('targets', true));
62
+ debug('Added the logger targets to options.logging', { targets: 3 });
63
+ return renderConfig(document);
64
+ }
20
65
  /**
21
66
  * Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
22
67
  * `options` are written in block style, so schema comments and values stay readable even when the
@@ -39,8 +84,137 @@ export function renderConfig(document) {
39
84
  }
40
85
  return document.toString();
41
86
  }
42
- /** Never committed: real settings, the legacy backup and the generated secrets in .env (D-0073). */
87
+ /**
88
+ * Never committed: the real settings with their secrets (D-0073 addendum 2), the legacy backup,
89
+ * and .env (still passed to the app by dev, start and verify; dev-infra's database password).
90
+ */
43
91
  export const CONFIG_IGNORES = ['dokebi.yaml', 'dokebi.json', '.dokebi/dokebi.json.backup', '.env'];
92
+ /**
93
+ * The file the app reads: DOKEBI_CONFIG when it is set and not empty (an empty value is no value,
94
+ * D-0154 D4), else dokebi.yaml; a relative path is from the project folder, the folder the app,
95
+ * `dokebi dev` and dokebi-migrate resolve it from. Links are followed to tell inside from outside.
96
+ */
97
+ export async function configTarget(root, environment = process.env) {
98
+ const base = await realpath(root);
99
+ const named = environment.DOKEBI_CONFIG;
100
+ if (!named)
101
+ return {
102
+ file: CONFIG_FILE,
103
+ path: join(base, CONFIG_FILE),
104
+ inside: true,
105
+ fromEnvironment: false,
106
+ };
107
+ const given = resolve(base, named);
108
+ let path = given;
109
+ try {
110
+ path = await realpath(given);
111
+ }
112
+ catch (error) {
113
+ // A file that is not there yet: its folder decides where it is.
114
+ if (!isMissing(error))
115
+ throw error;
116
+ }
117
+ const relation = relative(base, path);
118
+ // R4CA-4: `..staging.yaml` is a file of the project; only a `..` segment leaves it.
119
+ const inside = relation !== '' &&
120
+ relation !== '..' &&
121
+ !relation.startsWith(`..${sep}`) &&
122
+ !isAbsolute(relation);
123
+ const file = inside ? relation.split(sep).join('/') : path;
124
+ debug('Settings file named by DOKEBI_CONFIG', { file, inside });
125
+ return { file, path, inside, fromEnvironment: true };
126
+ }
127
+ /**
128
+ * The settings the app reads, for every CLI reader (doctor, describe, verify, dev, start, the
129
+ * app URLs, recipes, check): the file configTarget names, bounded like dokebi.yaml and followed
130
+ * when it is a link. File system errors (ENOENT) are thrown as they are; callers keep their own
131
+ * handling of a missing file.
132
+ */
133
+ export async function readProjectSettings(root) {
134
+ const target = await configTarget(root);
135
+ return { target, source: await readProjectConfigSource(root, target.path) };
136
+ }
137
+ /**
138
+ * The source of a settings file DOKEBI_CONFIG names; one that cannot be read (also not there)
139
+ * is READ_FAILED naming the variable, never dokebi.yaml instead.
140
+ */
141
+ export async function readNamedSettings(root, target) {
142
+ try {
143
+ return await readProjectConfigSource(root, target.path);
144
+ }
145
+ catch (error) {
146
+ if (error instanceof CliError)
147
+ throw error;
148
+ throw new CliError('READ_FAILED', `Cannot read ${target.file}, the settings file DOKEBI_CONFIG names`, {
149
+ cause: error,
150
+ phase: 'arguments',
151
+ details: {
152
+ file: target.file,
153
+ fix: 'point DOKEBI_CONFIG at the settings file the app reads, or unset it to use dokebi.yaml',
154
+ },
155
+ });
156
+ }
157
+ }
158
+ /**
159
+ * The fix of a missing dokebi.yaml (D-0163, R3 Claude T4 review): it stays out of version control,
160
+ * so a fresh copy of the project has none, and `config init` writes one from the sample.
161
+ */
162
+ export const FRESH_COPY_FIX = 'dokebi.yaml stays out of version control, so a fresh copy of the project (a clone, a CI checkout) has none: run npx dokebi config init in the project folder (it writes dokebi.yaml from dokebi.example.yaml with new generated secrets) and follow its nextSteps';
163
+ /** NOT_A_PROJECT for a missing dokebi.yaml, with FRESH_COPY_FIX. */
164
+ export function missingConfig(root, message) {
165
+ return new CliError('NOT_A_PROJECT', message, {
166
+ phase: 'arguments',
167
+ details: { root, file: CONFIG_FILE, fix: FRESH_COPY_FIX },
168
+ });
169
+ }
170
+ /**
171
+ * CONFIG_OUTSIDE (UNSAFE_PATH): a changing config command never writes a settings file outside
172
+ * the project (as with a linked dokebi.yaml), and never changes another file instead.
173
+ */
174
+ export function configOutsideProject(target) {
175
+ return new CliError('UNSAFE_PATH', `DOKEBI_CONFIG names ${target.file}, outside the project: config commands change files of the project only`, {
176
+ phase: 'arguments',
177
+ details: {
178
+ file: target.file,
179
+ fix: 'edit that file yourself (npx dokebi config show reads it with secrets hidden), or run the command without DOKEBI_CONFIG to change the dokebi.yaml of the project',
180
+ },
181
+ });
182
+ }
183
+ /** Read-only configuration boundary: runtime also follows this explicit root YAML link.
184
+ * Never use this for mutation; Transaction and securePath must still reject linked files.
185
+ * `path`: the file DOKEBI_CONFIG names (configTarget), else dokebi.yaml of `root`.
186
+ */
187
+ export async function readProjectConfigSource(root, path) {
188
+ debug('Reading project configuration', { file: path ?? CONFIG_FILE });
189
+ const limit = 1_048_576; // Same 1 MiB source bound as @dokebi/config.
190
+ const handle = await open(path ?? join(root, CONFIG_FILE), constants.O_RDONLY | constants.O_NONBLOCK);
191
+ try {
192
+ const stats = await handle.stat();
193
+ if (!stats.isFile())
194
+ throw new CliError('READ_FAILED', 'Configuration source must be a regular file');
195
+ if (stats.size > limit)
196
+ throw new CliError('INVALID_CONFIG', 'Configuration source exceeds 1 MiB');
197
+ const bytes = Buffer.alloc(limit + 1);
198
+ let length = 0;
199
+ while (length < bytes.length) {
200
+ const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null);
201
+ if (bytesRead === 0)
202
+ break;
203
+ length += bytesRead;
204
+ }
205
+ if (length > limit)
206
+ throw new CliError('INVALID_CONFIG', 'Configuration source exceeds 1 MiB');
207
+ try {
208
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length));
209
+ }
210
+ catch (cause) {
211
+ throw new CliError('INVALID_CONFIG', 'Configuration source must be valid UTF-8', { cause });
212
+ }
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ }
44
218
  export function parseProjectConfig(source) {
45
219
  try {
46
220
  return readConfig(parseConfig(source));
@@ -0,0 +1,2 @@
1
+ /** Optional, editable deployment files written atomically with a new project. */
2
+ export declare const DEPLOYMENT_FILES: Readonly<Record<string, string>>;