@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.
- package/AI.md +279 -57
- package/README.md +35 -10
- package/catalog.json +2520 -258
- 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 +638 -37
- 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 +15 -0
- package/dist/check/shared-state-rules.js +117 -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 +381 -36
- 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-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -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/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- 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/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -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 +497 -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/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -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
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// `dokebi config show [<plugin-id>[.<path>]]` (AI-M05): the options of dokebi.yaml with every
|
|
2
|
+
// secret replaced by a marker, so an agent never has to cat or sed the file (one did, masked
|
|
3
|
+
// by hand, missed a value and printed a CSRF secret into its log). A value is a secret when
|
|
4
|
+
// - the plugin's options schema marks it (`writeOnly`, from `o.string({ secret: true })`; the
|
|
5
|
+
// same marking `dokebi check` secret-in-config reads), a list of them as one secret,
|
|
6
|
+
// - its key is named like one by core's rule, the one the logs use (`redaction.isSecretField`
|
|
7
|
+
// of @dokebi/core/libraries, D-0047: password, secret, token, apiKey, authorization, cookie,
|
|
8
|
+
// credentials, pin …), unless the schema marks the option plain (`x-dokebi-secret: false`,
|
|
9
|
+
// core option `plain`); an object whose schema declares its fields is judged field by field,
|
|
10
|
+
// - it is the recipe's first admin password (first-admin.initialPassword, or the older
|
|
11
|
+
// blog-admin.initialPassword, D-0115),
|
|
12
|
+
// - or it holds credentials by core's `redaction.maskCredentials` (the password of a
|
|
13
|
+
// `scheme://user:password@host` URL, `Password=…` of a connection string; only that part).
|
|
14
|
+
// A plugin whose schema cannot be read shows no string at all (fail closed). Values are never
|
|
15
|
+
// put into the result, errors or debug logs; only option paths and counts are.
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { loadSchema } from '@dokebi/config';
|
|
19
|
+
import { reloadOptions } from '@dokebi/core';
|
|
20
|
+
import { redaction } from '@dokebi/core/libraries';
|
|
21
|
+
import { Document, isCollection, isMap, isNode, isScalar } from 'yaml';
|
|
22
|
+
import { CONFIG_FILE, configTarget, missingConfig, parseProjectConfig, readNamedSettings, readProjectConfigSource, } from './configuration.js';
|
|
23
|
+
import { aliasTargets } from './configuration-scaffold.js';
|
|
24
|
+
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
25
|
+
import { envFileWarnings } from './env-file.js';
|
|
26
|
+
import { CliError } from './errors.js';
|
|
27
|
+
import { parseObject } from './filesystem.js';
|
|
28
|
+
import { adminSections } from './first-admin.js';
|
|
29
|
+
import { optionsKey } from './manifest.js';
|
|
30
|
+
import { readState } from './project-state.js';
|
|
31
|
+
import { ADMIN_PASSWORD_KEY, ADMIN_SECTIONS } from './recipe-login.js';
|
|
32
|
+
import { CORE_LOGGING_SCHEMA, RELOADABLE_MARK, reloadableAt } from './reloadable.js';
|
|
33
|
+
import { CONVENTION_SECTIONS, catalogPackageOf, readSettingsSchema, undeclaredSection, undeclaredSectionError, } from './settings.js';
|
|
34
|
+
import { packageFile } from './workspace.js';
|
|
35
|
+
/** A list index as config set, unset and secret read one (resolveItems). */
|
|
36
|
+
const ITEM_INDEX = /^(?:0|[1-9][0-9]{0,5})$/;
|
|
37
|
+
/** What a hidden value is replaced with. */
|
|
38
|
+
export const SECRET_MARKER = '<secret: set>';
|
|
39
|
+
/** What the password of a URL is replaced with: `scheme://user:<secret>@host`. */
|
|
40
|
+
export const URL_SECRET_MARKER = '<secret>';
|
|
41
|
+
/**
|
|
42
|
+
* A credential written as a placeholder, the documented shape of a secret to fill in:
|
|
43
|
+
* `scheme://user:<password>@host`, `Password=<password>`. Not a credential.
|
|
44
|
+
*/
|
|
45
|
+
const CREDENTIAL_PLACEHOLDER = /(\/\/)[^\s/@:]*:<[^<>\s@]*>@|\b(?:password|pwd)\s*=\s*<[^<>;]*>/gi;
|
|
46
|
+
/**
|
|
47
|
+
* Text with its credentials hidden by core's rule (`redaction.maskCredentials`: the password of
|
|
48
|
+
* a URL, also one with a raw `/` or `@` in it, and `Password=…` of a connection string), or
|
|
49
|
+
* undefined when it holds none. The URL keeps its user: `scheme://user:<secret>@host`.
|
|
50
|
+
*/
|
|
51
|
+
export function hideCredentials(text) {
|
|
52
|
+
const value = text.trim();
|
|
53
|
+
const probe = value.replace(CREDENTIAL_PLACEHOLDER, (_match, slashes) => slashes ? '//' : '');
|
|
54
|
+
if (redaction.maskCredentials(probe) === probe)
|
|
55
|
+
return undefined;
|
|
56
|
+
const masked = redaction.maskCredentials(value);
|
|
57
|
+
const at = masked.indexOf('//***@');
|
|
58
|
+
if (at !== -1 && masked.split('***').length === 2) {
|
|
59
|
+
// One masked part: the user is what the original has before the password.
|
|
60
|
+
const rest = masked.slice(at + 6);
|
|
61
|
+
const userinfo = value.slice(at + 2, value.length - rest.length - 1);
|
|
62
|
+
const colon = userinfo.indexOf(':');
|
|
63
|
+
if (value.startsWith(masked.slice(0, at)) && value.endsWith(`@${rest}`))
|
|
64
|
+
return `${masked.slice(0, at)}//${colon > 0 ? `${userinfo.slice(0, colon)}:` : ''}${URL_SECRET_MARKER}@${rest}`;
|
|
65
|
+
}
|
|
66
|
+
return masked
|
|
67
|
+
.replaceAll('//***@', `//${URL_SECRET_MARKER}@`)
|
|
68
|
+
.replace(/\b(password|pwd)(\s*=\s*)\*\*\*/gi, `$1$2${URL_SECRET_MARKER}`);
|
|
69
|
+
}
|
|
70
|
+
/** The topmost reloadable paths present in `value` (a section's shown options). */
|
|
71
|
+
function reloadablePaths(value, schema, path, section, into) {
|
|
72
|
+
const visit = (node, at) => {
|
|
73
|
+
if (at.length && reloadableAt(schema, at)) {
|
|
74
|
+
into.push([section, ...at].join('.'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(node))
|
|
78
|
+
for (const [index, item] of node.entries())
|
|
79
|
+
visit(item, [...at, index]);
|
|
80
|
+
else if (isObject(node))
|
|
81
|
+
for (const [key, item] of Object.entries(node))
|
|
82
|
+
visit(item, [...at, key]);
|
|
83
|
+
};
|
|
84
|
+
visit(value, path);
|
|
85
|
+
}
|
|
86
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
87
|
+
/**
|
|
88
|
+
* The schema nodes that apply at one place: the node and its anyOf/oneOf/allOf branches.
|
|
89
|
+
* `conditional` (secrets only, R4CA-9): also the `then`/`else` and `dependentSchemas` forms, which
|
|
90
|
+
* may apply — a secret mark there hides the value; their defaults are never filled in.
|
|
91
|
+
*/
|
|
92
|
+
function expand(schemas, depth = 0, conditional = false) {
|
|
93
|
+
const nodes = [];
|
|
94
|
+
for (const schema of schemas) {
|
|
95
|
+
if (!isObject(schema))
|
|
96
|
+
continue;
|
|
97
|
+
nodes.push(schema);
|
|
98
|
+
if (depth >= 8)
|
|
99
|
+
continue;
|
|
100
|
+
const forms = [];
|
|
101
|
+
for (const key of ['anyOf', 'oneOf', 'allOf'])
|
|
102
|
+
if (Array.isArray(schema[key]))
|
|
103
|
+
forms.push(...schema[key]);
|
|
104
|
+
if (conditional) {
|
|
105
|
+
forms.push(schema.then, schema.else);
|
|
106
|
+
if (isObject(schema.dependentSchemas))
|
|
107
|
+
forms.push(...Object.values(schema.dependentSchemas));
|
|
108
|
+
}
|
|
109
|
+
nodes.push(...expand(forms, depth + 1, conditional));
|
|
110
|
+
}
|
|
111
|
+
return nodes;
|
|
112
|
+
}
|
|
113
|
+
/** Whether a patternProperties pattern names `key` (one that does not compile: fail closed). */
|
|
114
|
+
function patternNames(pattern, key) {
|
|
115
|
+
try {
|
|
116
|
+
return new RegExp(pattern, 'u').test(key);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
debug('config show: a patternProperties pattern does not compile; taken to apply', {
|
|
120
|
+
error: describeError(error),
|
|
121
|
+
});
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The schemas of the property `key` (JSON Schema 2020-12): its `properties` entry and every
|
|
127
|
+
* `patternProperties` entry whose pattern matches it; `additionalProperties` only when neither
|
|
128
|
+
* does. R4CA-9: patternProperties were not followed, so a secret marked there was printed.
|
|
129
|
+
*/
|
|
130
|
+
function childSchemas(nodes, key) {
|
|
131
|
+
return nodes.flatMap((node) => {
|
|
132
|
+
const properties = isObject(node.properties) ? node.properties : {};
|
|
133
|
+
const named = Object.hasOwn(properties, key) ? [properties[key]] : [];
|
|
134
|
+
const patterns = isObject(node.patternProperties)
|
|
135
|
+
? Object.entries(node.patternProperties)
|
|
136
|
+
.filter(([pattern]) => patternNames(pattern, key))
|
|
137
|
+
.map(([, schema]) => schema)
|
|
138
|
+
: [];
|
|
139
|
+
if (named.length || patterns.length)
|
|
140
|
+
return [...named, ...patterns];
|
|
141
|
+
return isObject(node.additionalProperties) ? [node.additionalProperties] : [];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** The schemas of item `index` of a list: its `prefixItems` entry, else `items` (R4CA-9). */
|
|
145
|
+
function itemSchemas(nodes, index) {
|
|
146
|
+
return nodes.flatMap((node) => Array.isArray(node.prefixItems) && index < node.prefixItems.length
|
|
147
|
+
? [node.prefixItems[index]]
|
|
148
|
+
: isObject(node.items)
|
|
149
|
+
? [node.items]
|
|
150
|
+
: []);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether an option named `key` that holds `value` is a secret by its name: core's rule, the one
|
|
154
|
+
* the logs use (`redaction.isSecretField`, D-0047). Text, a list or an object under a secret name
|
|
155
|
+
* is; a number only under a password, PIN or code name; a boolean never. Where the schema marks
|
|
156
|
+
* the option plain (`x-dokebi-secret: false`) the name is not asked.
|
|
157
|
+
*/
|
|
158
|
+
export function secretOption(key, value) {
|
|
159
|
+
return redaction.isSecretField(key, value);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The name rule at one option: `secretOption`, except that an object (or a list of objects) whose
|
|
163
|
+
* schema declares its fields is judged field by field (a cookie's name and path, auth's
|
|
164
|
+
* tokens.driver: AI-M24), and a plain option (`x-dokebi-secret: false`) never.
|
|
165
|
+
*/
|
|
166
|
+
function secretByName(key, value, nodes) {
|
|
167
|
+
if (/^\d+$/.test(key) || nodes.some((node) => node['x-dokebi-secret'] === false))
|
|
168
|
+
return false;
|
|
169
|
+
if (!secretOption(key, value))
|
|
170
|
+
return false;
|
|
171
|
+
if (isObject(value) && nodes.some((node) => isObject(node.properties)))
|
|
172
|
+
return false;
|
|
173
|
+
if (Array.isArray(value) &&
|
|
174
|
+
expand(nodes.map((node) => node.items)).some((node) => isObject(node.properties)))
|
|
175
|
+
return false;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
/** Fills schema defaults of unset properties into objects that are shown (a copy). */
|
|
179
|
+
function withDefaults(value, schemas, path, defaults,
|
|
180
|
+
/** Keys of this object an old name (D-0071 alias) of the parent already sets: no default. */
|
|
181
|
+
aliased = new Set()) {
|
|
182
|
+
if (!isObject(value))
|
|
183
|
+
return value;
|
|
184
|
+
const nodes = expand(schemas);
|
|
185
|
+
const result = { ...value };
|
|
186
|
+
// An alias that is set gives its target the value: the target's default does not apply.
|
|
187
|
+
const taken = new Set(aliased);
|
|
188
|
+
const below = new Map();
|
|
189
|
+
for (const node of nodes)
|
|
190
|
+
for (const [alias, [first, second]] of aliasTargets(node)) {
|
|
191
|
+
if (!Object.hasOwn(value, alias))
|
|
192
|
+
continue;
|
|
193
|
+
if (second === undefined)
|
|
194
|
+
taken.add(first);
|
|
195
|
+
else
|
|
196
|
+
below.set(first, new Set([...(below.get(first) ?? []), second]));
|
|
197
|
+
}
|
|
198
|
+
for (const [key, child] of Object.entries(result))
|
|
199
|
+
result[key] = withDefaults(child, childSchemas(nodes, key), [...path, key], defaults, below.get(key));
|
|
200
|
+
for (const node of nodes) {
|
|
201
|
+
if (!isObject(node.properties))
|
|
202
|
+
continue;
|
|
203
|
+
for (const [key, property] of Object.entries(node.properties)) {
|
|
204
|
+
if (Object.hasOwn(result, key) || !isObject(property) || property.default === undefined)
|
|
205
|
+
continue;
|
|
206
|
+
if (taken.has(key) || property.writeOnly === true)
|
|
207
|
+
continue;
|
|
208
|
+
// A default the name rule would hide is not filled in (it would read as a secret set).
|
|
209
|
+
if (secretByName(key, property.default, expand([property])))
|
|
210
|
+
continue;
|
|
211
|
+
let filled = structuredClone(property.default);
|
|
212
|
+
const inside = below.get(key);
|
|
213
|
+
if (inside && isObject(filled))
|
|
214
|
+
filled = Object.fromEntries(Object.entries(filled).filter(([name]) => !inside.has(name)));
|
|
215
|
+
result[key] = filled;
|
|
216
|
+
defaults.push([...path, key].join('.'));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A copy of `value` with its leaves hidden (null and empty strings say nothing and stay).
|
|
223
|
+
* `strings`: only text is hidden (a list or object under a secret name may hold counts).
|
|
224
|
+
*/
|
|
225
|
+
function hide(value, strings) {
|
|
226
|
+
let hidden = false;
|
|
227
|
+
const walk = (item) => {
|
|
228
|
+
if (Array.isArray(item))
|
|
229
|
+
return item.map(walk);
|
|
230
|
+
if (isObject(item))
|
|
231
|
+
return Object.fromEntries(Object.entries(item).map(([key, child]) => [key, walk(child)]));
|
|
232
|
+
if (item === null || item === undefined || item === '')
|
|
233
|
+
return item;
|
|
234
|
+
if (strings && typeof item !== 'string')
|
|
235
|
+
return item;
|
|
236
|
+
hidden = true;
|
|
237
|
+
return SECRET_MARKER;
|
|
238
|
+
};
|
|
239
|
+
const result = walk(value);
|
|
240
|
+
return { value: result, hidden };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A copy of `value` with the secrets hidden. `unknown`: no schema could be read for this part,
|
|
244
|
+
* so every string is hidden (numbers and booleans still show).
|
|
245
|
+
*/
|
|
246
|
+
function mask(value, schemas, path, unknown, secrets) {
|
|
247
|
+
// Every form that may apply, the conditional ones too: a secret mark anywhere hides (R4CA-9).
|
|
248
|
+
const nodes = expand(schemas, 0, true);
|
|
249
|
+
const key = path[path.length - 1] ?? '';
|
|
250
|
+
// A $ref is not followed: what it points at is treated as unknown (fail closed).
|
|
251
|
+
const blind = unknown || nodes.some((node) => node.$ref !== undefined);
|
|
252
|
+
const schemaMarked = nodes.some((node) => node.writeOnly === true) ||
|
|
253
|
+
(Array.isArray(value) &&
|
|
254
|
+
expand(nodes.map((node) => node.items)).some((node) => node.writeOnly === true)) ||
|
|
255
|
+
(path.length === 2 && ADMIN_SECTIONS.includes(path[0] ?? '') && key === ADMIN_PASSWORD_KEY);
|
|
256
|
+
// `x-dokebi-secret: false` (core option `plain`) is honoured by secretByName.
|
|
257
|
+
if (schemaMarked || (path.length > 1 && secretByName(key, value, nodes))) {
|
|
258
|
+
// A secret value is hidden whole; inside a list or object under a secret name, its text.
|
|
259
|
+
const hidden = hide(value, !schemaMarked && (Array.isArray(value) || isObject(value)));
|
|
260
|
+
if (hidden.hidden) {
|
|
261
|
+
secrets.push(path.join('.'));
|
|
262
|
+
return hidden.value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(value))
|
|
266
|
+
return value.map((item, index) => mask(item, itemSchemas(nodes, index), [...path, String(index)], blind, secrets));
|
|
267
|
+
if (isObject(value))
|
|
268
|
+
return Object.fromEntries(Object.entries(value).map(([child, item]) => [
|
|
269
|
+
child,
|
|
270
|
+
mask(item, childSchemas(nodes, child), [...path, child], blind, secrets),
|
|
271
|
+
]));
|
|
272
|
+
if (typeof value === 'string') {
|
|
273
|
+
if (blind && value !== '') {
|
|
274
|
+
secrets.push(path.join('.'));
|
|
275
|
+
return SECRET_MARKER;
|
|
276
|
+
}
|
|
277
|
+
const hidden = hideCredentials(value);
|
|
278
|
+
if (hidden !== undefined) {
|
|
279
|
+
secrets.push(path.join('.'));
|
|
280
|
+
return hidden;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Whether a value at an option path is a secret (`config unset` warns `SECRET_REMOVED` for one;
|
|
287
|
+
* D-0165 dropped the `SECRET_ON_COMMAND_LINE` warning it gave `--set` and `config set`). The
|
|
288
|
+
* plugin's options schema decides
|
|
289
|
+
* (AI-M24: `tokens.driver=redis`, `tokens.connection=main` and `maxTokensPerUser=5` of
|
|
290
|
+
* @dokebi/auth warned because a path word matched /token/): a value at a `writeOnly` option
|
|
291
|
+
* (`o.string({ secret: true })`), a list of them, or anything inside an object value that
|
|
292
|
+
* reaches one. Where the schema says nothing (an option it does not declare, a schema that
|
|
293
|
+
* cannot be read: `schema` undefined), the name decides by core's rule (`secretOption`): text,
|
|
294
|
+
* or text inside a list or object, under a secret name; a number only under a password, PIN or
|
|
295
|
+
* code name. Credentials by core's `maskCredentials` (a `scheme://user:password@` URL,
|
|
296
|
+
* `Password=…`) are a secret anywhere. Booleans never are.
|
|
297
|
+
*/
|
|
298
|
+
export function commandLineSecret(path, value, schema) {
|
|
299
|
+
let nodes = schema === undefined || schema === null ? [] : expand([schema], 0, true);
|
|
300
|
+
// A list item on the way (`targets.0.token`) is that item's schema.
|
|
301
|
+
for (const key of path)
|
|
302
|
+
nodes = expand(/^\d+$/.test(key) &&
|
|
303
|
+
!nodes.some((node) => isObject(node.properties) && Object.hasOwn(node.properties, key))
|
|
304
|
+
? itemSchemas(nodes, Number(key))
|
|
305
|
+
: childSchemas(nodes, key), 0, true);
|
|
306
|
+
return secretValue(value, nodes, path[path.length - 1] ?? '');
|
|
307
|
+
}
|
|
308
|
+
function secretValue(value, nodes, key) {
|
|
309
|
+
const marked = nodes.some((node) => node.writeOnly === true) ||
|
|
310
|
+
(Array.isArray(value) &&
|
|
311
|
+
expand(nodes.map((node) => node.items)).some((node) => node.writeOnly === true));
|
|
312
|
+
if (marked && hide(value, false).hidden)
|
|
313
|
+
return true;
|
|
314
|
+
// Undeclared here: only the name is left to go by.
|
|
315
|
+
if (nodes.length === 0 &&
|
|
316
|
+
!/^\d+$/.test(key) &&
|
|
317
|
+
secretOption(key, value) &&
|
|
318
|
+
hide(value, Array.isArray(value) || isObject(value)).hidden)
|
|
319
|
+
return true;
|
|
320
|
+
if (Array.isArray(value))
|
|
321
|
+
return value.some((item, index) => secretValue(item, expand(itemSchemas(nodes, index), 0, true), key));
|
|
322
|
+
if (isObject(value))
|
|
323
|
+
return Object.entries(value).some(([child, item]) => secretValue(item, expand(childSchemas(nodes, child), 0, true), child));
|
|
324
|
+
return typeof value === 'string' && value !== '' && hideCredentials(value) !== undefined;
|
|
325
|
+
}
|
|
326
|
+
async function readText(root, file) {
|
|
327
|
+
try {
|
|
328
|
+
return file === CONFIG_FILE
|
|
329
|
+
? await readProjectConfigSource(root)
|
|
330
|
+
: await readFile(join(root, file), 'utf8');
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (error instanceof CliError)
|
|
334
|
+
throw error;
|
|
335
|
+
if (isMissing(error))
|
|
336
|
+
throw file === CONFIG_FILE
|
|
337
|
+
? missingConfig(root, `No ${file}: run this in a Dokebi project (the folder with dokebi.yaml)`)
|
|
338
|
+
: new CliError('NOT_A_PROJECT', `No ${file}: run this in a Dokebi project (the folder with dokebi.yaml)`, { phase: 'arguments', details: { root, file } });
|
|
339
|
+
throw new CliError('READ_FAILED', `Cannot read ${file}`, { cause: error });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The settings the app reads (M50): the file DOKEBI_CONFIG names, else dokebi.yaml. Reading one
|
|
344
|
+
* outside the project is allowed (as a linked dokebi.yaml is); a named file that is not there is
|
|
345
|
+
* READ_FAILED with the variable in the message.
|
|
346
|
+
*/
|
|
347
|
+
export async function readSettings(root) {
|
|
348
|
+
const target = await configTarget(root);
|
|
349
|
+
return {
|
|
350
|
+
target,
|
|
351
|
+
source: target.fromEnvironment
|
|
352
|
+
? await readNamedSettings(root, target)
|
|
353
|
+
: await readText(root, CONFIG_FILE),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** The options schema of one installed plugin; undefined when it cannot be read. */
|
|
357
|
+
async function pluginSchema(root, plugin) {
|
|
358
|
+
if (!plugin.optionsSchema)
|
|
359
|
+
return { schema: {} };
|
|
360
|
+
try {
|
|
361
|
+
return {
|
|
362
|
+
schema: await loadSchema(await packageFile(root, plugin.packageName, plugin.optionsSchema)),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
debug('config show: options schema unreadable; its strings are hidden', {
|
|
367
|
+
plugin: plugin.id,
|
|
368
|
+
error: describeError(error),
|
|
369
|
+
});
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** `dokebi config show [<plugin-id>[.<path>]]`: options with secrets hidden. */
|
|
374
|
+
export async function configShow(root, target,
|
|
375
|
+
/**
|
|
376
|
+
* Another document than the settings file (config diff reads the sample's secrets with it, and
|
|
377
|
+
* the settings its transaction read).
|
|
378
|
+
*/
|
|
379
|
+
from) {
|
|
380
|
+
const state = readState(parseObject(await readText(root, '.dokebi/state.json'), 'state'));
|
|
381
|
+
const read = from ? undefined : await readSettings(root);
|
|
382
|
+
const file = from?.file ?? read?.target.file ?? CONFIG_FILE;
|
|
383
|
+
const options = parseProjectConfig(from?.source ?? read?.source ?? '').options;
|
|
384
|
+
const sections = { ...options };
|
|
385
|
+
// D-0151: the logger host's options are options.logging (optionsKey).
|
|
386
|
+
for (const plugin of state.plugins)
|
|
387
|
+
if (!Object.hasOwn(sections, optionsKey(plugin)))
|
|
388
|
+
sections[optionsKey(plugin)] = {};
|
|
389
|
+
// D-0155: the settings sections of the application, with their schema defaults.
|
|
390
|
+
for (const section of state.settings ?? [])
|
|
391
|
+
if (!Object.hasOwn(sections, section))
|
|
392
|
+
sections[section] = {};
|
|
393
|
+
const defaults = [];
|
|
394
|
+
const secrets = [];
|
|
395
|
+
const reloadable = [];
|
|
396
|
+
// R4CA-3: a DOKEBI_CONFIG in .env is not what the app or this command reads: said here.
|
|
397
|
+
const warnings = from
|
|
398
|
+
? []
|
|
399
|
+
: envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint }));
|
|
400
|
+
const shown = {};
|
|
401
|
+
let packageOf;
|
|
402
|
+
const undeclared = async (section) => {
|
|
403
|
+
packageOf ??= await catalogPackageOf();
|
|
404
|
+
return undeclaredSection(section, packageOf, state.plugins.map((item) => item.id), state.settings ?? []);
|
|
405
|
+
};
|
|
406
|
+
// The first admin a recipe wrote, under any section name (D-0115), is the framework's too.
|
|
407
|
+
const conventions = new Set([...CONVENTION_SECTIONS, ...adminSections(options)]);
|
|
408
|
+
for (const [section, value] of Object.entries(sections)) {
|
|
409
|
+
if (target && target.section !== section)
|
|
410
|
+
continue;
|
|
411
|
+
const plugin = state.plugins.find((item) => optionsKey(item) === section);
|
|
412
|
+
// Core's own sections (D-0154): options.reload, and options.logging without a logger plugin.
|
|
413
|
+
const core = section === 'reload'
|
|
414
|
+
? reloadOptions.schema
|
|
415
|
+
: section === 'logging'
|
|
416
|
+
? CORE_LOGGING_SCHEMA
|
|
417
|
+
: {};
|
|
418
|
+
const settings = !plugin && (state.settings ?? []).includes(section);
|
|
419
|
+
const settingsSchema = settings ? await readSettingsSchema(root, section) : undefined;
|
|
420
|
+
const declared = plugin || settings || section === 'reload' || section === 'logging';
|
|
421
|
+
// D-0155: a section nobody declares has no defaults and no secret marks: its text values are
|
|
422
|
+
// hidden (it may hold secrets) and it is named with the code config set would refuse. The
|
|
423
|
+
// framework's own sections (first-admin) keep the masking by name they always had.
|
|
424
|
+
if (!declared && !conventions.has(section)) {
|
|
425
|
+
const found = await undeclared(section);
|
|
426
|
+
warnings.push({
|
|
427
|
+
code: found.code,
|
|
428
|
+
plugin: section,
|
|
429
|
+
hint: `${found.message}; ${found.details.fix}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const schema = plugin
|
|
433
|
+
? await pluginSchema(root, plugin)
|
|
434
|
+
: settingsSchema
|
|
435
|
+
? { schema: settingsSchema }
|
|
436
|
+
: declared || conventions.has(section)
|
|
437
|
+
? { schema: core }
|
|
438
|
+
: undefined;
|
|
439
|
+
if (!schema && plugin)
|
|
440
|
+
warnings.push({
|
|
441
|
+
code: 'OPTIONS_SCHEMA_UNREADABLE',
|
|
442
|
+
plugin: plugin.id,
|
|
443
|
+
hint: `the options schema of ${plugin.packageName} could not be read (npm install?); its text values are hidden`,
|
|
444
|
+
});
|
|
445
|
+
const filled = schema ? withDefaults(value, [schema.schema], [section], defaults) : value;
|
|
446
|
+
shown[section] = mask(filled, schema ? [schema.schema] : [], [section], !schema, secrets);
|
|
447
|
+
if (schema)
|
|
448
|
+
reloadablePaths(filled, schema.schema, [], section, reloadable);
|
|
449
|
+
}
|
|
450
|
+
// An installed plugin's id that is not its section (the logger's dokebi-logger reads
|
|
451
|
+
// options.logging, D-0151): never "not installed"; the same refusal as config set and unset.
|
|
452
|
+
const named = state.plugins.find((item) => item.id === target?.section);
|
|
453
|
+
if (target && named && !Object.hasOwn(shown, target.section)) {
|
|
454
|
+
const asked = [target.section, ...target.path].join('.');
|
|
455
|
+
const live = [optionsKey(named), ...target.path].join('.');
|
|
456
|
+
throw new CliError('PATH_MISMATCH', `${named.packageName} reads options.${optionsKey(named)}, not options.${target.section}`, {
|
|
457
|
+
phase: 'arguments',
|
|
458
|
+
details: {
|
|
459
|
+
requested: [asked],
|
|
460
|
+
outside: [live],
|
|
461
|
+
fix: `name the options as the files spell them: npx dokebi config show ${live}`,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// A section neither declared nor in dokebi.yaml: the same code as config set and unset.
|
|
466
|
+
if (target && !Object.hasOwn(shown, target.section))
|
|
467
|
+
throw undeclaredSectionError(await undeclared(target.section));
|
|
468
|
+
let value = target ? shown[target.section] : shown;
|
|
469
|
+
/** The asked path with list items as indexes (the paths of defaults and secrets use them). */
|
|
470
|
+
const resolved = [];
|
|
471
|
+
if (target)
|
|
472
|
+
for (const [index, key] of target.path.entries()) {
|
|
473
|
+
let next;
|
|
474
|
+
let segment = key;
|
|
475
|
+
if (Array.isArray(value)) {
|
|
476
|
+
// R4CA-7: a list item by its index, else its `name`, else the `type` of an item without
|
|
477
|
+
// a name — as config set, unset and secret name it (resolveItems).
|
|
478
|
+
let at = ITEM_INDEX.test(key)
|
|
479
|
+
? Number(key)
|
|
480
|
+
: value.findIndex((item) => isObject(item) && item.name === key);
|
|
481
|
+
if (at < 0)
|
|
482
|
+
at = value.findIndex((item) => isObject(item) && !Object.hasOwn(item, 'name') && item.type === key);
|
|
483
|
+
next = at >= 0 ? value[at] : undefined;
|
|
484
|
+
segment = String(at);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
next = isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
488
|
+
if (next === undefined) {
|
|
489
|
+
const where = [target.section, ...target.path.slice(0, index)].join('.');
|
|
490
|
+
throw new CliError('UNKNOWN_OPTION', `${[target.section, ...target.path].join('.')} is not set in ${file} and has no default`, {
|
|
491
|
+
phase: 'arguments',
|
|
492
|
+
details: {
|
|
493
|
+
path: [target.section, ...target.path].join('.'),
|
|
494
|
+
parent: where,
|
|
495
|
+
keys: isObject(value) ? Object.keys(value) : Array.isArray(value) ? value.length : [],
|
|
496
|
+
// A list: how each item is named (its name, else type, else index).
|
|
497
|
+
...(Array.isArray(value)
|
|
498
|
+
? {
|
|
499
|
+
items: value.map((item, at) => isObject(item) && typeof (item.name ?? item.type) === 'string'
|
|
500
|
+
? String(item.name ?? item.type)
|
|
501
|
+
: String(at)),
|
|
502
|
+
}
|
|
503
|
+
: {}),
|
|
504
|
+
fix: `npx dokebi config show ${where} shows what is there; the plugin AI.md lists its options`,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
value = next;
|
|
509
|
+
resolved.push(segment);
|
|
510
|
+
}
|
|
511
|
+
const prefix = target ? [target.section, ...target.path].join('.') : '';
|
|
512
|
+
const at = target ? [target.section, ...resolved].join('.') : '';
|
|
513
|
+
const inside = (path) => !at || path === at || path.startsWith(`${at}.`);
|
|
514
|
+
debug('Configuration shown', {
|
|
515
|
+
path: prefix || null,
|
|
516
|
+
sections: Object.keys(shown).length,
|
|
517
|
+
secrets: secrets.length,
|
|
518
|
+
defaults: defaults.length,
|
|
519
|
+
});
|
|
520
|
+
return {
|
|
521
|
+
file,
|
|
522
|
+
path: prefix || null,
|
|
523
|
+
value,
|
|
524
|
+
defaults: defaults.filter(inside),
|
|
525
|
+
secrets: secrets.filter(inside),
|
|
526
|
+
reloadable: reloadable.filter((path) => inside(path) || at.startsWith(`${path}.`)),
|
|
527
|
+
...(warnings.length ? { warnings } : {}),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/** The result for people: YAML, defaults marked with a comment, secrets already hidden. */
|
|
531
|
+
export function configShowText(result) {
|
|
532
|
+
const base = result.path ? result.path.split('.') : [];
|
|
533
|
+
const document = new Document(result.value);
|
|
534
|
+
for (const path of result.defaults) {
|
|
535
|
+
const relative = path.split('.').slice(base.length);
|
|
536
|
+
const node = relative.length ? document.getIn(relative, true) : document.contents;
|
|
537
|
+
if (isNode(node))
|
|
538
|
+
node.comment = ` default (not in ${result.file})`;
|
|
539
|
+
}
|
|
540
|
+
// D-0154: what a running app reads again, as in the YAML the CLI writes.
|
|
541
|
+
for (const path of result.reloadable ?? []) {
|
|
542
|
+
const relative = path.split('.').slice(base.length);
|
|
543
|
+
if (!relative.length)
|
|
544
|
+
continue;
|
|
545
|
+
const parent = relative.length > 1 ? document.getIn(relative.slice(0, -1), true) : document.contents;
|
|
546
|
+
const pair = isMap(parent)
|
|
547
|
+
? parent.items.find((item) => isScalar(item.key) && String(item.key.value) === relative.at(-1))
|
|
548
|
+
: undefined;
|
|
549
|
+
const value = pair?.value ?? document.getIn(relative, true);
|
|
550
|
+
// A block collection gets the mark on its key line; a value after itself.
|
|
551
|
+
const target = isCollection(value) && pair && isNode(pair.key) ? pair.key : value;
|
|
552
|
+
if (isNode(target))
|
|
553
|
+
target.comment = target.comment
|
|
554
|
+
? `${target.comment} · ${RELOADABLE_MARK}`
|
|
555
|
+
: ` ${RELOADABLE_MARK}`;
|
|
556
|
+
}
|
|
557
|
+
const lines = [
|
|
558
|
+
`# ${result.file}${result.path ? ` ${result.path}` : ' options'}: secrets shown as ${SECRET_MARKER}${result.secrets.length ? ` (${result.secrets.join(', ')})` : ' (none here)'}`,
|
|
559
|
+
document.toString({ lineWidth: 0 }).trimEnd(),
|
|
560
|
+
...(result.warnings ?? []).map((warning) => `# warning ${warning.code}: ${warning.hint}`),
|
|
561
|
+
];
|
|
562
|
+
return `${lines.join('\n')}\n`;
|
|
563
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A plain object (`{}` or JSON), not an array, a class instance or a null-prototype object. */
|
|
2
|
+
export declare function isPlainRecord(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Accepts a plain object of JSON values (strings, finite numbers, booleans, null, arrays and plain
|
|
5
|
+
* objects) within MAX_VALUES values, MAX_DEPTH levels and MAX_BYTES; anything else is
|
|
6
|
+
* INVALID_INSTALL_DEFAULTS with `details.reason`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateDefaults(value: unknown): asserts value is Record<string, unknown>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The bound on a value the CLI writes into dokebi.yaml for a plugin: install plan defaults
|
|
2
|
+
// (install-plan, merge-install-defaults) and `--set` / `config set` values (config-edit). A leaf
|
|
3
|
+
// module (only errors and diagnostics): install-plan used to import it from merge-install-defaults,
|
|
4
|
+
// which imports configuration, which imports project-state, which imports install-plan — a cycle
|
|
5
|
+
// (D-0110 §1).
|
|
6
|
+
import { debug } from './diagnostics.js';
|
|
7
|
+
import { CliError } from './errors.js';
|
|
8
|
+
/** The most values (nodes) one defaults object may hold. */
|
|
9
|
+
const MAX_VALUES = 2000;
|
|
10
|
+
/** The deepest nesting of one defaults object. */
|
|
11
|
+
const MAX_DEPTH = 16;
|
|
12
|
+
/** The largest JSON form of one defaults object. */
|
|
13
|
+
const MAX_BYTES = 1024 * 1024;
|
|
14
|
+
/** Keys that would change a prototype or a YAML merge instead of naming an option. */
|
|
15
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype', '<<']);
|
|
16
|
+
function invalid(reason) {
|
|
17
|
+
debug('Plugin defaults refused', { reason });
|
|
18
|
+
throw new CliError('INVALID_INSTALL_DEFAULTS', 'Plugin defaults must be bounded JSON values within their own configuration namespace.', { details: { reason } });
|
|
19
|
+
}
|
|
20
|
+
/** A plain object (`{}` or JSON), not an array, a class instance or a null-prototype object. */
|
|
21
|
+
export function isPlainRecord(value) {
|
|
22
|
+
return (typeof value === 'object' &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
!Array.isArray(value) &&
|
|
25
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Accepts a plain object of JSON values (strings, finite numbers, booleans, null, arrays and plain
|
|
29
|
+
* objects) within MAX_VALUES values, MAX_DEPTH levels and MAX_BYTES; anything else is
|
|
30
|
+
* INVALID_INSTALL_DEFAULTS with `details.reason`.
|
|
31
|
+
*/
|
|
32
|
+
export function validateDefaults(value) {
|
|
33
|
+
if (!isPlainRecord(value))
|
|
34
|
+
invalid('not a plain object');
|
|
35
|
+
let count = 0;
|
|
36
|
+
const seen = new WeakSet();
|
|
37
|
+
const visit = (item, depth) => {
|
|
38
|
+
count += 1;
|
|
39
|
+
if (count > MAX_VALUES)
|
|
40
|
+
invalid(`more than ${MAX_VALUES} values`);
|
|
41
|
+
if (depth > MAX_DEPTH)
|
|
42
|
+
invalid(`nested deeper than ${MAX_DEPTH} levels`);
|
|
43
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean')
|
|
44
|
+
return;
|
|
45
|
+
if (typeof item === 'number' && Number.isFinite(item))
|
|
46
|
+
return;
|
|
47
|
+
if (typeof item !== 'object' || item === null)
|
|
48
|
+
invalid(`a ${typeof item} is not a JSON value`);
|
|
49
|
+
if (seen.has(item))
|
|
50
|
+
invalid('a value appears twice (a cycle or a shared object)');
|
|
51
|
+
seen.add(item);
|
|
52
|
+
if (Array.isArray(item)) {
|
|
53
|
+
for (const child of item)
|
|
54
|
+
visit(child, depth + 1);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!isPlainRecord(item))
|
|
58
|
+
invalid('an object that is not a plain object');
|
|
59
|
+
for (const [key, child] of Object.entries(item)) {
|
|
60
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
61
|
+
invalid(`the key ${key} is not allowed`);
|
|
62
|
+
visit(child, depth + 1);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
visit(value, 0);
|
|
66
|
+
if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES)
|
|
67
|
+
invalid(`larger than ${MAX_BYTES} bytes as JSON`);
|
|
68
|
+
}
|