@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
@@ -0,0 +1,130 @@
1
+ // D-0153: the `fix` of an options error names the commands that repair it. A key the plugin does
2
+ // not know (JSON Schema `additionalProperties`) is removed with `npx dokebi config unset`; a key
3
+ // that moved to another plugin (MOVED_OPTIONS, kept here) says where it went and how to carry its
4
+ // settings over. Fixes name keys and commands, never values.
5
+ /** Options that moved to another plugin, newest last. */
6
+ export const MOVED_OPTIONS = Object.freeze([
7
+ Object.freeze({
8
+ plugin: 'dokebi-web',
9
+ path: Object.freeze(['files']),
10
+ to: 'dokebi-static.mounts',
11
+ package: '@dokebi/static',
12
+ decision: 'D-0147',
13
+ set: 'npx dokebi config set dokebi-static.mounts.<name>.prefix=<prefix> dokebi-static.mounts.<name>.root=<folder> for each mount',
14
+ }),
15
+ ]);
16
+ const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
17
+ /** The option path (segments) an issue points at, with the unknown key for additionalProperties. */
18
+ function issuePath(issue) {
19
+ const parts = issue.instancePath
20
+ .split('/')
21
+ .slice(1)
22
+ .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'));
23
+ return issue.keyword === 'additionalProperties' && issue.additionalProperty !== undefined
24
+ ? [...parts, issue.additionalProperty]
25
+ : parts;
26
+ }
27
+ /** `<id>.<path>` of every key the plugin does not know, when the path can be unset. */
28
+ export function unknownOptionPaths(id, issues) {
29
+ const paths = [];
30
+ let unnamed = false;
31
+ for (const issue of issues) {
32
+ if (issue.keyword !== 'additionalProperties')
33
+ continue;
34
+ const path = issuePath(issue);
35
+ if (issue.additionalProperty === undefined || !path.every((part) => SEGMENT.test(part))) {
36
+ unnamed = true;
37
+ continue;
38
+ }
39
+ const where = [id, ...path].join('.');
40
+ if (!paths.includes(where))
41
+ paths.push(where);
42
+ }
43
+ return { paths, unnamed };
44
+ }
45
+ /** The moved option a path is, or is inside of. */
46
+ export function movedOption(id, path) {
47
+ return MOVED_OPTIONS.find((moved) => moved.plugin === id &&
48
+ moved.path.length <= path.length &&
49
+ moved.path.every((part, index) => path[index] === part));
50
+ }
51
+ /**
52
+ * The fix of INVALID_PLUGIN_OPTIONS for plugin `id`: the moved keys first (where they went, then
53
+ * show, unset, add, set), then `npx dokebi config unset` for the other unknown keys, then
54
+ * `fallback` (how to correct the remaining issues), which is the whole fix when no key is unknown.
55
+ */
56
+ export function optionsFix(id, issues, fallback,
57
+ /** The section of dokebi.yaml the commands name (options.logging for the logger host). */
58
+ section = id,
59
+ /** The plugin's options schema: the fix then says what the refused field expects. */
60
+ schema) {
61
+ const expected = expectedShape(schema, issues, section);
62
+ if (expected)
63
+ fallback = `${fallback}; ${expected}`;
64
+ const { paths, unnamed } = unknownOptionPaths(section, issues);
65
+ const parts = [];
66
+ const moved = new Map();
67
+ const other = [];
68
+ for (const where of paths) {
69
+ const match = movedOption(id, where.split('.').slice(1));
70
+ if (match)
71
+ moved.set(match, [match.plugin, ...match.path].join('.'));
72
+ else
73
+ other.push(where);
74
+ }
75
+ for (const [match, from] of moved)
76
+ parts.push(`options.${from} moved to options.${match.to} (${match.decision}): npx dokebi config show ${from} to see what it held, npx dokebi config unset ${from}, npx dokebi add ${match.package} when it is not installed, then ${match.set}`);
77
+ if (other.length)
78
+ parts.push(`remove what ${id} does not know: npx dokebi config unset ${other.join(' ')} (npx dokebi plugins info ${id} --json lists its option keys)`);
79
+ const rest = issues.some((issue) => issue.keyword !== 'additionalProperties') || unnamed;
80
+ if (!parts.length)
81
+ return fallback;
82
+ return rest ? `${parts.join('; ')}; then ${fallback}` : parts.join('; ');
83
+ }
84
+ const node = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
85
+ ? value
86
+ : undefined;
87
+ /** The schema of the field at an issue's instance path (properties, additionalProperties, items). */
88
+ function fieldSchema(schema, issue) {
89
+ let at = node(schema);
90
+ for (const part of issue.instancePath.split('/').slice(1)) {
91
+ if (!at)
92
+ return undefined;
93
+ const key = part.replaceAll('~1', '/').replaceAll('~0', '~');
94
+ const properties = node(at.properties);
95
+ at =
96
+ properties && Object.hasOwn(properties, key)
97
+ ? node(properties[key])
98
+ : /^[0-9]+$/.test(key) && node(at.items)
99
+ ? node(at.items)
100
+ : node(at.additionalProperties);
101
+ }
102
+ return at;
103
+ }
104
+ /**
105
+ * What the first refused field expects, from the schema only (community request D3: `config set`
106
+ * said `(pattern)` and nothing about the shape, so the value could not be corrected). Never the
107
+ * value from the configuration: the description, the allowed values, the type or the bounds.
108
+ */
109
+ function expectedShape(schema, issues, section) {
110
+ const issue = issues.find((item) => item.keyword !== 'additionalProperties' && item.keyword !== 'required');
111
+ if (!issue)
112
+ return undefined;
113
+ const field = fieldSchema(schema, issue);
114
+ if (!field)
115
+ return undefined;
116
+ const said = [];
117
+ if (typeof field.description === 'string')
118
+ said.push(field.description);
119
+ if (Array.isArray(field.enum))
120
+ said.push(`one of ${field.enum.map((value) => JSON.stringify(value)).join(', ')}`);
121
+ else if (field.type !== undefined && issue.keyword === 'type')
122
+ said.push(`type ${[field.type].flat().join(' or ')}`);
123
+ for (const bound of ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems'])
124
+ if (issue.keyword === bound && typeof field[bound] === 'number')
125
+ said.push(`${bound} ${field[bound]}`);
126
+ if (!said.length)
127
+ return undefined;
128
+ const where = `options.${section}${issue.instancePath.replaceAll('/', '.')}`;
129
+ return `${where} expects: ${said.join('; ')}`;
130
+ }
package/dist/output.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const CLI_VERSION = "0.4.2";
1
+ export declare const CLI_VERSION = "0.4.27";
2
2
  export declare function envelope(command: string, result: unknown, ok?: boolean): object;
3
3
  export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;
package/dist/output.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { CliError } from './errors.js';
2
- export const CLI_VERSION = '0.4.2';
2
+ export const CLI_VERSION = '0.4.27';
3
3
  export function envelope(command, result, ok = true) {
4
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
5
5
  }
6
6
  export function failure(command, error, mutationStarted) {
7
7
  const code = error instanceof CliError ? error.code : 'INTERNAL_ERROR';
8
8
  // npm failing in the staging folder never touched the project (D-0065, AI-M01).
9
- const staged = code === 'NPM_FAILED' &&
9
+ const staged = (code === 'NPM_FAILED' || code === 'REGISTRY_MISMATCH') &&
10
10
  error instanceof CliError &&
11
11
  error.details?.stage === 'staging';
12
12
  const phase = (error instanceof CliError ? error.phase : undefined) ??
@@ -17,9 +17,9 @@ export function failure(command, error, mutationStarted) {
17
17
  : code === 'USAGE'
18
18
  ? 'arguments'
19
19
  : 'unknown');
20
- // An error of the arguments phase is raised before anything is written (or before a
21
- // transaction commits): the project is untouched.
22
- const mutation = !mutationStarted || staged || phase === 'arguments'
20
+ // An error of the arguments or plan phase is raised before anything is written (or before a
21
+ // transaction commits): the project is untouched (D-0116).
22
+ const mutation = !mutationStarted || staged || phase === 'arguments' || phase === 'plan'
23
23
  ? 'none'
24
24
  : code === 'COMMIT_FAILED'
25
25
  ? 'rolled-back'
@@ -14,8 +14,14 @@ export interface LifecyclePlan extends Omit<InstallPlan, 'configDefaults'> {
14
14
  requires: string[];
15
15
  configChanges: ConfigChange[];
16
16
  /**
17
- * planInstall only (D-0073): environment variables that must hold a secret. The CLI writes a
18
- * generated value into the project's .env when neither the environment nor .env has one.
17
+ * planInstall only (D-0073 addendum 2): option paths inside the plugin's own section that must
18
+ * hold a secret. `add` writes a generated value there in dokebi.yaml unless one is there.
19
+ */
20
+ configSecrets: string[][];
21
+ /**
22
+ * planInstall only, deprecated (D-0073 §2, removed in 0.5): environment variables that must
23
+ * hold a secret. The CLI appends a generated value to the project's .env when neither the
24
+ * environment nor .env has one.
19
25
  */
20
26
  envSecrets: string[];
21
27
  }
@@ -41,10 +47,36 @@ export interface LifecycleInput {
41
47
  }
42
48
  /** An environment variable name a plan may ask a generated secret for. */
43
49
  export declare const ENV_NAME: RegExp;
50
+ /**
51
+ * Keys a planInstall result may have. Install modules get them as `planKeys` in their context, so
52
+ * a plugin can tell whether this CLI takes `configSecrets` (0.4.3 and later) or only `envSecrets`.
53
+ */
54
+ export declare const INSTALL_PLAN_KEYS: readonly string[];
44
55
  export declare function optionsOf(options: Record<string, unknown>, id: string): Record<string, unknown>;
45
56
  export declare function optionPath(value: unknown): string[];
46
57
  /** Static `dokebi.install` merged with the module's planInstall or planUpgrade result. */
47
58
  export declare function planLifecycle(input: LifecycleInput, phase: 'planInstall' | 'planUpgrade', extra?: Record<string, unknown>): Promise<LifecyclePlan>;
59
+ /** One issue of a plugin's own option rules, shaped like the schema validator's (D-0162). */
60
+ export interface PluginOptionIssue {
61
+ readonly instancePath: string;
62
+ readonly keyword: string;
63
+ readonly message: string;
64
+ }
65
+ /** D-0162: one plugin's verdict: the issues of its own rules, or why they could not run. */
66
+ export type PluginOptionsVerdict = {
67
+ readonly issues: readonly PluginOptionIssue[];
68
+ } | {
69
+ readonly failed: string;
70
+ };
71
+ /**
72
+ * D-0162: the plugins' own option rules on their sections, cross-field checks included
73
+ * (dokebi-app's `unknownDomain` must be `reject` or a key of `domains`), which the JSON schema
74
+ * cannot say. Every listed plugin's main entry is imported in ONE permission-restricted child, as
75
+ * install modules are (a child per plugin made `dokebi check` about a second slower on a blog
76
+ * project), and its `createPlugin.checkOptions` gets its section. A plugin without it (a parser
77
+ * function, an older core) is left out of the result; a plugin whose entry fails has `failed`.
78
+ */
79
+ export declare function checkPluginsOptions(base: string, plugins: readonly PluginManifest[], options: Record<string, unknown>): Promise<Map<string, PluginOptionsVerdict>>;
48
80
  export declare function checkPlugin(input: LifecycleInput): Promise<CheckResult>;
49
81
  /** Runs check for every plugin and returns results by plugin id. */
50
82
  export declare function checkAll(inputs: readonly Omit<LifecycleInput, 'plugins'>[], plugins: readonly PluginManifest[]): Promise<Map<string, CheckResult>>;
@@ -1,10 +1,13 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
1
4
  import { debug, debugEnabled, describeError } from './diagnostics.js';
2
5
  import { CliError } from './errors.js';
3
6
  import { object } from './filesystem.js';
4
7
  import { InstallPlanningError, installReadRoots, PHASE_MISSING, runInstallModule, } from './install-module.js';
5
8
  import { readInstallPlan } from './install-plan.js';
6
9
  import { assertLayout } from './layout.js';
7
- import { ID } from './manifest.js';
10
+ import { ID, optionsKey } from './manifest.js';
8
11
  import { packageDirectory, packageFile } from './workspace.js';
9
12
  // Printable single line: no control characters (log/terminal injection) and a short bound.
10
13
  const HINT = /^[^\p{Cc}]{1,200}$/u;
@@ -12,6 +15,28 @@ const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
12
15
  const KEY = /^[A-Za-z0-9_-]{1,64}$/;
13
16
  /** An environment variable name a plan may ask a generated secret for. */
14
17
  export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
18
+ /** The most generated secrets one plan may ask for (configSecrets, envSecrets). */
19
+ const MAX_SECRETS = 8;
20
+ /**
21
+ * Keys a planInstall result may have. Install modules get them as `planKeys` in their context, so
22
+ * a plugin can tell whether this CLI takes `configSecrets` (0.4.3 and later) or only `envSecrets`.
23
+ */
24
+ export const INSTALL_PLAN_KEYS = Object.freeze([
25
+ 'directories',
26
+ 'files',
27
+ 'entry',
28
+ 'configDefaults',
29
+ 'requires',
30
+ 'configSecrets',
31
+ 'envSecrets',
32
+ ]);
33
+ const UPGRADE_PLAN_KEYS = Object.freeze([
34
+ 'directories',
35
+ 'files',
36
+ 'entry',
37
+ 'configChanges',
38
+ 'requires',
39
+ ]);
15
40
  export function optionsOf(options, id) {
16
41
  const value = Object.hasOwn(options, id) ? options[id] : undefined;
17
42
  return value === undefined || value === null ? {} : object(value, 'plugin options');
@@ -40,10 +65,28 @@ export function optionPath(value) {
40
65
  }
41
66
  return [...value];
42
67
  }
68
+ /** `configSecrets: [{ path }]`: paths inside the plugin's own options, at most 8, no repeats. */
69
+ function configSecrets(value) {
70
+ if (value === undefined)
71
+ return [];
72
+ if (!Array.isArray(value) || value.length > MAX_SECRETS)
73
+ throw new Error('configSecrets');
74
+ const result = [];
75
+ for (const raw of value) {
76
+ const item = object(raw, 'config secret');
77
+ if (Object.keys(item).some((key) => key !== 'path'))
78
+ throw new Error('configSecrets');
79
+ const path = optionPath(item.path);
80
+ if (result.some((other) => other.join('.') === path.join('.')))
81
+ throw new Error('configSecrets');
82
+ result.push(path);
83
+ }
84
+ return result;
85
+ }
43
86
  function envSecrets(value) {
44
87
  if (value === undefined)
45
88
  return [];
46
- if (!Array.isArray(value) || value.length > 8)
89
+ if (!Array.isArray(value) || value.length > MAX_SECRETS)
47
90
  throw new Error('envSecrets');
48
91
  const result = [];
49
92
  for (const item of value) {
@@ -81,7 +124,8 @@ async function run(input, phase, extra = {}) {
81
124
  // the workspace root with their .env files.
82
125
  const readRoots = await installReadRoots(directory);
83
126
  return await runInstallModule(path, phase, {
84
- options: optionsOf(input.options, input.plugin.id),
127
+ // R4CB-6: the plugin's own section: options.logging for the logger host (D-0151).
128
+ options: optionsOf(input.options, optionsKey(input.plugin)),
85
129
  plugins: input.plugins.map(({ id, version, packageName }) => ({
86
130
  id,
87
131
  version,
@@ -92,24 +136,28 @@ async function run(input, phase, extra = {}) {
92
136
  }, readRoots);
93
137
  }
94
138
  catch (error) {
95
- // The caller sees only the code; debug keeps the cause of an unexpected failure.
96
- if (!(error instanceof InstallPlanningError) && debugEnabled())
139
+ // The caller sees only the code (a module's message may carry option values, and the module
140
+ // is a package's code); debug keeps the cause, and the module's own words for its author
141
+ // (the error it threw or the end of its stderr, masked), which were dropped before.
142
+ if (debugEnabled())
97
143
  debug('Install module could not be run', {
98
144
  plugin: input.plugin.id,
99
145
  phase,
100
- error: describeError(error),
146
+ ...(error instanceof InstallPlanningError
147
+ ? { code: error.code, ...(error.detail ? { module: error.detail } : {}) }
148
+ : { error: describeError(error) }),
101
149
  });
102
150
  throw new CliError('PLUGIN_INSTALL_FAILED', input.plugin.id +
103
151
  ': ' +
104
152
  (error instanceof InstallPlanningError ? error.code : 'INSTALL_MODULE_FAILED'), { details: { plugin: input.plugin.id, phase } });
105
153
  }
106
154
  }
107
- function invalidPlan(id) {
108
- return new CliError('INVALID_MANIFEST', 'Invalid installation plan: ' + id);
155
+ function invalidPlan(id, cause) {
156
+ return new CliError('INVALID_MANIFEST', 'Invalid installation plan: ' + id, cause === undefined ? undefined : { cause });
109
157
  }
110
158
  /** Static `dokebi.install` merged with the module's planInstall or planUpgrade result. */
111
159
  export async function planLifecycle(input, phase, extra = {}) {
112
- const raw = await run(input, phase, extra);
160
+ const raw = await run(input, phase, phase === 'planInstall' ? { planKeys: [...INSTALL_PLAN_KEYS], ...extra } : extra);
113
161
  const plugin = input.plugin;
114
162
  const base = {
115
163
  files: plugin.files,
@@ -120,24 +168,32 @@ export async function planLifecycle(input, phase, extra = {}) {
120
168
  const staticDefaults = base.configDefaults ? [base.configDefaults] : [];
121
169
  if (raw === PHASE_MISSING) {
122
170
  const { configDefaults: _ignored, ...rest } = base;
123
- return { ...rest, defaults: staticDefaults, requires: [], configChanges: [], envSecrets: [] };
171
+ return {
172
+ ...rest,
173
+ defaults: staticDefaults,
174
+ requires: [],
175
+ configChanges: [],
176
+ configSecrets: [],
177
+ envSecrets: [],
178
+ };
124
179
  }
125
180
  let plan;
126
181
  let requires;
127
182
  let changes;
183
+ let secretPaths;
128
184
  let secrets;
129
185
  try {
130
186
  plan = object(raw, 'installation plan');
131
- const allowed = phase === 'planInstall'
132
- ? ['directories', 'files', 'entry', 'configDefaults', 'requires', 'envSecrets']
133
- : ['directories', 'files', 'entry', 'configChanges', 'requires'];
187
+ const allowed = phase === 'planInstall' ? INSTALL_PLAN_KEYS : UPGRADE_PLAN_KEYS;
134
188
  if (Object.keys(plan).some((key) => !allowed.includes(key)))
135
189
  throw new Error('keys');
136
190
  requires = requirements(plan.requires, plugin.id);
137
191
  changes = configChanges(plan.configChanges);
192
+ secretPaths = configSecrets(plan.configSecrets);
138
193
  secrets = envSecrets(plan.envSecrets);
139
194
  }
140
- catch {
195
+ catch (error) {
196
+ debug('Install plan refused', { plugin: plugin.id, phase, error: describeError(error) });
141
197
  throw invalidPlan(plugin.id);
142
198
  }
143
199
  if (plan.entry !== undefined && base.entry !== undefined)
@@ -160,8 +216,8 @@ export async function planLifecycle(input, phase, extra = {}) {
160
216
  ...(plan.entry !== undefined ? { entry: dynamic.entry } : {}),
161
217
  }, plugin.id, plugin.hosts);
162
218
  }
163
- catch {
164
- throw invalidPlan(plugin.id);
219
+ catch (error) {
220
+ throw invalidPlan(plugin.id, error);
165
221
  }
166
222
  const { configDefaults, ...files } = dynamic;
167
223
  return {
@@ -169,9 +225,132 @@ export async function planLifecycle(input, phase, extra = {}) {
169
225
  defaults: configDefaults ? [...staticDefaults, configDefaults] : staticDefaults,
170
226
  requires,
171
227
  configChanges: changes,
228
+ configSecrets: secretPaths,
172
229
  envSecrets: secrets,
173
230
  };
174
231
  }
232
+ const ISSUE_CODE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
233
+ /** The package's main ESM entry, relative to its folder: `exports["."]` (import), else `main`. */
234
+ async function mainEntry(directory) {
235
+ const pkg = object(JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')), 'package');
236
+ const exported = pkg.exports;
237
+ const dot = typeof exported === 'string'
238
+ ? exported
239
+ : exported && typeof exported === 'object' && !Array.isArray(exported)
240
+ ? exported['.']
241
+ : undefined;
242
+ const target = typeof dot === 'string'
243
+ ? dot
244
+ : dot && typeof dot === 'object'
245
+ ? (dot.import ?? dot.default)
246
+ : pkg.main;
247
+ return typeof target === 'string' ? target.replace(/^\.\//, '') : undefined;
248
+ }
249
+ /**
250
+ * D-0162: the plugins' own option rules on their sections, cross-field checks included
251
+ * (dokebi-app's `unknownDomain` must be `reject` or a key of `domains`), which the JSON schema
252
+ * cannot say. Every listed plugin's main entry is imported in ONE permission-restricted child, as
253
+ * install modules are (a child per plugin made `dokebi check` about a second slower on a blog
254
+ * project), and its `createPlugin.checkOptions` gets its section. A plugin without it (a parser
255
+ * function, an older core) is left out of the result; a plugin whose entry fails has `failed`.
256
+ */
257
+ export async function checkPluginsOptions(base, plugins, options) {
258
+ const verdicts = new Map();
259
+ const entries = [];
260
+ for (const plugin of plugins) {
261
+ const directory = await packageDirectory(base, plugin.packageName);
262
+ const entry = await mainEntry(directory);
263
+ if (entry === undefined)
264
+ continue;
265
+ entries.push({
266
+ id: plugin.id,
267
+ path: await packageFile(base, plugin.packageName, entry),
268
+ directory,
269
+ options: optionsOf(options, optionsKey(plugin)),
270
+ });
271
+ }
272
+ const first = entries[0];
273
+ if (!first)
274
+ return verdicts;
275
+ const roots = new Set();
276
+ for (const entry of entries)
277
+ for (const root of await installReadRoots(entry.directory))
278
+ roots.add(root);
279
+ let raw;
280
+ try {
281
+ raw = await runInstallModule(first.path, 'checkOptions', {
282
+ // Each entry carries its own section; the shared fields stay empty for this phase.
283
+ options: {},
284
+ plugins: [],
285
+ entries: entries.map((entry) => ({
286
+ url: pathToFileURL(entry.path).href,
287
+ options: entry.options,
288
+ })),
289
+ }, [...roots]);
290
+ }
291
+ catch (error) {
292
+ if (debugEnabled())
293
+ debug('Plugin options could not be checked', {
294
+ plugins: entries.map((entry) => entry.id),
295
+ ...(error instanceof InstallPlanningError
296
+ ? { code: error.code, ...(error.detail ? { module: error.detail } : {}) }
297
+ : { error: describeError(error) }),
298
+ });
299
+ throw new CliError('PLUGIN_OPTIONS_CHECK_FAILED', `Plugin options could not be checked: ${error instanceof InstallPlanningError ? error.code : 'INSTALL_MODULE_FAILED'}`, { cause: error });
300
+ }
301
+ if (!Array.isArray(raw) || raw.length !== entries.length)
302
+ throw new CliError('INVALID_MANIFEST', 'Invalid options check result');
303
+ raw.forEach((item, index) => {
304
+ const entry = entries[index];
305
+ const answer = object(item, 'options check');
306
+ if (answer.ok === false) {
307
+ if (debugEnabled())
308
+ debug('Plugin options could not be checked', {
309
+ plugin: entry.id,
310
+ code: answer.code,
311
+ ...(typeof answer.message === 'string' ? { module: answer.message } : {}),
312
+ });
313
+ verdicts.set(entry.id, {
314
+ failed: typeof answer.code === 'string' && ISSUE_CODE.test(answer.code)
315
+ ? answer.code
316
+ : 'INSTALL_MODULE_FAILED',
317
+ });
318
+ return;
319
+ }
320
+ if (answer.missing === true)
321
+ return;
322
+ verdicts.set(entry.id, { issues: optionIssues(answer.result, entry.id) });
323
+ });
324
+ return verdicts;
325
+ }
326
+ /** The issues of one checkOptions result, shaped like the schema validator's. */
327
+ function optionIssues(raw, id) {
328
+ const result = object(raw, 'options check');
329
+ const issues = result.issues;
330
+ if (typeof result.ok !== 'boolean' || !Array.isArray(issues) || issues.length > 100)
331
+ throw new CliError('INVALID_MANIFEST', `Invalid options check result: ${id}`);
332
+ return issues.map((item) => {
333
+ const issue = object(item, 'options issue');
334
+ if (typeof issue.path !== 'string' ||
335
+ issue.path.length > 500 ||
336
+ typeof issue.code !== 'string' ||
337
+ !ISSUE_CODE.test(issue.code) ||
338
+ typeof issue.message !== 'string' ||
339
+ issue.message.length > 500)
340
+ throw new CliError('INVALID_MANIFEST', `Invalid options check result: ${id}`);
341
+ const pointer = issue.path
342
+ .replace(/\[(\d+)\]/g, '.$1')
343
+ .split('.')
344
+ .filter(Boolean)
345
+ .map((part) => part.replaceAll('~', '~0').replaceAll('/', '~1'))
346
+ .join('/');
347
+ return {
348
+ instancePath: pointer ? `/${pointer}` : '',
349
+ keyword: issue.code,
350
+ message: issue.message,
351
+ };
352
+ });
353
+ }
175
354
  export async function checkPlugin(input) {
176
355
  const raw = await run(input, 'check', {
177
356
  installation: input.installation?.entry ? { entry: input.installation.entry } : {},
@@ -1,16 +1,31 @@
1
- import type { PluginManifest } from './manifest.js';
1
+ import { type PluginManifest } from './manifest.js';
2
+ import { type OptionIssueLike } from './option-fixes.js';
2
3
  export interface OptionsCheck {
3
4
  schema: Record<string, unknown> | null;
4
- errors: Array<{
5
- instancePath: string;
6
- keyword: string;
5
+ errors: OptionIssueLike[];
6
+ }
7
+ /** What checks options against a schema (ConfigValidator of @dokebi/config; a fake in tests). */
8
+ export interface SchemaChecker {
9
+ check(schema: unknown, options: unknown): Promise<{
10
+ errors: OptionIssueLike[];
7
11
  }>;
8
12
  }
9
13
  export declare class PluginOptionsValidator {
10
14
  #private;
11
15
  constructor(options?: {
12
16
  allErrors?: boolean;
17
+ validator?: SchemaChecker;
13
18
  });
19
+ /**
20
+ * D-0155: the options of an application settings section against the schema file beside its
21
+ * declaration (src/shared/settings/<section>.schema.json); no schema yet checks nothing.
22
+ */
23
+ checkSettings(root: string, section: string, options: unknown): Promise<OptionsCheck>;
24
+ /**
25
+ * A settings section against a schema in hand (`settings sync` checks the options against the
26
+ * schema it is about to write, before the file is on disk).
27
+ */
28
+ checkSettingsWith(section: string, schema: Record<string, unknown>, options: unknown): Promise<OptionsCheck>;
14
29
  check(root: string, plugin: PluginManifest, options: unknown): Promise<OptionsCheck>;
15
30
  }
16
31
  export declare function checkPluginOptions(root: string, plugin: PluginManifest, options: unknown): Promise<OptionsCheck>;