@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
@@ -27,8 +27,16 @@ export declare function registryBase(value: string | undefined): string | undefi
27
27
  export type VersionManifest = {
28
28
  dokebi?: {
29
29
  core?: string;
30
+ id?: string;
31
+ dependencies?: {
32
+ name?: string;
33
+ version?: string;
34
+ package?: string;
35
+ optional?: boolean;
36
+ }[];
30
37
  };
31
38
  deprecated?: string;
39
+ peerDependencies?: Record<string, string>;
32
40
  };
33
41
  export type Packument = ({
34
42
  readonly status: 'found';
@@ -47,6 +55,63 @@ export type Packument = ({
47
55
  * a plain auth-free GET. `offline` covers network and server errors, and npm `offline=true`.
48
56
  */
49
57
  export declare function fetchPackument(name: string, cwd?: string): Promise<Packument>;
58
+ /** An installed package version that the registry npm now uses for it does not have (D-0138). */
59
+ export interface MissingOnRegistry {
60
+ readonly package: string;
61
+ readonly version: string;
62
+ /** The registry npm uses for the package now (registryFor in the project folder). */
63
+ readonly registry: string;
64
+ /** Where package-lock.json says this copy came from, when it says. */
65
+ readonly installedFrom?: string;
66
+ }
67
+ /**
68
+ * D-0138: the installed packages (an exact registry version each) whose version is not on the
69
+ * registry npm now uses for them in `cwd`. npm re-reads them from that registry to check the
70
+ * peers of what it adds, so a project installed from another registry (another `.npmrc`, a shell
71
+ * without the `@dokebi:registry` it was installed with) fails with an ERESOLVE whose "Found:
72
+ * @dokebi/core@undefined" says nothing of the cause. A package the registry does not know at all
73
+ * counts as missing; a registry that cannot be read (offline, an error) proves nothing and is
74
+ * skipped (debug log). A lock `resolved` under the same registry is not asked again.
75
+ */
76
+ export declare function missingOnRegistry(installed: readonly {
77
+ readonly package: string;
78
+ readonly version: string;
79
+ readonly resolved?: string;
80
+ }[], cwd?: string): Promise<MissingOnRegistry[]>;
81
+ /**
82
+ * What the project asks of a version `add` or `upgrade` picks: the peers it names must accept the
83
+ * versions installed, and the installed plugins must accept it (their manifest dependency ranges,
84
+ * optional ones too). Without this, the highest core-compatible version could need a newer
85
+ * @dokebi/web than the project has, and npm stopped the install with ERESOLVE.
86
+ */
87
+ export interface ProjectConstraints {
88
+ /** Installed npm packages and their versions (the package being changed left out). */
89
+ readonly installed: Readonly<Record<string, string>>;
90
+ /** Plugin ids of installed packages, by package name, for the fix commands. */
91
+ readonly ids: Readonly<Record<string, string>>;
92
+ /** Ranges installed plugins declare for a plugin id (their manifest dependencies). */
93
+ readonly required: Readonly<Record<string, readonly {
94
+ plugin: string;
95
+ range: string;
96
+ }[]>>;
97
+ }
98
+ /** Why a version does not fit the project: a peer range, or a dependent plugin's range. */
99
+ export type VersionConflict = {
100
+ readonly kind: 'peer';
101
+ readonly package: string;
102
+ readonly range: string;
103
+ readonly installed: string;
104
+ } | {
105
+ readonly kind: 'dependent';
106
+ readonly plugin: string;
107
+ readonly range: string;
108
+ };
109
+ /** The conflicts of `version` (with manifest `manifest`) with the installed project. */
110
+ export declare function versionConflicts(version: string, manifest: VersionManifest, constraints: ProjectConstraints): VersionConflict[];
111
+ /** What `version` of `name` needs, in one sentence part per conflict. */
112
+ export declare function describeConflicts(name: string, version: string, conflicts: readonly VersionConflict[]): string;
113
+ /** The commands that clear `conflicts` (each upgrade first), then `then`. */
114
+ export declare function conflictFix(conflicts: readonly VersionConflict[], constraints: ProjectConstraints, then: string): string;
50
115
  /**
51
116
  * The highest release whose `dokebi.core` range accepts `coreVersion` (a version without the
52
117
  * field counts as compatible). Prereleases are never chosen (semver's `includePrerelease:
@@ -56,6 +121,19 @@ export declare function fetchPackument(name: string, cwd?: string): Promise<Pack
56
121
  export declare function highestCompatible(packument: {
57
122
  readonly versions: Readonly<Record<string, VersionManifest>>;
58
123
  }, coreVersion: string): string | null;
124
+ export interface VersionPick {
125
+ /** The chosen version: compatible with the core and, with constraints, with the project. */
126
+ readonly version: string | null;
127
+ /** The version the core alone would choose, when the project's packages hold it back. */
128
+ readonly heldBack?: {
129
+ readonly version: string;
130
+ readonly conflicts: readonly VersionConflict[];
131
+ };
132
+ }
133
+ /** highestCompatible, and with `constraints` only a version without conflicts (see heldBack). */
134
+ export declare function pickVersion(packument: {
135
+ readonly versions: Readonly<Record<string, VersionManifest>>;
136
+ }, coreVersion: string, constraints?: ProjectConstraints): VersionPick;
59
137
  export interface Resolution {
60
138
  readonly package: string;
61
139
  readonly version: string;
@@ -71,9 +149,27 @@ export interface Resolution {
71
149
  * compatible with the project core, from the registry npm uses for that package in `cwd`, else
72
150
  * from the CLI snapshot. Throws NOT_PUBLISHED (the registry has no such package) or
73
151
  * NO_COMPATIBLE_VERSION. Returns undefined with an offline marker when neither source knows the
74
- * package.
152
+ * package. With `project`, the version must also fit the installed packages (pickVersion): a
153
+ * newer one they hold back is returned as `heldBack`, and when none fits it is
154
+ * INCOMPATIBLE_VERSION whose fix upgrades them first, then runs `project.next`.
75
155
  */
76
- export declare function resolveVersion(name: string, coreVersion: string, catalog: Catalog, cwd?: string): Promise<{
156
+ export declare function resolveVersion(name: string, coreVersion: string, catalog: Catalog, cwd?: string, project?: {
157
+ readonly constraints: ProjectConstraints;
158
+ readonly next: string;
159
+ }): Promise<{
77
160
  resolution?: Resolution;
78
161
  offline?: string;
162
+ heldBack?: VersionPick['heldBack'];
163
+ /** The registry's manifest of the chosen version (its peers and dokebi dependencies). */
164
+ manifest?: VersionManifest;
79
165
  }>;
166
+ /**
167
+ * The versions of the required plugins `manifest` names with a package (`dokebi.dependencies`,
168
+ * not optional) that the project lacks, and theirs in turn: `<package>@<version>` each, the
169
+ * highest in the required range that fits the core and the installed packages. npm installs a
170
+ * missing peer at the highest version of its range and never tries a lower one, so a session
171
+ * for core 0.4.1 got the newest @dokebi/web, which needed a newer core, and the install stopped
172
+ * with ERESOLVE. A dependency no version fits is INCOMPATIBLE_VERSION (fix: upgrade first, then
173
+ * `then`); one the registry cannot tell is left to npm. At most 16, like the automatic installs.
174
+ */
175
+ export declare function companionVersions(manifest: VersionManifest, coreVersion: string, constraints: ProjectConstraints, cwd: string, then: string, skip?: (name: string) => boolean): Promise<string[]>;
package/dist/registry.js CHANGED
@@ -5,7 +5,6 @@ import valid from 'semver/functions/valid.js';
5
5
  import { findPackage } from './catalog.js';
6
6
  import { debug, describeError } from './diagnostics.js';
7
7
  import { CliError } from './errors.js';
8
- import { runNpm } from './npm-client.js';
9
8
  const FETCH_TIMEOUT_MS = 8000;
10
9
  const NPM_CONFIG_TIMEOUT_MS = 30_000;
11
10
  /** npm's built-in default registry. */
@@ -36,6 +35,9 @@ export function registryFor(name, cwd = process.cwd()) {
36
35
  }
37
36
  async function askNpm(cwd, scope) {
38
37
  const keys = ['registry', 'offline', ...(scope ? [`${scope}:registry`] : [])];
38
+ // Loaded here: npm-client pulls in the manifest and YAML modules, which `plugins list` without
39
+ // --online never needs.
40
+ const runNpm = (await import('./npm-client.js')).runNpm;
39
41
  let values;
40
42
  try {
41
43
  const result = await runNpm(cwd, ['config', 'get', ...keys], {
@@ -145,8 +147,86 @@ export async function fetchPackument(name, cwd = process.cwd()) {
145
147
  };
146
148
  }
147
149
  }
150
+ /**
151
+ * D-0138: the installed packages (an exact registry version each) whose version is not on the
152
+ * registry npm now uses for them in `cwd`. npm re-reads them from that registry to check the
153
+ * peers of what it adds, so a project installed from another registry (another `.npmrc`, a shell
154
+ * without the `@dokebi:registry` it was installed with) fails with an ERESOLVE whose "Found:
155
+ * @dokebi/core@undefined" says nothing of the cause. A package the registry does not know at all
156
+ * counts as missing; a registry that cannot be read (offline, an error) proves nothing and is
157
+ * skipped (debug log). A lock `resolved` under the same registry is not asked again.
158
+ */
159
+ export async function missingOnRegistry(installed, cwd = process.cwd()) {
160
+ const found = await Promise.all(installed.map(async (item) => {
161
+ const settings = await registryFor(item.package, cwd);
162
+ if (item.resolved?.startsWith(settings.url))
163
+ return undefined;
164
+ const packument = await fetchPackument(item.package, cwd);
165
+ if (packument.status === 'offline') {
166
+ debug('Registry not readable; installed version not checked', {
167
+ package: item.package,
168
+ registry: packument.registry,
169
+ error: packument.error,
170
+ });
171
+ return undefined;
172
+ }
173
+ if (packument.status === 'found' && Object.hasOwn(packument.versions, item.version))
174
+ return undefined;
175
+ return {
176
+ package: item.package,
177
+ version: item.version,
178
+ registry: packument.registry,
179
+ ...(item.resolved ? { installedFrom: withoutCredentials(item.resolved) } : {}),
180
+ };
181
+ }));
182
+ return found.filter((item) => item !== undefined);
183
+ }
184
+ /** A lock `resolved` URL without `user:password@` (a file path or other text is kept). */
185
+ function withoutCredentials(value) {
186
+ if (!/^https?:\/\//.test(value))
187
+ return value;
188
+ try {
189
+ const url = new URL(value);
190
+ url.username = '';
191
+ url.password = '';
192
+ return url.href;
193
+ }
194
+ catch (error) {
195
+ debug('Lock resolved value is not a URL', { error: describeError(error) });
196
+ return value;
197
+ }
198
+ }
148
199
  /** `0.0.0-<tag>`: a name-reserving placeholder, never a real release. */
149
200
  const PLACEHOLDER = /^0\.0\.0-/;
201
+ /** The conflicts of `version` (with manifest `manifest`) with the installed project. */
202
+ export function versionConflicts(version, manifest, constraints) {
203
+ const found = [];
204
+ for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) {
205
+ const installed = constraints.installed[name];
206
+ if (installed !== undefined && valid(installed) && !satisfies(installed, range))
207
+ found.push({ kind: 'peer', package: name, range, installed });
208
+ }
209
+ const id = manifest.dokebi?.id;
210
+ for (const item of (id !== undefined && constraints.required[id]) || [])
211
+ if (!satisfies(version, item.range))
212
+ found.push({ kind: 'dependent', plugin: item.plugin, range: item.range });
213
+ return found;
214
+ }
215
+ /** What `version` of `name` needs, in one sentence part per conflict. */
216
+ export function describeConflicts(name, version, conflicts) {
217
+ return conflicts
218
+ .map((item) => item.kind === 'peer'
219
+ ? `${name} ${version} needs ${item.package} ${item.range} and the project has ${item.installed}`
220
+ : `the installed ${item.plugin} accepts ${name} ${item.range} only`)
221
+ .join('; ');
222
+ }
223
+ /** The commands that clear `conflicts` (each upgrade first), then `then`. */
224
+ export function conflictFix(conflicts, constraints, then) {
225
+ const steps = conflicts.map((item) => item.kind === 'peer'
226
+ ? `npx dokebi upgrade ${constraints.ids[item.package] ?? item.package} (to a version in ${item.range})`
227
+ : `npx dokebi upgrade ${item.plugin} (to a version that accepts it)`);
228
+ return `${[...new Set(steps)].join(', ')}, then ${then}`;
229
+ }
150
230
  /**
151
231
  * The highest release whose `dokebi.core` range accepts `coreVersion` (a version without the
152
232
  * field counts as compatible). Prereleases are never chosen (semver's `includePrerelease:
@@ -154,6 +234,10 @@ const PLACEHOLDER = /^0\.0\.0-/;
154
234
  * placeholder. A deprecated version is chosen only when no compatible one is undeprecated.
155
235
  */
156
236
  export function highestCompatible(packument, coreVersion) {
237
+ return pickVersion(packument, coreVersion).version;
238
+ }
239
+ /** highestCompatible, and with `constraints` only a version without conflicts (see heldBack). */
240
+ export function pickVersion(packument, coreVersion, constraints) {
157
241
  const candidates = Object.entries(packument.versions)
158
242
  .filter(([version, manifest]) => {
159
243
  if (!valid(version) || prerelease(version) || PLACEHOLDER.test(version))
@@ -162,16 +246,26 @@ export function highestCompatible(packument, coreVersion) {
162
246
  return range === undefined || satisfies(coreVersion, range);
163
247
  })
164
248
  .sort(([a], [b]) => rcompare(a, b));
165
- return candidates.find(([, manifest]) => !manifest.deprecated)?.[0] ?? candidates[0]?.[0] ?? null;
249
+ const choose = (list) => list.find(([, manifest]) => !manifest.deprecated)?.[0] ?? list[0]?.[0] ?? null;
250
+ const top = choose(candidates);
251
+ if (!constraints || top === null)
252
+ return { version: top };
253
+ const version = choose(candidates.filter(([candidate, manifest]) => versionConflicts(candidate, manifest, constraints).length === 0));
254
+ if (version === top)
255
+ return { version };
256
+ const conflicts = versionConflicts(top, packument.versions[top] ?? {}, constraints);
257
+ return { version, heldBack: { version: top, conflicts } };
166
258
  }
167
259
  /**
168
260
  * Picks the version `dokebi add <package>` installs when no version is given: the highest one
169
261
  * compatible with the project core, from the registry npm uses for that package in `cwd`, else
170
262
  * from the CLI snapshot. Throws NOT_PUBLISHED (the registry has no such package) or
171
263
  * NO_COMPATIBLE_VERSION. Returns undefined with an offline marker when neither source knows the
172
- * package.
264
+ * package. With `project`, the version must also fit the installed packages (pickVersion): a
265
+ * newer one they hold back is returned as `heldBack`, and when none fits it is
266
+ * INCOMPATIBLE_VERSION whose fix upgrades them first, then runs `project.next`.
173
267
  */
174
- export async function resolveVersion(name, coreVersion, catalog, cwd = process.cwd()) {
268
+ export async function resolveVersion(name, coreVersion, catalog, cwd = process.cwd(), project) {
175
269
  const found = await fetchPackument(name, cwd);
176
270
  if (found.status === 'missing')
177
271
  throw new CliError('NOT_PUBLISHED', `${name} is not published on the registry`, {
@@ -179,7 +273,24 @@ export async function resolveVersion(name, coreVersion, catalog, cwd = process.c
179
273
  phase: 'install',
180
274
  });
181
275
  if (found.status === 'found') {
182
- const version = highestCompatible(found, coreVersion);
276
+ const { version, heldBack } = pickVersion(found, coreVersion, project?.constraints);
277
+ if (!version && heldBack && project) {
278
+ const [first] = heldBack.conflicts;
279
+ throw new CliError('INCOMPATIBLE_VERSION', `No version of ${name} for core ${coreVersion} fits the installed packages: ${describeConflicts(name, heldBack.version, heldBack.conflicts)}`, {
280
+ details: {
281
+ package: name,
282
+ version: heldBack.version,
283
+ ...(first?.kind === 'peer'
284
+ ? { requires: first.package, range: first.range, installed: first.installed }
285
+ : first
286
+ ? { dependent: first.plugin, range: first.range }
287
+ : {}),
288
+ conflicts: heldBack.conflicts,
289
+ fix: conflictFix(heldBack.conflicts, project.constraints, project.next),
290
+ },
291
+ phase: 'plan',
292
+ });
293
+ }
183
294
  if (!version)
184
295
  throw new CliError('NO_COMPATIBLE_VERSION', `No version of ${name} supports @dokebi/core ${coreVersion}`, {
185
296
  details: {
@@ -200,6 +311,8 @@ export async function resolveVersion(name, coreVersion, catalog, cwd = process.c
200
311
  registry: found.registry,
201
312
  ...(deprecated ? { deprecated } : {}),
202
313
  },
314
+ ...(heldBack ? { heldBack } : {}),
315
+ manifest: found.versions[version] ?? {},
203
316
  };
204
317
  }
205
318
  const known = findPackage(catalog, name);
@@ -210,3 +323,56 @@ export async function resolveVersion(name, coreVersion, catalog, cwd = process.c
210
323
  };
211
324
  return { offline: found.error };
212
325
  }
326
+ /**
327
+ * The versions of the required plugins `manifest` names with a package (`dokebi.dependencies`,
328
+ * not optional) that the project lacks, and theirs in turn: `<package>@<version>` each, the
329
+ * highest in the required range that fits the core and the installed packages. npm installs a
330
+ * missing peer at the highest version of its range and never tries a lower one, so a session
331
+ * for core 0.4.1 got the newest @dokebi/web, which needed a newer core, and the install stopped
332
+ * with ERESOLVE. A dependency no version fits is INCOMPATIBLE_VERSION (fix: upgrade first, then
333
+ * `then`); one the registry cannot tell is left to npm. At most 16, like the automatic installs.
334
+ */
335
+ export async function companionVersions(manifest, coreVersion, constraints, cwd, then, skip = () => false) {
336
+ const specs = [];
337
+ const seen = new Set();
338
+ const queue = [manifest];
339
+ while (queue.length && specs.length < 16) {
340
+ const current = queue.shift();
341
+ for (const dependency of current.dokebi?.dependencies ?? []) {
342
+ const name = dependency.package;
343
+ const range = dependency.version;
344
+ if (dependency.optional || !name || !range || seen.has(name) || skip(name))
345
+ continue;
346
+ seen.add(name);
347
+ if (constraints.installed[name] !== undefined)
348
+ continue;
349
+ const found = await fetchPackument(name, cwd);
350
+ if (found.status !== 'found')
351
+ continue;
352
+ const inRange = Object.fromEntries(Object.entries(found.versions).filter(([version]) => valid(version) !== null && satisfies(version, range)));
353
+ const { version, heldBack } = pickVersion({ versions: inRange }, coreVersion, constraints);
354
+ if (!version) {
355
+ if (!heldBack)
356
+ continue;
357
+ throw new CliError('INCOMPATIBLE_VERSION', `No version of ${name} in ${range} (required by the package being installed) fits the installed packages: ${describeConflicts(name, heldBack.version, heldBack.conflicts)}`, {
358
+ details: {
359
+ package: name,
360
+ range,
361
+ version: heldBack.version,
362
+ conflicts: heldBack.conflicts,
363
+ fix: conflictFix(heldBack.conflicts, constraints, then),
364
+ },
365
+ phase: 'plan',
366
+ });
367
+ }
368
+ debug('Picked the version of a required plugin npm would install as a peer', {
369
+ package: name,
370
+ range,
371
+ version,
372
+ });
373
+ specs.push(`${name}@${version}`);
374
+ queue.push(found.versions[version] ?? {});
375
+ }
376
+ }
377
+ return specs;
378
+ }
@@ -0,0 +1,35 @@
1
+ import { type Document } from 'yaml';
2
+ import { type PluginManifest } from './manifest.js';
3
+ /** The comment the CLI writes after a reloadable key. */
4
+ export declare const RELOADABLE_MARK = "reloadable";
5
+ type Schema = Readonly<Record<string, unknown>>;
6
+ /**
7
+ * Core's part of options.logging (D-0047): level and plugins, both reloadable. With @dokebi/logger
8
+ * installed its schema (which has them, marked the same way) is used instead.
9
+ */
10
+ export declare const CORE_LOGGING_SCHEMA: Schema;
11
+ /** Whether a change at `path` inside a section with `schema` applies without a restart. */
12
+ export declare function reloadableAt(schema: unknown, path: readonly (string | number)[]): boolean;
13
+ /**
14
+ * The schema of each section of dokebi.yaml a running app can reload: every installed plugin
15
+ * with an options schema (its own section, `logging` for the logger host), core's `reload` and,
16
+ * without a logger plugin, core's `logging`. A schema that cannot be read is left out (nothing is
17
+ * marked for it).
18
+ */
19
+ export declare function reloadSchemas(root: string, plugins: readonly PluginManifest[], settings?: readonly string[]): Promise<Map<string, unknown>>;
20
+ /**
21
+ * Puts `# reloadable` after each topmost reloadable key under `options` and takes stale marks
22
+ * away (an option that is no longer reloadable), leaving every other comment and value as it is.
23
+ * Returns how many marks changed, and how many were moved back onto their key line.
24
+ */
25
+ export declare function applyReloadableMarks(document: Document, schemas: ReadonlyMap<string, unknown>): {
26
+ readonly changed: number;
27
+ readonly moved: number;
28
+ };
29
+ /**
30
+ * `source` (dokebi.yaml or dokebi.example.yaml) with the reloadable marks of `schemas`; the same
31
+ * text when no mark changes (nothing is re-rendered then) or when it is not a readable YAML
32
+ * document (the caller reports that).
33
+ */
34
+ export declare function markReloadable(source: string, schemas: ReadonlyMap<string, unknown>): string;
35
+ export {};
@@ -0,0 +1,257 @@
1
+ // D-0154: which options of dokebi.yaml a running app reads again, from the options schemas
2
+ // (`x-dokebi-reloadable: true` on an option, covering everything under it) and core's own
3
+ // sections. The CLI marks them in the YAML it writes (a trailing `# reloadable` comment on the
4
+ // topmost reloadable key present) and in `config show`, so a person or an AI sees which edits
5
+ // apply without a restart (`dokebi reload`, or saving the file while the app watches it).
6
+ import { loadSchema } from '@dokebi/config';
7
+ import { RELOADABLE_KEYWORD, reloadOptions } from '@dokebi/core';
8
+ import { isCollection, isMap, isScalar, isSeq, parseDocument, Scalar, } from 'yaml';
9
+ import { renderConfig } from './configuration.js';
10
+ import { debug } from './diagnostics.js';
11
+ import { optionsKey } from './manifest.js';
12
+ import { readSettingsSchema } from './settings.js';
13
+ import { packageFile } from './workspace.js';
14
+ /** The comment the CLI writes after a reloadable key. */
15
+ export const RELOADABLE_MARK = 'reloadable';
16
+ const MARK_TOKEN = /(^|\s|·)reloadable(?=\s|$)/;
17
+ /**
18
+ * Core's part of options.logging (D-0047): level and plugins, both reloadable. With @dokebi/logger
19
+ * installed its schema (which has them, marked the same way) is used instead.
20
+ */
21
+ export const CORE_LOGGING_SCHEMA = Object.freeze({
22
+ type: 'object',
23
+ properties: {
24
+ level: { [RELOADABLE_KEYWORD]: true },
25
+ plugins: { [RELOADABLE_KEYWORD]: true },
26
+ },
27
+ });
28
+ function record(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ const COMPOSED = ['anyOf', 'oneOf', 'allOf'];
32
+ /** A schema node and the forms it composes (no `$ref`: the generated schemas have none). */
33
+ function forms(node, depth = 0) {
34
+ const result = [node];
35
+ if (depth > 8)
36
+ return result;
37
+ for (const keyword of COMPOSED) {
38
+ const list = node[keyword];
39
+ if (Array.isArray(list))
40
+ for (const item of list)
41
+ if (record(item))
42
+ result.push(...forms(item, depth + 1));
43
+ }
44
+ return result;
45
+ }
46
+ /** The schema nodes one step down (a property, a map value, an array item). */
47
+ function children(nodes, segment) {
48
+ const next = [];
49
+ for (const node of nodes)
50
+ for (const form of forms(node)) {
51
+ if (typeof segment === 'number') {
52
+ if (record(form.items))
53
+ next.push(form.items);
54
+ continue;
55
+ }
56
+ const properties = record(form.properties) ? form.properties : undefined;
57
+ const own = properties?.[segment];
58
+ if (record(own))
59
+ next.push(own);
60
+ else if (record(form.additionalProperties))
61
+ next.push(form.additionalProperties);
62
+ }
63
+ return next;
64
+ }
65
+ function marked(nodes) {
66
+ if (!nodes.length)
67
+ return false;
68
+ // Every form that has the key must mark it (a form that changes the shape needs a restart).
69
+ return nodes.every((node) => forms(node).some((form) => form[RELOADABLE_KEYWORD] === true));
70
+ }
71
+ /** Whether a change at `path` inside a section with `schema` applies without a restart. */
72
+ export function reloadableAt(schema, path) {
73
+ if (!record(schema) || !path.length)
74
+ return false;
75
+ let nodes = [schema];
76
+ for (const segment of path) {
77
+ nodes = children(nodes, segment);
78
+ if (!nodes.length)
79
+ return false;
80
+ if (marked(nodes))
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+ /**
86
+ * The schema of each section of dokebi.yaml a running app can reload: every installed plugin
87
+ * with an options schema (its own section, `logging` for the logger host), core's `reload` and,
88
+ * without a logger plugin, core's `logging`. A schema that cannot be read is left out (nothing is
89
+ * marked for it).
90
+ */
91
+ export async function reloadSchemas(root, plugins, settings = []) {
92
+ const schemas = new Map([
93
+ ['logging', CORE_LOGGING_SCHEMA],
94
+ ['reload', reloadOptions.schema],
95
+ ]);
96
+ for (const plugin of plugins) {
97
+ if (!plugin.optionsSchema)
98
+ continue;
99
+ try {
100
+ schemas.set(optionsKey(plugin), await loadSchema(await packageFile(root, plugin.packageName, plugin.optionsSchema)));
101
+ }
102
+ catch (error) {
103
+ // Reported where the schema is used to check options (INVALID_OPTIONS_SCHEMA); here the
104
+ // section keeps its marks as they are (a staged install is not readable here yet).
105
+ schemas.delete(optionsKey(plugin));
106
+ debug('Options schema not read for the reloadable marks', {
107
+ plugin: plugin.id,
108
+ error: error instanceof Error ? error.message : String(error),
109
+ });
110
+ }
111
+ }
112
+ // D-0155: the settings sections of the application, from the schema beside each declaration.
113
+ for (const section of settings) {
114
+ try {
115
+ const schema = await readSettingsSchema(root, section);
116
+ if (schema)
117
+ schemas.set(section, schema);
118
+ }
119
+ catch (error) {
120
+ debug('Settings schema not read for the reloadable marks', {
121
+ section,
122
+ error: error instanceof Error ? error.message : String(error),
123
+ });
124
+ }
125
+ }
126
+ return schemas;
127
+ }
128
+ /** The comment of a pair that holds the mark: the value's, or the key line of a block. */
129
+ function hasMark(pair) {
130
+ const key = pair.key;
131
+ const value = pair.value;
132
+ if (key && MARK_TOKEN.test(key.comment ?? ''))
133
+ return true;
134
+ if (value && MARK_TOKEN.test(value.comment ?? ''))
135
+ return true;
136
+ // A key comment of a block collection is read back as the first line before its value.
137
+ const before = value?.commentBefore?.split('\n')[0] ?? '';
138
+ return isCollection(value) && !value.flow && before.trim() === RELOADABLE_MARK;
139
+ }
140
+ function withoutMark(comment) {
141
+ if (!comment)
142
+ return undefined;
143
+ const rest = comment
144
+ .replace(/\s*·\s*reloadable(?=\s|$)/, '')
145
+ .replace(/^\s*reloadable(?=\s|$)\s*(·\s*)?/, ' ')
146
+ .replace(/\s+$/, '');
147
+ return rest.trim() ? rest : undefined;
148
+ }
149
+ function addMark(pair) {
150
+ // A key set as a plain string (document.set) becomes a node that can carry the comment.
151
+ if (!isScalar(pair.key))
152
+ pair.key = new Scalar(pair.key);
153
+ const key = pair.key;
154
+ const value = pair.value;
155
+ if (value && isCollection(value) && !value.flow) {
156
+ // `key: # reloadable` above the block.
157
+ key.comment = ` ${RELOADABLE_MARK}`;
158
+ return;
159
+ }
160
+ const target = value ?? key;
161
+ const existing = target.comment;
162
+ target.comment = existing?.trim() ? `${existing} · ${RELOADABLE_MARK}` : ` ${RELOADABLE_MARK}`;
163
+ }
164
+ /**
165
+ * A mark written on the key line of a block (`targets: # reloadable`) is read back as the first
166
+ * comment line before its value; put it back on the key line so it is rendered there again.
167
+ */
168
+ function normalizeMark(pair) {
169
+ const key = pair.key;
170
+ const value = pair.value;
171
+ if (!key || !value || !isCollection(value) || value.flow || !value.commentBefore)
172
+ return false;
173
+ const lines = value.commentBefore.split('\n');
174
+ if (lines[0]?.trim() !== RELOADABLE_MARK)
175
+ return false;
176
+ lines.shift();
177
+ value.commentBefore = lines.length ? lines.join('\n') : null;
178
+ key.comment = ` ${RELOADABLE_MARK}`;
179
+ return true;
180
+ }
181
+ function removeMark(pair) {
182
+ const key = pair.key;
183
+ const value = pair.value;
184
+ if (key)
185
+ key.comment = withoutMark(key.comment) ?? null;
186
+ if (value)
187
+ value.comment = withoutMark(value.comment) ?? null;
188
+ if (value && isCollection(value) && !value.flow && value.commentBefore) {
189
+ const lines = value.commentBefore.split('\n');
190
+ if (lines[0]?.trim() === RELOADABLE_MARK)
191
+ lines.shift();
192
+ value.commentBefore = lines.length ? lines.join('\n') : null;
193
+ }
194
+ }
195
+ /**
196
+ * Puts `# reloadable` after each topmost reloadable key under `options` and takes stale marks
197
+ * away (an option that is no longer reloadable), leaving every other comment and value as it is.
198
+ * Returns how many marks changed, and how many were moved back onto their key line.
199
+ */
200
+ export function applyReloadableMarks(document, schemas) {
201
+ const options = document.get('options', true);
202
+ if (!isMap(options))
203
+ return { changed: 0, moved: 0 };
204
+ let changed = 0;
205
+ let moved = 0;
206
+ const visit = (node, schema, path, under) => {
207
+ if (isMap(node))
208
+ for (const pair of node.items) {
209
+ const key = isScalar(pair.key) ? pair.key.value : pair.key;
210
+ if (typeof key !== 'string')
211
+ continue;
212
+ const at = [...path, key];
213
+ const reloadable = !under && reloadableAt(schema, at);
214
+ const mark = reloadable;
215
+ if (mark && !hasMark(pair)) {
216
+ addMark(pair);
217
+ changed++;
218
+ }
219
+ else if (!mark && hasMark(pair)) {
220
+ removeMark(pair);
221
+ changed++;
222
+ }
223
+ else if (mark && normalizeMark(pair))
224
+ moved++;
225
+ visit(pair.value, schema, at, under || reloadable);
226
+ }
227
+ else if (isSeq(node))
228
+ node.items.forEach((item, index) => {
229
+ visit(item, schema, [...path, index], under);
230
+ });
231
+ };
232
+ for (const pair of options.items) {
233
+ const section = isScalar(pair.key) ? pair.key.value : undefined;
234
+ // A section whose schema is not known here keeps its marks as they are.
235
+ if (typeof section !== 'string' || !schemas.has(section))
236
+ continue;
237
+ visit(pair.value, schemas.get(section), [], false);
238
+ }
239
+ return { changed, moved };
240
+ }
241
+ /**
242
+ * `source` (dokebi.yaml or dokebi.example.yaml) with the reloadable marks of `schemas`; the same
243
+ * text when no mark changes (nothing is re-rendered then) or when it is not a readable YAML
244
+ * document (the caller reports that).
245
+ */
246
+ export function markReloadable(source, schemas) {
247
+ const document = parseDocument(source, { version: '1.2' });
248
+ if (document.errors.length)
249
+ return source;
250
+ const { changed, moved } = applyReloadableMarks(document, schemas);
251
+ if (!changed && !moved)
252
+ return source;
253
+ if (changed)
254
+ debug('Reloadable options marked in the configuration', { changed });
255
+ // A mark read back before its block goes back on the key line (rendered as it was written).
256
+ return renderConfig(document);
257
+ }