@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  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 +638 -37
  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 +15 -0
  46. package/dist/check/shared-state-rules.js +117 -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 +381 -36
  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-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
@@ -4,16 +4,22 @@
4
4
  // GUIDE_BLOCK_MODIFIED (the AGENTS.md installed list between its markers) and
5
5
  // MODIFIED_TEMPLATE (a plugin file declared `owner: 'cli'`, D-0065 §11).
6
6
  // Files scaffolded for the developer (`owner: 'user'`) are never reported.
7
- // secret-in-config a plugin check's SECRET_IN_CONFIG (doctor PLUGIN_CHECK) when dokebi.yaml
8
- // can reach version control (tracked, or not ignored by git)
7
+ // secret-in-config a plugin check's SECRET_IN_CONFIG (doctor PLUGIN_CHECK), an option the
8
+ // plugin schema marks secret (`writeOnly`, D-0073 addendum 2), or any value
9
+ // that is a URL with a password (`scheme://user:password@host`, AI-M04 F9),
10
+ // or the first admin password a recipe wrote (initialPassword of any
11
+ // section: first-admin, the older blog-admin or a renamed one, D-0115),
12
+ // when dokebi.yaml can reach version control (tracked, or not ignored by git)
9
13
  import { execFile } from 'node:child_process';
10
14
  import { readFile } from 'node:fs/promises';
11
15
  import { join } from 'node:path';
12
- import { isMap, isScalar, parseDocument } from 'yaml';
13
- import { CONFIG_FILE, CONFIG_IGNORES } from '../configuration.js';
16
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
17
+ import { CONFIG_FILE, CONFIG_IGNORES, configTarget } from '../configuration.js';
14
18
  import { debug, describeError, isMissing } from '../diagnostics.js';
15
19
  import { CliError } from '../errors.js';
20
+ import { adminSections } from '../first-admin.js';
16
21
  import { AGENTS_FILE, INSTALLED_START } from '../project-guide.js';
22
+ import { ADMIN_PASSWORD_KEY } from '../recipe-login.js';
17
23
  /** git's answer for one path: 0/1 exit codes, or undefined when git or the repository is absent. */
18
24
  function git(root, args) {
19
25
  return new Promise((resolve) => {
@@ -30,14 +36,18 @@ function git(root, args) {
30
36
  });
31
37
  }
32
38
  /**
33
- * Whether dokebi.yaml can end up in version control: git tracks it, or git does not ignore it.
34
- * Without git (or outside a repository) the project's .gitignore must list it.
39
+ * Whether the settings file (dokebi.yaml, or the one DOKEBI_CONFIG names) can end up in version
40
+ * control: git tracks it, or git does not ignore it. Without git (or outside a repository) the
41
+ * project's .gitignore must list it. A file outside the project is not in its repository.
35
42
  */
36
- export async function configReachesGit(root) {
37
- const tracked = await git(root, ['ls-files', '--error-unmatch', '--', CONFIG_FILE]);
43
+ export async function configReachesGit(root, settings) {
44
+ if (settings && !settings.inside)
45
+ return false;
46
+ const file = settings?.file ?? CONFIG_FILE;
47
+ const tracked = await git(root, ['ls-files', '--error-unmatch', '--', file]);
38
48
  if (tracked === 0)
39
49
  return true;
40
- const ignored = await git(root, ['check-ignore', '-q', '--', CONFIG_FILE]);
50
+ const ignored = await git(root, ['check-ignore', '-q', '--', file]);
41
51
  if (ignored !== undefined)
42
52
  return ignored !== 0;
43
53
  let text = '';
@@ -48,7 +58,7 @@ export async function configReachesGit(root) {
48
58
  if (!isMissing(error))
49
59
  throw new CliError('READ_FAILED', 'Cannot read .gitignore', { cause: error });
50
60
  }
51
- const entry = CONFIG_IGNORES[0];
61
+ const entry = settings?.file ?? CONFIG_IGNORES[0];
52
62
  return !text
53
63
  .split(/\r?\n/)
54
64
  .map((line) => line.trim())
@@ -76,20 +86,204 @@ export function optionLine(source, path) {
76
86
  line++;
77
87
  return line;
78
88
  }
89
+ /** A URL that carries a password: `scheme://user:password@host…` (a `<placeholder>` is none). */
90
+ const PASSWORD_URL = /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]*:([^@/\s]+)@/i;
91
+ /**
92
+ * Values of dokebi.yaml that are URLs with a password (AI-M04 F9): the option path and its line.
93
+ * Plugins read connection URLs from dokebi.yaml (it has no environment references), so the URL is
94
+ * fine there while the file stays out of git; only the path is returned, never the value.
95
+ */
96
+ export function passwordUrls(source) {
97
+ const found = [];
98
+ const lineAt = (offset) => {
99
+ let line = 1;
100
+ for (let index = 0; index < offset; index++)
101
+ if (source.charCodeAt(index) === 10)
102
+ line++;
103
+ return line;
104
+ };
105
+ const walk = (node, path) => {
106
+ if (isMap(node))
107
+ for (const pair of node.items)
108
+ walk(pair.value, [...path, isScalar(pair.key) ? String(pair.key.value) : '?']);
109
+ else if (isSeq(node))
110
+ for (const [index, item] of node.items.entries())
111
+ walk(item, [...path, String(index)]);
112
+ else if (isScalar(node) && typeof node.value === 'string') {
113
+ const password = PASSWORD_URL.exec(node.value.trim())?.[1];
114
+ if (password && !password.startsWith('<'))
115
+ found.push({ path, line: lineAt(node.range?.[0] ?? 0) });
116
+ }
117
+ };
118
+ walk(parseDocument(source, { version: '1.2' }).contents, []);
119
+ return found;
120
+ }
121
+ /**
122
+ * Option paths (`[pluginId, …]`) whose schema marks the value secret (`writeOnly`, from
123
+ * `o.string({ secret: true })`) and that hold a value. A list of secrets is one path. Unions are
124
+ * followed branch by branch; only the paths are returned, never the values.
125
+ */
126
+ export function schemaSecrets(options, plugins) {
127
+ const found = new Map();
128
+ // Every schema node that applies at one place (JSON Schema 2020-12, as config show reads them,
129
+ // R4CA-9): R4CB-11 followed properties, additionalProperties, items and anyOf/oneOf/allOf
130
+ // only, so a writeOnly mark under patternProperties, prefixItems, then/else or
131
+ // dependentSchemas of a hand-written schema was not found.
132
+ const walk = (value, schemas, path) => {
133
+ if (value === undefined || value === null)
134
+ return;
135
+ const nodes = expandSchemas(schemas);
136
+ if (nodes.some((node) => node.writeOnly === true)) {
137
+ found.set(path.join('.'), path);
138
+ return;
139
+ }
140
+ if (Array.isArray(value)) {
141
+ // A list of secrets (`items` writeOnly, no prefixItems) is one path.
142
+ const listed = nodes.flatMap((node) => (isSchema(node.items) ? [node.items] : []));
143
+ if (value.length &&
144
+ !nodes.some((node) => Array.isArray(node.prefixItems)) &&
145
+ expandSchemas(listed).some((node) => node.writeOnly === true)) {
146
+ found.set(path.join('.'), path);
147
+ return;
148
+ }
149
+ for (const [index, item] of value.entries())
150
+ walk(item, itemSchemas(nodes, index), [...path, String(index)]);
151
+ return;
152
+ }
153
+ if (typeof value !== 'object')
154
+ return;
155
+ for (const [key, child] of Object.entries(value))
156
+ walk(child, propertySchemas(nodes, key), [...path, key]);
157
+ };
158
+ const section = options && typeof options === 'object' ? options : {};
159
+ for (const { id, schema } of plugins)
160
+ if (Object.hasOwn(section, id))
161
+ walk(section[id], [schema], [id]);
162
+ return [...found.values()];
163
+ }
164
+ const isSchema = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
165
+ /** The nodes and their anyOf/oneOf/allOf, then/else and dependentSchemas forms (a mark hides). */
166
+ function expandSchemas(schemas, depth = 0) {
167
+ const nodes = [];
168
+ for (const schema of schemas) {
169
+ if (!isSchema(schema))
170
+ continue;
171
+ nodes.push(schema);
172
+ if (depth >= 8)
173
+ continue;
174
+ const forms = [schema.then, schema.else];
175
+ for (const key of ['anyOf', 'oneOf', 'allOf'])
176
+ if (Array.isArray(schema[key]))
177
+ forms.push(...schema[key]);
178
+ if (isSchema(schema.dependentSchemas))
179
+ forms.push(...Object.values(schema.dependentSchemas));
180
+ nodes.push(...expandSchemas(forms, depth + 1));
181
+ }
182
+ return nodes;
183
+ }
184
+ /** `properties[key]` and the matching patternProperties; additionalProperties when none. */
185
+ function propertySchemas(nodes, key) {
186
+ return nodes.flatMap((node) => {
187
+ const named = isSchema(node.properties) && Object.hasOwn(node.properties, key)
188
+ ? [node.properties[key]]
189
+ : [];
190
+ const patterns = isSchema(node.patternProperties)
191
+ ? Object.entries(node.patternProperties)
192
+ .filter(([pattern]) => {
193
+ try {
194
+ return new RegExp(pattern, 'u').test(key);
195
+ }
196
+ catch (error) {
197
+ // A pattern that does not compile is taken to apply (fail closed).
198
+ debug('check: a patternProperties pattern does not compile', {
199
+ error: describeError(error),
200
+ });
201
+ return true;
202
+ }
203
+ })
204
+ .map(([, schema]) => schema)
205
+ : [];
206
+ if (named.length || patterns.length)
207
+ return [...named, ...patterns];
208
+ return isSchema(node.additionalProperties) ? [node.additionalProperties] : [];
209
+ });
210
+ }
211
+ /** The schemas of item `index`: its prefixItems entry, else `items`. */
212
+ function itemSchemas(nodes, index) {
213
+ return nodes.flatMap((node) => Array.isArray(node.prefixItems) && index < node.prefixItems.length
214
+ ? [node.prefixItems[index]]
215
+ : isSchema(node.items)
216
+ ? [node.items]
217
+ : []);
218
+ }
219
+ /** The `options` map of a dokebi.yaml source; undefined when the YAML does not parse. */
220
+ function configOptions(source) {
221
+ const document = parseDocument(source, { version: '1.2' });
222
+ if (document.errors.length) {
223
+ debug('check: dokebi.yaml does not parse; schema secrets not looked for', {
224
+ errors: document.errors.length,
225
+ });
226
+ return undefined;
227
+ }
228
+ const data = document.toJS();
229
+ return data && typeof data === 'object' ? data.options : undefined;
230
+ }
79
231
  function lineOf(text, needle) {
80
232
  const index = text.indexOf(needle);
81
233
  return index === -1 ? 0 : text.slice(0, index).split('\n').length;
82
234
  }
83
235
  export async function projectProblems(root, inspection) {
236
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
237
+ const settings = await configTarget(root);
238
+ const configFile = settings.file;
84
239
  const problems = [];
85
240
  const codes = (code) => inspection.diagnostics.filter((item) => item.code === code);
241
+ // D-0155: a section of dokebi.yaml nothing declares (doctor UNDECLARED_SECTION, or
242
+ // UNKNOWN_PLUGIN for the section of a plugin that is not installed).
243
+ const sections = inspection.diagnostics.filter((item) => (item.code === 'UNDECLARED_SECTION' || item.code === 'UNKNOWN_PLUGIN') &&
244
+ item.path === configFile &&
245
+ item.plugin !== null);
246
+ if (sections.length) {
247
+ let text = '';
248
+ try {
249
+ text = await readFile(settings.path, 'utf8');
250
+ }
251
+ catch (error) {
252
+ if (!isMissing(error))
253
+ throw new CliError('READ_FAILED', `Cannot read ${configFile}`, { cause: error });
254
+ }
255
+ for (const item of sections)
256
+ problems.push({
257
+ rule: 'undeclared-section',
258
+ severity: 'warning',
259
+ file: configFile,
260
+ line: lineOf(text, `\n ${item.plugin}:`) + (text.includes(`\n ${item.plugin}:`) ? 1 : 0),
261
+ message: `options.${item.plugin} is read by nothing the framework knows (doctor ${item.code}): it is not checked at start, and config set, secret and the example do not know it`,
262
+ fix: item.hint ??
263
+ `npx dokebi settings add ${item.plugin}, then read it with the settings declaration`,
264
+ });
265
+ }
266
+ // Studio v2 (10-31): a file copied from a template once lacks a later fix of the template, or an
267
+ // entry passes no configFile (doctor TEMPLATE_FIX_MISSING, RELOAD_NOT_WIRED): check names them
268
+ // too, so an app that runs check (verify) hears of them.
269
+ for (const item of [...codes('TEMPLATE_FIX_MISSING'), ...codes('RELOAD_NOT_WIRED')])
270
+ problems.push({
271
+ rule: 'template-fix-missing',
272
+ severity: 'warning',
273
+ file: item.path,
274
+ line: 0,
275
+ message: item.code === 'RELOAD_NOT_WIRED'
276
+ ? `${item.path} passes no configFile (doctor RELOAD_NOT_WIRED): the app cannot read dokebi.yaml again`
277
+ : `${item.path} was copied from a template that has fixes since: ${(item.details?.missing ?? []).join(', ')} (doctor TEMPLATE_FIX_MISSING)`,
278
+ fix: item.hint ?? 'compare the file with the template and bring the fixes over',
279
+ });
86
280
  for (const item of codes('MODIFIED_REGISTRATION'))
87
281
  problems.push({
88
282
  rule: 'generated-file',
89
283
  file: item.path,
90
284
  line: 1,
91
285
  message: `${item.path} is generated by the CLI and was edited (doctor MODIFIED_REGISTRATION); add, upgrade and remove refuse to run until it is restored`,
92
- fix: 'undo the edit (for example git checkout -- src/dokebi.generated.ts); change plugins with npx dokebi add/remove/app/module and options in dokebi.yaml',
286
+ fix: 'undo the edit (for example git checkout -- src/dokebi.generated.ts) and keep code formatters out of the file (formatter-generated); change plugins with npx dokebi add/remove/app/module and options in dokebi.yaml',
93
287
  });
94
288
  for (const item of codes('MODIFIED_TEMPLATE'))
95
289
  problems.push({
@@ -118,23 +312,71 @@ export async function projectProblems(root, inspection) {
118
312
  }
119
313
  const secrets = inspection.diagnostics.filter((item) => item.code === 'PLUGIN_CHECK' &&
120
314
  item.details?.code === 'SECRET_IN_CONFIG');
121
- if (secrets.length && (await configReachesGit(root))) {
122
- let source = '';
123
- try {
124
- source = await readFile(join(root, CONFIG_FILE), 'utf8');
125
- }
126
- catch (error) {
127
- if (!isMissing(error))
128
- throw new CliError('READ_FAILED', `Cannot read ${CONFIG_FILE}`, { cause: error });
129
- }
315
+ let source = '';
316
+ try {
317
+ source = await readFile(settings.path, 'utf8');
318
+ }
319
+ catch (error) {
320
+ if (!isMissing(error))
321
+ throw new CliError('READ_FAILED', `Cannot read ${configFile}`, { cause: error });
322
+ }
323
+ const urls = source ? passwordUrls(source) : [];
324
+ const options = source ? configOptions(source) : undefined;
325
+ const marked = source
326
+ ? schemaSecrets(options, inspection.plugins.map((item) => ({
327
+ id: (item.installed ?? item.expected).id,
328
+ schema: item.optionsSchema,
329
+ })))
330
+ : [];
331
+ // The first admin password of `recipe blog --admin login` (D-0076 §6): no plugin owns it.
332
+ // D-0115: whatever the section is called (first-admin, the older blog-admin, a renamed one).
333
+ // A plugin's own section is its schema's to judge (above).
334
+ const plugins = new Set(inspection.plugins.map((item) => (item.installed ?? item.expected).id));
335
+ const admins = new Set((options && typeof options === 'object' && !Array.isArray(options)
336
+ ? adminSections(options)
337
+ : []).filter((section) => !plugins.has(section)));
338
+ for (const section of admins)
339
+ marked.push([section, ADMIN_PASSWORD_KEY]);
340
+ // git is asked only when dokebi.yaml holds a secret.
341
+ if ((secrets.length || urls.length || marked.length) &&
342
+ (await configReachesGit(root, settings))) {
343
+ const keepOut = `keep ${configFile} out of git: list it in .gitignore (dokebi init does) and git rm --cached ${configFile}`;
344
+ const reported = new Set();
130
345
  for (const item of secrets) {
131
346
  const path = ['options', item.plugin ?? '', ...(item.details.optionPath ?? [])];
347
+ reported.add(path.join('.'));
348
+ problems.push({
349
+ rule: 'secret-in-config',
350
+ file: configFile,
351
+ line: optionLine(source, path),
352
+ message: `${path.slice(1).join('.')} holds a secret (doctor PLUGIN_CHECK SECRET_IN_CONFIG) and ${configFile} can reach version control (git tracks it or does not ignore it)`,
353
+ fix: `${item.hint ? `${item.hint}; or ` : ''}${keepOut}`,
354
+ });
355
+ }
356
+ for (const option of marked) {
357
+ const path = ['options', ...option];
358
+ if (reported.has(path.join('.')))
359
+ continue;
360
+ reported.add(path.join('.'));
361
+ debug('check: a secret option in dokebi.yaml', { path: option.join('.') });
132
362
  problems.push({
133
363
  rule: 'secret-in-config',
134
- file: CONFIG_FILE,
364
+ file: configFile,
135
365
  line: optionLine(source, path),
136
- message: `${path.slice(1).join('.')} holds a secret (doctor PLUGIN_CHECK SECRET_IN_CONFIG) and ${CONFIG_FILE} can reach version control (git tracks it or does not ignore it)`,
137
- fix: `${item.hint ? `${item.hint}; or ` : ''}keep ${CONFIG_FILE} out of git: list it in .gitignore (dokebi init does) and git rm --cached ${CONFIG_FILE}`,
366
+ message: `${option.join('.')} holds a secret (${admins.has(option[0] ?? '') && option[1] === ADMIN_PASSWORD_KEY ? 'the first admin password of the recipe' : 'its plugin schema marks it secret'}) and ${configFile} can reach version control (git tracks it or does not ignore it)`,
367
+ fix: `${keepOut}; replace the secret if the file was already committed or pushed`,
368
+ });
369
+ }
370
+ for (const url of urls) {
371
+ if (reported.has(url.path.join('.')))
372
+ continue;
373
+ debug('check: a URL with a password in dokebi.yaml', { path: url.path.join('.') });
374
+ problems.push({
375
+ rule: 'secret-in-config',
376
+ file: configFile,
377
+ line: url.line,
378
+ message: `${url.path.slice(url.path[0] === 'options' ? 1 : 0).join('.')} is a URL with a password and ${configFile} can reach version control (git tracks it or does not ignore it)`,
379
+ fix: `${keepOut}; change that password if the file was already committed or pushed`,
138
380
  });
139
381
  }
140
382
  }
@@ -0,0 +1,6 @@
1
+ import type { Problem } from './problems.js';
2
+ export declare const SEAL_HEAD_FIX = "write <%- data.seal?.head ?? '' %> in the <head> of the layout of an app page; a page rendered outside an app route (an oauth consent page) takes context.getDependency(apps).seal(request).head (AppGroupApi of @dokebi/app ^0.4.5, empty on a plain host). A page that is no app page at all takes // dokebi-check: allow seal-head-copied <reason>";
3
+ /** The seal head problems of one file's text (the path is project-relative, with `/`). */
4
+ export declare function sealHeadProblems(path: string, text: string): Problem[];
5
+ /** The seal head rule over the files under `src/` (only with @dokebi/app installed). */
6
+ export declare function sealHeadsProblems(root: string, skip: (path: string) => boolean): Promise<Problem[]>;
@@ -0,0 +1,68 @@
1
+ // The seal head rule of `dokebi check` (a warning, R7 Codex T3 review; only with @dokebi/app):
2
+ // seal-head-copied app code or a view under src/ that loads the seal forms module
3
+ // `/dokebi/seal/forms.mjs` itself — a copied `<script type="module">` or
4
+ // import map. @dokebi/app gives a page the head of its host: `data.seal.head`
5
+ // in an app page, `AppGroupApi.seal(request).head` for a page rendered outside
6
+ // an app route (an oauth consent page). A copy is written on a plain host too
7
+ // and drifts from the files @dokebi/seal serves. R6 Claude T3 and R7 Codex T3
8
+ // wrote it into their oauth extension, although the oauth AI.md shows `seal()`.
9
+ // The rule reads the text of .ts/.js/.mjs/.ejs/.html files; a line comment (`//`, `*`) or an EJS
10
+ // comment (`<%#`) that holds the path is not counted.
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { debug } from '../diagnostics.js';
14
+ import { sources } from './form-rules.js';
15
+ import { readMarkers } from './source-rules.js';
16
+ const RULE = 'seal-head-copied';
17
+ const FILES = /\.(?:ts|mts|js|mjs|ejs|html)$/;
18
+ const FORMS = '/dokebi/seal/forms.mjs';
19
+ /** Files larger than this are not read (generated bundles, data). */
20
+ const MAX_BYTES = 1024 * 1024;
21
+ export const SEAL_HEAD_FIX = "write <%- data.seal?.head ?? '' %> in the <head> of the layout of an app page; a page rendered outside an app route (an oauth consent page) takes context.getDependency(apps).seal(request).head (AppGroupApi of @dokebi/app ^0.4.5, empty on a plain host). A page that is no app page at all takes // dokebi-check: allow seal-head-copied <reason>";
22
+ /** True when the text before `index` on its line opens a comment. */
23
+ function inComment(line, column) {
24
+ const before = line.slice(0, column);
25
+ return /^\s*(?:\*|\/\*)/.test(line) || before.includes('//') || before.includes('<%#');
26
+ }
27
+ /** The seal head problems of one file's text (the path is project-relative, with `/`). */
28
+ export function sealHeadProblems(path, text) {
29
+ if (!text.includes(FORMS))
30
+ return [];
31
+ const markers = readMarkers(text).filter((marker) => marker.rule === RULE);
32
+ const problems = [];
33
+ const lines = text.split('\n');
34
+ for (const [index, content] of lines.entries()) {
35
+ const column = content.indexOf(FORMS);
36
+ if (column === -1 || inComment(content, column))
37
+ continue;
38
+ const line = index + 1;
39
+ const marker = markers.find((item) => item.line === line || item.line === line - 1);
40
+ if (marker?.reason.replace(/\s*[-_%]*>\s*$/, '').trim())
41
+ continue;
42
+ problems.push({
43
+ rule: RULE,
44
+ severity: 'warning',
45
+ file: path,
46
+ line,
47
+ message: `${path} loads ${FORMS} itself: a copy of the seal head @dokebi/app gives every page of a seal host${marker ? ' (the dokebi-check marker has no reason, so it does not count)' : ''}`,
48
+ fix: SEAL_HEAD_FIX,
49
+ });
50
+ }
51
+ return problems;
52
+ }
53
+ /** The seal head rule over the files under `src/` (only with @dokebi/app installed). */
54
+ export async function sealHeadsProblems(root, skip) {
55
+ const problems = [];
56
+ let files = 0;
57
+ for (const path of await sources(root, 'src', FILES)) {
58
+ if (skip(path))
59
+ continue;
60
+ const text = await readFile(join(root, path), 'utf8');
61
+ if (text.length > MAX_BYTES)
62
+ continue;
63
+ files++;
64
+ problems.push(...sealHeadProblems(path, text));
65
+ }
66
+ debug('check: seal heads', { files, problems: problems.length });
67
+ return problems;
68
+ }
@@ -0,0 +1,10 @@
1
+ import { type Kinds, type TsNode, type TsSourceFile } from './typescript.js';
2
+ export type SecurityRule = 'plain-password-compare' | 'csrf-exempt-unverified';
3
+ export type Report = (node: TsNode, rule: SecurityRule, message: string, fix: string) => void;
4
+ /** `passwordHash` → [password, hash]; `pass_phc` → [pass, phc]; `PW` → [pw]. */
5
+ export declare function nameWords(name: string): string[];
6
+ export declare function passwordLike(name: string): boolean;
7
+ export declare function securityRules(file: TsSourceFile, kinds: Kinds, installed: {
8
+ readonly auth: boolean;
9
+ readonly csrf: boolean;
10
+ }, report: Report): void;