@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.
- package/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +518 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +20 -2
- package/dist/install-module.js +45 -8
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +17 -2
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +40 -4
- package/dist/npm-client.js +152 -15
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +195 -16
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3006 -333
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- 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)
|
|
8
|
-
//
|
|
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
|
|
34
|
-
* Without git (or outside a repository) the
|
|
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
|
-
|
|
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', '--',
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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:
|
|
364
|
+
file: configFile,
|
|
135
365
|
line: optionLine(source, path),
|
|
136
|
-
message: `${
|
|
137
|
-
fix: `${
|
|
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;
|