@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,289 @@
1
+ // `dokebi config diff [<plugin-id>]` (AI-M45): which options dokebi.yaml and dokebi.example.yaml do
2
+ // not share, by path only. An agent keeping the sample in step dumped both files through
3
+ // loadConfig to compare them and printed a database password and two signing secrets into its
4
+ // log. This compares inside the CLI and never puts a value into the result, errors or debug logs.
5
+ //
6
+ // - onlyInConfig / onlyInExample: option paths one file sets and the other does not (a subtree
7
+ // the other lacks is one path, a list is one value);
8
+ // - different: paths both set to different non-secret values (a sample value is allowed to
9
+ // differ; the list says where);
10
+ // - secrets: secret paths of dokebi.yaml (as config show hides them, and a subtree only in
11
+ // dokebi.yaml that holds nothing else). They are never compared and never count as missing
12
+ // from the sample, which must not hold them;
13
+ // - exampleSecrets: secret paths that hold a value in dokebi.example.yaml (a leak to remove).
14
+ //
15
+ // `--write` (AI-M46: a builder kept the sample in step with a script of its own, since config set
16
+ // writes dokebi.yaml only) copies the paths only in dokebi.yaml into the sample, without their
17
+ // secrets: the same rule that keeps them out of the comparison drops them from the copy.
18
+ // `--write --values` (Studio v2, 10-31) also writes dokebi.yaml's value of every `different` path
19
+ // into the sample, the same way (a secret is never compared, so never copied).
20
+ import { readFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { configShow, hideCredentials, readSettings, secretOption, } from './config-show.js';
23
+ import { CONFIG_FILE, missingConfig, parseProjectConfig } from './configuration.js';
24
+ import { debug, isMissing } from './diagnostics.js';
25
+ import { envFileWarnings } from './env-file.js';
26
+ import { CliError } from './errors.js';
27
+ export const EXAMPLE_FILE = 'dokebi.example.yaml';
28
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+ /** A value worth calling set: not null, not '', not an empty list or mapping. */
30
+ function holdsValue(value) {
31
+ if (value === null || value === undefined || value === '')
32
+ return false;
33
+ if (Array.isArray(value))
34
+ return value.some(holdsValue);
35
+ if (isObject(value))
36
+ return Object.values(value).some(holdsValue);
37
+ return true;
38
+ }
39
+ function same(a, b) {
40
+ if (Array.isArray(a) || Array.isArray(b) || isObject(a) || isObject(b))
41
+ return JSON.stringify(a) === JSON.stringify(b);
42
+ return a === b;
43
+ }
44
+ // Text with credentials (core's maskCredentials: `scheme://user:password@`, `Password=…`) is a
45
+ // secret wherever it is; a `<password>` placeholder is the documented shape (hideCredentials).
46
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
47
+ /** `scheme://user@`: a URL whose user has no password (group 1 is the scheme and slashes). */
48
+ const URL_USER_ONLY = /^([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+@/i;
49
+ /** A placeholder the scaffold writes for a value to fill in: `<set … here …>`. */
50
+ const PLACEHOLDER = /^<[^<>]*>$/;
51
+ /** Text anywhere in the value (what a secret name hides; numbers and booleans show). */
52
+ function holdsText(value) {
53
+ if (typeof value === 'string')
54
+ return value !== '';
55
+ if (Array.isArray(value))
56
+ return value.some(holdsText);
57
+ if (isObject(value))
58
+ return Object.values(value).some(holdsText);
59
+ return false;
60
+ }
61
+ export async function configDiff(root, section) {
62
+ // M50: the settings the app reads (DOKEBI_CONFIG, else dokebi.yaml), against the one sample.
63
+ const settings = await readSettings(root);
64
+ return (await compare(root, settings.source, await readProjectFile(root, EXAMPLE_FILE), section, settings.target.file)).result;
65
+ }
66
+ /**
67
+ * What `config diff --write` copies into the sample: every path only in dokebi.yaml with the
68
+ * secrets inside it dropped (a secret leaf; a list with a secret item is left out whole).
69
+ */
70
+ export async function sampleAdditions(root, configSource, exampleSource, section,
71
+ /** The settings file `configSource` is (dokebi.yaml, or the one DOKEBI_CONFIG names). */
72
+ file = CONFIG_FILE) {
73
+ return compare(root, configSource, exampleSource, section, file);
74
+ }
75
+ async function compare(root, configSource, exampleSource, section, file) {
76
+ const config = parseProjectConfig(configSource).options;
77
+ const example = parseProjectConfig(exampleSource).options;
78
+ // Schema-marked and name-marked secrets of dokebi.yaml, exactly as config show hides them.
79
+ const shown = await configShow(root, undefined, { source: configSource, file });
80
+ // The sample's own secrets too (schema-marked ones in a subtree or list item only it has).
81
+ const sampleShown = await configShow(root, undefined, { source: exampleSource });
82
+ // A section nobody declares hides every text value (fail closed in config show); in the
83
+ // sample that would call each old value a secret, so only declared sections count here.
84
+ // Sections without a schema in either file: there the name of a key is all there is to go on.
85
+ const unknownSections = new Set([...(shown.warnings ?? []), ...(sampleShown.warnings ?? [])]
86
+ .filter((warning) => warning.code !== 'OPTIONS_SCHEMA_UNREADABLE')
87
+ .map((warning) => warning.plugin));
88
+ const blind = new Set((sampleShown.warnings ?? [])
89
+ .filter((warning) => warning.code !== 'OPTIONS_SCHEMA_UNREADABLE')
90
+ .map((warning) => warning.plugin));
91
+ const sampleSecretPaths = new Set(sampleShown.secrets.filter((path) => !blind.has(path.split('.')[0] ?? '')));
92
+ // dokebi.yaml is judged fail closed (what config show hides is never compared or copied).
93
+ const secretPaths = new Set([...shown.secrets, ...sampleSecretPaths]);
94
+ // R4CA-1: a value of the sample is a leaked secret by the sample's own rule: in a section
95
+ // nobody declares (one a removed plugin left, D-0153) by name and credentials only. Before,
96
+ // the fail-closed paths of dokebi.yaml's same section called every plain sample value there a
97
+ // secret with a value (`remove them`).
98
+ for (const path of shown.secrets)
99
+ if (!unknownSections.has(path.split('.')[0] ?? ''))
100
+ sampleSecretPaths.add(path);
101
+ const isSecret = (path, value, paths = secretPaths) => {
102
+ for (let length = path.length; length > 0; length--)
103
+ if (paths.has(path.slice(0, length).join('.')))
104
+ return true;
105
+ const key = path[path.length - 1];
106
+ // Core's name rule, as config show applies it: `transientLogMaxKeys: 256` is a count, and a
107
+ // list or object under a secret name is a secret by its text. A section with a schema
108
+ // already went through config show, which honours `plain`.
109
+ if (key !== undefined &&
110
+ !/^\d+$/.test(key) &&
111
+ unknownSections.has(path[0] ?? '') &&
112
+ secretOption(key, value) &&
113
+ (Array.isArray(value) || isObject(value) ? holdsText(value) : holdsValue(value)))
114
+ return true;
115
+ return typeof value === 'string' && hideCredentials(value) !== undefined;
116
+ };
117
+ const sampleSecret = (path, value) => isSecret(path, value, sampleSecretPaths);
118
+ // A sample URL without credentials is the documented placeholder of a secret URL, not a leak;
119
+ // nor is the scaffold's `<set the postgres URL here or run npx dokebi dev-infra>`, also as the
120
+ // items of a list (a list or object leaks when one of its values does).
121
+ const leaks = (value) => {
122
+ if (Array.isArray(value))
123
+ return value.some(leaks);
124
+ if (isObject(value))
125
+ return Object.values(value).some(leaks);
126
+ if (!holdsValue(value))
127
+ return false;
128
+ if (typeof value !== 'string')
129
+ return true;
130
+ const text = value.trim();
131
+ // A user without a password (`postgres://blog@host`) is the documented shape too; core
132
+ // masks that user in logs and config show hides it, but it is no password in the sample.
133
+ const withoutUser = text.replace(URL_USER_ONLY, '$1');
134
+ return !((URL_SCHEME.test(text) && hideCredentials(withoutUser) === undefined) ||
135
+ PLACEHOLDER.test(text));
136
+ };
137
+ const DROP = Symbol('secret');
138
+ /** The value without its secrets; DROP when nothing but secrets (or nothing) is left. */
139
+ const scrub = (value, path) => {
140
+ if (isSecret(path, value))
141
+ return DROP;
142
+ if (Array.isArray(value)) {
143
+ const items = value.map((item, index) => scrub(item, [...path, String(index)]));
144
+ // Dropping an item would change what the list means: a list with a secret item is left out.
145
+ return items.includes(DROP) ? DROP : items;
146
+ }
147
+ if (isObject(value)) {
148
+ const kept = {};
149
+ for (const [key, item] of Object.entries(value)) {
150
+ const clean = scrub(item, [...path, key]);
151
+ if (clean !== DROP)
152
+ kept[key] = clean;
153
+ }
154
+ return Object.keys(kept).length || !Object.keys(value).length ? kept : DROP;
155
+ }
156
+ return value;
157
+ };
158
+ const additions = [];
159
+ /** `different` paths with dokebi.yaml's value without secrets (`--write --values`). */
160
+ const changes = [];
161
+ /** Secrets with a value in the sample, as paths (what `--prune` always removes). */
162
+ const prunable = [];
163
+ /**
164
+ * Secrets inside a sample value that is compared whole (a list, an object only the sample has):
165
+ * logging.targets.<n>.token of a network log target. The value itself was not a secret.
166
+ */
167
+ const leakedInside = (value, path) => {
168
+ if (Array.isArray(value)) {
169
+ for (const [index, item] of value.entries())
170
+ leakedInside(item, [...path, String(index)]);
171
+ return;
172
+ }
173
+ if (isObject(value)) {
174
+ for (const [key, item] of Object.entries(value))
175
+ leakedInside(item, [...path, key]);
176
+ return;
177
+ }
178
+ if (sampleSecret(path, value) && leaks(value)) {
179
+ exampleSecrets.push(path.join('.'));
180
+ prunable.push(path);
181
+ }
182
+ };
183
+ const onlyInConfig = [];
184
+ const onlyInExample = [];
185
+ const different = [];
186
+ const secrets = [];
187
+ const exampleSecrets = [];
188
+ const walk = (left, right, path, hasLeft, hasRight) => {
189
+ const at = path.join('.');
190
+ const secretLeft = hasLeft && isSecret(path, left);
191
+ const secretRight = hasRight && isSecret(path, right);
192
+ if (secretLeft || secretRight) {
193
+ if (hasLeft)
194
+ secrets.push(at);
195
+ if (hasRight && sampleSecret(path, right) && leaks(right)) {
196
+ exampleSecrets.push(at);
197
+ prunable.push(path);
198
+ }
199
+ return;
200
+ }
201
+ if (isObject(left) && isObject(right)) {
202
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)]))
203
+ walk(left[key], right[key], [...path, key], Object.hasOwn(left, key), Object.hasOwn(right, key));
204
+ return;
205
+ }
206
+ if (hasLeft && !hasRight) {
207
+ // A subtree that holds nothing but secrets is a secret too: never missing from the sample.
208
+ const value = scrub(left, path);
209
+ if (value === DROP)
210
+ secrets.push(at);
211
+ else {
212
+ onlyInConfig.push(at);
213
+ additions.push({ path, value });
214
+ }
215
+ }
216
+ else if (hasRight && !hasLeft)
217
+ onlyInExample.push(at);
218
+ else if (!same(left, right)) {
219
+ different.push(at);
220
+ // A list or mapping is copied without its secrets, or not at all when one item is secret.
221
+ const value = scrub(left, path);
222
+ if (value !== DROP)
223
+ changes.push({ path, value });
224
+ }
225
+ if (hasRight && (Array.isArray(right) || isObject(right)))
226
+ leakedInside(right, path);
227
+ };
228
+ for (const name of new Set([...Object.keys(config), ...Object.keys(example)])) {
229
+ if (section !== undefined && name !== section)
230
+ continue;
231
+ walk(config[name], example[name], [name], Object.hasOwn(config, name), Object.hasOwn(example, name));
232
+ }
233
+ const warnings = new Map();
234
+ for (const warning of [
235
+ // R4CA-3: what the project's .env sets that neither the app nor this command reads.
236
+ ...envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint })),
237
+ ...(shown.warnings ?? []),
238
+ ...(sampleShown.warnings ?? []),
239
+ ])
240
+ if (section === undefined || warning.plugin === undefined || warning.plugin === section)
241
+ warnings.set(`${warning.code} ${warning.plugin ?? warning.path}`, warning);
242
+ const result = {
243
+ file,
244
+ example: EXAMPLE_FILE,
245
+ section: section ?? null,
246
+ inSync: !onlyInConfig.length && !onlyInExample.length && !exampleSecrets.length,
247
+ onlyInConfig,
248
+ onlyInExample,
249
+ different,
250
+ secrets,
251
+ exampleSecrets,
252
+ ...(warnings.size ? { warnings: [...warnings.values()] } : {}),
253
+ };
254
+ debug('Configuration compared with the sample', {
255
+ section: section ?? null,
256
+ onlyInConfig: onlyInConfig.length,
257
+ onlyInExample: onlyInExample.length,
258
+ different: different.length,
259
+ secrets: secrets.length,
260
+ exampleSecrets: exampleSecrets.length,
261
+ });
262
+ return { result, additions, changes, prunable };
263
+ }
264
+ async function readProjectFile(root, file) {
265
+ try {
266
+ return await readFile(join(root, file), 'utf8');
267
+ }
268
+ catch (error) {
269
+ if (isMissing(error))
270
+ throw file === CONFIG_FILE
271
+ ? missingConfig(root, `No ${file}: run this in a Dokebi project (the folder with dokebi.yaml)`)
272
+ : new CliError('EXAMPLE_MISSING', `No ${file} to compare with (it is the sample of dokebi.yaml without secrets)`, { phase: 'arguments', details: { root, file } });
273
+ throw new CliError('READ_FAILED', `Cannot read ${file}`, { cause: error });
274
+ }
275
+ }
276
+ /** The result for people: paths only. */
277
+ export function configDiffText(result) {
278
+ const list = (label, paths) => paths.length ? [`${label} [${paths.length}]:`, ...paths.map((path) => ` ${path}`)] : [];
279
+ const lines = [
280
+ `# ${result.file} against ${result.example}${result.section ? ` (${result.section})` : ''}: ${result.inSync ? 'in step' : 'not in step'}; values are never shown`,
281
+ ...list(`only in ${result.file}`, result.onlyInConfig),
282
+ ...list(`only in ${result.example}`, result.onlyInExample),
283
+ ...list('set in both to different values', result.different),
284
+ ...list(`secrets of ${result.file} (never compared; the sample must not hold them)`, result.secrets),
285
+ ...list(`secrets with a value in ${result.example} (remove them)`, result.exampleSecrets),
286
+ ...(result.warnings ?? []).map((warning) => `# warning ${warning.code}: ${warning.hint}`),
287
+ ];
288
+ return `${lines.join('\n')}\n`;
289
+ }
@@ -1,3 +1,4 @@
1
+ import { type Document } from 'yaml';
1
2
  import type { ConfigChange } from './plugin-lifecycle.js';
2
3
  export interface SetOption {
3
4
  readonly path: string[];
@@ -9,9 +10,41 @@ export interface Warning {
9
10
  }
10
11
  /** Parses one `--set <path>=<value>` argument. Values are JSON when parseable, otherwise text. */
11
12
  export declare function parseSetOption(argument: string): SetOption;
12
- export declare function setWarnings(sets: readonly SetOption[]): Warning[];
13
+ /**
14
+ * A path whose list items are named turned into item indexes: `targets.studio.token` is
15
+ * `targets.<index>.token` of the item of `targets` whose `name` is studio (else, for an item
16
+ * without a name, whose `type` is). An index stays an index. `undefined` when a named item or an
17
+ * index is not there (callers decide: an error for set, not set for unset). D-0151 follow-up:
18
+ * the targets of options.logging are a list.
19
+ */
20
+ export declare function resolveItems(document: Document, path: readonly string[]): string[] | undefined;
21
+ /**
22
+ * A path with each list index named the way resolveItems finds the item (`name`, else `type` of
23
+ * an item without a name), so the same item is found in another file whose list has another
24
+ * order. `undefined` when an indexed item has neither (it cannot be found elsewhere for sure);
25
+ * the path as given when the item is not in this file.
26
+ */
27
+ export declare function namedItems(source: string, id: string, path: readonly string[]): string[] | undefined;
13
28
  export declare function applySetOptions(source: string, id: string, sets: readonly SetOption[]): string;
14
- export declare function applyConfigChanges(source: string, id: string, changes: readonly ConfigChange[]): string;
29
+ /** The value at `<id>.<path>` of a dokebi.yaml source (list items by name or index), or undefined. */
30
+ export declare function optionAtPath(source: string, id: string, path: readonly string[]): unknown;
31
+ /**
32
+ * `dokebi config unset` (D-0153): removes `options.<id>.<path>` for each path that is set, and
33
+ * the mappings this leaves empty below the plugin section (the section itself stays). Returns
34
+ * the new source and the paths that were set; a source without any of them comes back as it is.
35
+ * Values are never read out.
36
+ */
37
+ export declare function applyUnsetOptions(source: string, id: string, paths: readonly (readonly string[])[]): {
38
+ source: string;
39
+ removed: string[][];
40
+ };
41
+ /** A manifest's change, or `set` (the CLI's own: replaces a value, `config diff --values`). */
42
+ export type ConfigEdit = ConfigChange | {
43
+ readonly op: 'set';
44
+ readonly path: readonly string[];
45
+ readonly value: unknown;
46
+ };
47
+ export declare function applyConfigChanges(source: string, id: string, changes: readonly ConfigEdit[]): string;
15
48
  /**
16
49
  * Removes `options.<host>.apps.<app>` (D-0061 per-app settings of the apps host, D-0070) and the
17
50
  * `apps` map when it is left empty; a source without that entry is returned unchanged. The host
@@ -1,11 +1,10 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { isMap, isNode, isScalar, parseDocument } from 'yaml';
2
+ import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml';
3
+ import { validateDefaults } from './config-values.js';
3
4
  import { parseProjectConfig, renderConfig } from './configuration.js';
4
5
  import { CliError } from './errors.js';
5
- import { validateDefaults } from './merge-install-defaults.js';
6
6
  const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
7
7
  const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype', '<<']);
8
- const SECRET = /(password|passwd|secret|token|credential|api.?key|private.?key)/i;
9
8
  /** Parses one `--set <path>=<value>` argument. Values are JSON when parseable, otherwise text. */
10
9
  export function parseSetOption(argument) {
11
10
  const index = argument.indexOf('=');
@@ -20,25 +19,93 @@ export function parseSetOption(argument) {
20
19
  value = JSON.parse(text);
21
20
  }
22
21
  catch {
23
- /* plain text value */
22
+ // dokebi-check: allow empty-catch a value that is not JSON is the plain text the user typed
24
23
  }
25
24
  try {
26
25
  validateDefaults({ value });
27
26
  }
28
- catch {
29
- throw new CliError('INVALID_SET', 'Invalid --set value');
27
+ catch (cause) {
28
+ throw new CliError('INVALID_SET', 'Invalid --set value', { cause });
30
29
  }
31
30
  return { path, value };
32
31
  }
33
- export function setWarnings(sets) {
34
- return sets
35
- .filter((set) => set.path.some((key) => SECRET.test(key)))
36
- .map((set) => ({ code: 'SECRET_ON_COMMAND_LINE', path: set.path.join('.') }));
32
+ const INDEX = /^(?:0|[1-9][0-9]{0,5})$/;
33
+ /**
34
+ * A path whose list items are named turned into item indexes: `targets.studio.token` is
35
+ * `targets.<index>.token` of the item of `targets` whose `name` is studio (else, for an item
36
+ * without a name, whose `type` is). An index stays an index. `undefined` when a named item or an
37
+ * index is not there (callers decide: an error for set, not set for unset). D-0151 follow-up:
38
+ * the targets of options.logging are a list.
39
+ */
40
+ export function resolveItems(document, path) {
41
+ const result = [];
42
+ for (const segment of path) {
43
+ const node = document.getIn(result, true);
44
+ if (!isSeq(node)) {
45
+ result.push(segment);
46
+ continue;
47
+ }
48
+ let index = INDEX.test(segment)
49
+ ? Number(segment)
50
+ : node.items.findIndex((item) => isMap(item) && item.get('name') === segment);
51
+ if (index < 0)
52
+ index = node.items.findIndex((item) => isMap(item) && !item.has('name') && item.get('type') === segment);
53
+ if (index < 0 || index >= node.items.length)
54
+ return undefined;
55
+ result.push(String(index));
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * A path with each list index named the way resolveItems finds the item (`name`, else `type` of
61
+ * an item without a name), so the same item is found in another file whose list has another
62
+ * order. `undefined` when an indexed item has neither (it cannot be found elsewhere for sure);
63
+ * the path as given when the item is not in this file.
64
+ */
65
+ export function namedItems(source, id, path) {
66
+ const document = parseDocument(source, { version: '1.2' });
67
+ const named = [];
68
+ const walked = ['options', id];
69
+ for (const segment of path) {
70
+ const node = document.getIn(walked, true);
71
+ if (isSeq(node) && INDEX.test(segment)) {
72
+ const item = node.items[Number(segment)];
73
+ if (item === undefined)
74
+ return [...path];
75
+ const name = isMap(item) ? item.get('name') : undefined;
76
+ const type = isMap(item) && !item.has('name') ? item.get('type') : undefined;
77
+ if (typeof name === 'string')
78
+ named.push(name);
79
+ else if (typeof type === 'string')
80
+ named.push(type);
81
+ else
82
+ return undefined;
83
+ }
84
+ else
85
+ named.push(segment);
86
+ walked.push(segment);
87
+ }
88
+ return named;
89
+ }
90
+ /** The names of the items of the list a path goes through (for the error of a missing one). */
91
+ function itemNames(document, path) {
92
+ for (let length = path.length - 1; length > 0; length--) {
93
+ const prefix = resolveItems(document, path.slice(0, length));
94
+ const node = prefix ? document.getIn(prefix, true) : undefined;
95
+ if (!isSeq(node))
96
+ continue;
97
+ return node.items.map((item, index) => isMap(item) ? String(item.get('name') ?? item.get('type') ?? index) : String(index));
98
+ }
99
+ return [];
37
100
  }
38
101
  function ensureMap(document, path, code) {
39
102
  for (let length = 1; length <= path.length; length++) {
40
103
  const current = path.slice(0, length);
41
104
  const node = document.getIn(current, true);
105
+ // An item of a list (an index resolveItems found) is crossed, never created.
106
+ const next = path[length];
107
+ if (isSeq(node) && next !== undefined && INDEX.test(next) && Number(next) < node.items.length)
108
+ continue;
42
109
  if (!document.hasIn(current) || (isScalar(node) && node.value === null))
43
110
  document.setIn(current, document.createNode({}));
44
111
  else if (!isMap(node))
@@ -68,12 +135,77 @@ export function applySetOptions(source, id, sets) {
68
135
  }
69
136
  return edit(source, id, (document) => {
70
137
  for (const set of sets) {
71
- const path = ['options', id, ...set.path];
138
+ const wanted = ['options', id, ...set.path];
139
+ const path = resolveItems(document, wanted);
140
+ if (!path)
141
+ throw new CliError('INVALID_SET', `No list item at ${[id, ...set.path].join('.')}`, {
142
+ details: {
143
+ path: [id, ...set.path].join('.'),
144
+ items: itemNames(document, wanted),
145
+ fix: 'name an item of the list by its name (else its type) or by its index, as npx dokebi config show lists them',
146
+ },
147
+ });
72
148
  ensureMap(document, path.slice(0, -1), 'INVALID_SET');
73
149
  document.setIn(path, document.createNode(set.value));
74
150
  }
75
151
  });
76
152
  }
153
+ /** The value at `<id>.<path>` of a dokebi.yaml source (list items by name or index), or undefined. */
154
+ export function optionAtPath(source, id, path) {
155
+ const document = parseDocument(source, { version: '1.2' });
156
+ const resolved = resolveItems(document, ['options', id, ...path]);
157
+ if (!resolved || !document.hasIn(resolved))
158
+ return undefined;
159
+ const value = document.getIn(resolved, false);
160
+ return isNode(value) ? value.toJSON() : value;
161
+ }
162
+ /**
163
+ * `dokebi config unset` (D-0153): removes `options.<id>.<path>` for each path that is set, and
164
+ * the mappings this leaves empty below the plugin section (the section itself stays). Returns
165
+ * the new source and the paths that were set; a source without any of them comes back as it is.
166
+ * Values are never read out.
167
+ */
168
+ export function applyUnsetOptions(source, id, paths) {
169
+ const seen = new Set();
170
+ for (const path of paths) {
171
+ const key = path.join('.');
172
+ if (seen.has(key))
173
+ throw new CliError('INVALID_SET', 'Repeated config unset path');
174
+ seen.add(key);
175
+ }
176
+ const document = parseDocument(source, { version: '1.2' });
177
+ // List items may be named (resolveItems); one that is not there is a path that is not set.
178
+ const present = [];
179
+ for (const path of paths) {
180
+ const full = resolveItems(document, ['options', id, ...path]);
181
+ if (full && document.hasIn(full))
182
+ present.push({ path: [...path], full });
183
+ }
184
+ const removed = present.map((item) => item.path);
185
+ if (!removed.length)
186
+ return { source, removed };
187
+ // Later list items first, so removing one never shifts the index of another. One item named
188
+ // twice (`targets.studio` and `targets.3`) is removed once: removing it again would take the
189
+ // item that moved into its place.
190
+ const order = [
191
+ ...new Map(present.map((item) => [item.full.join('\0'), item.full])).values(),
192
+ ].sort((a, b) => b.join('\0').localeCompare(a.join('\0'), 'en', { numeric: true }));
193
+ const result = edit(source, id, (target) => {
194
+ for (const full of order) {
195
+ // A path below one removed earlier in this call is gone already.
196
+ if (!target.hasIn(full))
197
+ continue;
198
+ target.deleteIn(full);
199
+ for (let length = full.length - 1; length > 2; length--) {
200
+ const parent = target.getIn(full.slice(0, length), true);
201
+ if (!isMap(parent) || parent.items.length)
202
+ break;
203
+ target.deleteIn(full.slice(0, length));
204
+ }
205
+ }
206
+ });
207
+ return { source: result, removed };
208
+ }
77
209
  export function applyConfigChanges(source, id, changes) {
78
210
  if (!changes.length)
79
211
  return source;
@@ -98,9 +230,9 @@ export function applyConfigChanges(source, id, changes) {
98
230
  if (document.hasIn(path))
99
231
  document.deleteIn(path);
100
232
  }
101
- else if (change.op === 'default' && change.path) {
233
+ else if ((change.op === 'default' || change.op === 'set') && change.path) {
102
234
  const path = [...base, ...change.path];
103
- if (document.hasIn(path))
235
+ if (change.op === 'default' && document.hasIn(path))
104
236
  continue;
105
237
  validateDefaults({ value: change.value });
106
238
  ensureMap(document, path.slice(0, -1), 'UPGRADE_CONFIG_CONFLICT');
@@ -0,0 +1,15 @@
1
+ /** The option keys of a validator issue's instancePath (`/connections/main` → connections, main). */
2
+ export declare function issuePathOf(instancePath: string): string[];
3
+ /**
4
+ * Whether the option at `path` of a section is a secret: marked so in its schema (`writeOnly`,
5
+ * `x-dokebi-secret: true`), or named like one by core's rule unless the schema marks it plain
6
+ * (`x-dokebi-secret: false`), as `config show` hides it.
7
+ */
8
+ export declare function secretInSchema(schema: unknown, path: readonly string[]): boolean;
9
+ /** A sample value still to fill in (`<set the postgres URL here or run npx dokebi dev-infra>`). */
10
+ export declare function isPlaceholder(value: unknown): boolean;
11
+ /**
12
+ * The option paths whose value is a placeholder of the sample (`<set the postgres URL here or
13
+ * run npx dokebi dev-infra>`, `<password>`), as `section.key.key`; list items by index.
14
+ */
15
+ export declare function placeholderPaths(options: Record<string, unknown>): string[];
@@ -0,0 +1,61 @@
1
+ // Pure parts of `dokebi config init` (D-0163): which missing options are secrets the CLI may
2
+ // generate, and which sample values are placeholders still to fill.
3
+ import { secretOption } from './config-show.js';
4
+ /** The option keys of a validator issue's instancePath (`/connections/main` → connections, main). */
5
+ export function issuePathOf(instancePath) {
6
+ return instancePath
7
+ .split('/')
8
+ .slice(1)
9
+ .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'));
10
+ }
11
+ function propertySchema(node, key) {
12
+ if (!node || typeof node !== 'object')
13
+ return undefined;
14
+ const properties = node.properties;
15
+ if (!properties || typeof properties !== 'object')
16
+ return undefined;
17
+ const found = properties[key];
18
+ return found && typeof found === 'object' ? found : undefined;
19
+ }
20
+ /**
21
+ * Whether the option at `path` of a section is a secret: marked so in its schema (`writeOnly`,
22
+ * `x-dokebi-secret: true`), or named like one by core's rule unless the schema marks it plain
23
+ * (`x-dokebi-secret: false`), as `config show` hides it.
24
+ */
25
+ export function secretInSchema(schema, path) {
26
+ let node = schema;
27
+ for (const key of path)
28
+ node = propertySchema(node, key);
29
+ const marked = node;
30
+ if (marked?.['x-dokebi-secret'] === false)
31
+ return false;
32
+ if (marked?.writeOnly === true || marked?.['x-dokebi-secret'] === true)
33
+ return true;
34
+ return secretOption(path.at(-1) ?? '', 'text');
35
+ }
36
+ /** A sample value still to fill in (`<set the postgres URL here or run npx dokebi dev-infra>`). */
37
+ export function isPlaceholder(value) {
38
+ return typeof value === 'string' && /^<[^<>]+>$/.test(value.trim());
39
+ }
40
+ /**
41
+ * The option paths whose value is a placeholder of the sample (`<set the postgres URL here or
42
+ * run npx dokebi dev-infra>`, `<password>`), as `section.key.key`; list items by index.
43
+ */
44
+ export function placeholderPaths(options) {
45
+ const found = [];
46
+ const walk = (value, path) => {
47
+ if (typeof value === 'string') {
48
+ if (isPlaceholder(value))
49
+ found.push(path.join('.'));
50
+ }
51
+ else if (Array.isArray(value)) {
52
+ for (const [index, item] of value.entries())
53
+ walk(item, [...path, `${index}`]);
54
+ }
55
+ else if (value && typeof value === 'object')
56
+ for (const [key, item] of Object.entries(value))
57
+ walk(item, [...path, key]);
58
+ };
59
+ walk(options, []);
60
+ return found;
61
+ }
@@ -0,0 +1,22 @@
1
+ /** The option paths of one file a command changed: dotted, below `options`; a list is one path. */
2
+ export interface OptionPaths {
3
+ readonly removed: string[];
4
+ readonly set: string[];
5
+ readonly added: string[];
6
+ }
7
+ /** The leaf paths (a scalar or a list each) that differ between the options of two sources. */
8
+ export declare function optionChanges(before: string, after: string): OptionPaths;
9
+ /** Each file's changes, the files nothing changed in left out. */
10
+ export declare function filePaths(files: readonly (readonly [string, string | undefined, string | undefined])[]): Record<string, OptionPaths>;
11
+ /**
12
+ * A requested path as the files spell it: `<section>.<path>` with named list items turned into
13
+ * indexes in `source` (`logging.targets.studio.token` → `logging.targets.3.token`); as typed when
14
+ * the item is not there.
15
+ */
16
+ export declare function requestedPath(source: string, section: string, path: readonly string[]): string[];
17
+ /**
18
+ * The changed paths outside the requested ones. A changed path is asked for when it is a
19
+ * requested path or below one, or when it is a list a requested path goes through (a list is
20
+ * compared whole).
21
+ */
22
+ export declare function outsideRequested(changes: Readonly<Record<string, OptionPaths>>, requested: readonly (readonly string[])[]): string[];