@dokebi/cli 0.4.1 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  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 +527 -35
  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 +2 -0
  46. package/dist/check/shared-state-rules.js +114 -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 +307 -31
  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-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +29 -3
  110. package/dist/install-module.js +142 -13
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +106 -27
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +59 -4
  124. package/dist/npm-client.js +220 -16
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +206 -20
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3016 -336
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +50 -4
  170. package/dist/server.js +790 -103
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +67 -0
  180. package/dist/transaction.js +428 -46
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +113 -4
  184. package/dist/verify.js +326 -28
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
package/dist/project.js CHANGED
@@ -1,36 +1,310 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
3
- import { basename, dirname, join, resolve } from 'node:path';
4
- import { parseEnv } from 'node:util';
2
+ import { copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { RELOAD_OPTIONS_KEY, reloadOptions } from '@dokebi/core';
5
5
  import lt from 'semver/functions/lt.js';
6
6
  import satisfies from 'semver/functions/satisfies.js';
7
+ import validVersion from 'semver/functions/valid.js';
7
8
  import validRange from 'semver/ranges/valid.js';
8
9
  import { parse } from 'yaml';
9
- import { HOST_CAPABILITY, loadCatalog, providersOf } from './catalog.js';
10
- import { applyConfigChanges, applySetOptions, markRemoved, removeAppSettings, setWarnings, } from './config-edit.js';
11
- import { CONFIG_BACKUP, CONFIG_EXAMPLE, CONFIG_FILE, CONFIG_TEMPLATE, ignoreConfig, LEGACY_CONFIG_FILE, migrateConfig, parseProjectConfig, } from './configuration.js';
12
- import { scaffoldConfig } from './configuration-scaffold.js';
13
- import { COMPOSE_FILE, composeProject, composeText, connectionUrl, DB_SERVICE, ENV_FILE, envValue, FALLBACK_PROJECT_NAME, freePortAfter, generatePassword, PASSWORD_VARIABLE, portInUse, portQuestion, redactUrl, URL_PLACEHOLDER_PREFIX, withEnv, } from './dev-infra.js';
10
+ import { apiAppFiles } from './api-app.js';
11
+ import { capabilityBlock, syncCapabilityBlock } from './capability-guide.js';
12
+ import { findPackage, HOST_CAPABILITY, loadCatalog, providersOf, } from './catalog.js';
13
+ import { sampleAdditions } from './config-diff.js';
14
+ import { applyConfigChanges, applySetOptions, applyUnsetOptions, markRemoved, namedItems, optionAtPath, removeAppSettings, } from './config-edit.js';
15
+ import { isPlaceholder, issuePathOf, placeholderPaths, secretInSchema } from './config-init.js';
16
+ import { filePaths, outsideRequested, requestedPath } from './config-paths.js';
17
+ import { commandLineSecret } from './config-show.js';
18
+ import { addLoggingTargets, CONFIG_BACKUP, CONFIG_EXAMPLE, CONFIG_FILE, CONFIG_TEMPLATE, configOutsideProject, configTarget, FRESH_COPY_FIX, ignoreConfig, LEGACY_CONFIG_FILE, migrateConfig, parseProjectConfig, readProjectSettings, } from './configuration.js';
19
+ import { aliasConflicts, followDefaults, pruneUndeclared, scaffoldConfig, } from './configuration-scaffold.js';
20
+ import { DEPLOYMENT_FILES } from './deployment.js';
21
+ import { connectionSecrets } from './dev-compose.js';
22
+ import { AUTO_DOMAIN, devHost, projectHostLabel } from './dev-host.js';
23
+ import { COMPOSE_FILE, composeProject, composePublishedPort, composeText, composeUnsetVariables, composeVariablesHint, connectionUrl, DB_SERVICE, ENV_FILE, envKeys, envValue, FALLBACK_PROJECT_NAME, freePortAfter, generatePassword, PASSWORD_VARIABLE, portInUse, portQuestion, redactUrl, replicaSetOf, runsReplicaSet, URL_PLACEHOLDER_PREFIX, withComposePort, withEnv, } from './dev-infra.js';
14
24
  import { debug, describeError, isMissing } from './diagnostics.js';
15
- import { checkEnvText } from './env-file.js';
25
+ import { envFileWarnings, parseEnvText } from './env-file.js';
16
26
  import { CliError } from './errors.js';
17
27
  import { markExampleRemoved, syncExample } from './example-config.js';
18
28
  import { exists, fingerprint, hash, json, object, parseObject, securePath, templateTarget, } from './filesystem.js';
19
- import { generateRegistration, LEGACY_MAIN_SOURCE, MAIN_SOURCE, MAIN_SOURCE_V1, PROJECT_DEV_DEPENDENCIES, TSCONFIG_SOURCE, TYPECHECK_SCRIPT, } from './generator.js';
20
- import { findHost, HOST_ROLES, orderPlugins, PACKAGE_NAME, readManifest, } from './manifest.js';
29
+ import { adminSections } from './first-admin.js';
30
+ import { biomeConfigText, biomeMajor, formatterFix, formatterUses, GENERATED_FILE, migrationsFolder, PRETTIER_IGNORE, prettierIgnoreText, } from './formatter-ignore.js';
31
+ import { generateRegistration, LEGACY_MAIN_SOURCE, MAIN_SOURCE, MAIN_SOURCE_V1, MAIN_SOURCE_V2, MAIN_SOURCE_V3, MAIN_SOURCE_V4, MAIN_SOURCE_V5, PROJECT_DEV_DEPENDENCIES, TEST_SCRIPT, TSCONFIG_SOURCE, TYPECHECK_SCRIPT, } from './generator.js';
32
+ import { findHost, HOST_ROLES, missingDependency, optionsKey, orderPlugins, PACKAGE_NAME, readManifest, } from './manifest.js';
21
33
  import { mergeInstallDefaults } from './merge-install-defaults.js';
22
- import { dependencyMap, NpmClient, packageSpec, stageNpmConfig, validateProjectPackage, } from './npm-client.js';
34
+ import { dependencyMap, NpmClient, packageSpec, projectTarball, stageNpmConfig, validateProjectPackage, } from './npm-client.js';
35
+ import { optionsFix, unknownOptionPaths } from './option-fixes.js';
23
36
  import { CLI_VERSION } from './output.js';
24
- import { checkAll, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
37
+ import { checkAll, checkPluginsOptions, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
25
38
  import { PluginOptionsValidator, validatePluginOptions } from './plugin-options.js';
26
- import { AGENTS_FILE, CLAUDE_FILE, CLAUDE_GUIDE, guideEntries, installedBlock, projectGuide, replaceInstalledBlock, } from './project-guide.js';
39
+ import { AGENTS_FILE, CLAUDE_FILE, CLAUDE_GUIDE, guideEntries, installedBlock, projectBlockState, projectGuide, replaceInstalledBlock, withProjectBlock, } from './project-guide.js';
27
40
  import { APP_NAME, defaultInstallation, emptyState, readState, } from './project-state.js';
28
- import { resolveVersion } from './registry.js';
41
+ import { companionVersions, conflictFix, describeConflicts, fetchPackument, missingOnRegistry, resolveVersion, versionConflicts, } from './registry.js';
42
+ import { markReloadable, reloadSchemas } from './reloadable.js';
29
43
  import { readRequiredPlugins } from './required-plugins.js';
30
- import { Transaction } from './transaction.js';
44
+ import { CONVENTION_SECTIONS, catalogPackageOf, RESERVED_SECTIONS, renderSettingsSchema, renderSettingsSchemaFrom, SETTINGS_FOLDER, SETTINGS_SECTION, settingsFile, settingsIdentifier, settingsSchemaFile, settingsSource, undeclaredSection, undeclaredSectionError, } from './settings.js';
45
+ import { templateFixes } from './template-fixes.js';
46
+ import { Interruption, interruptedError, Transaction } from './transaction.js';
47
+ import { appViewValue, NO_VIEW, projectView } from './view-choice.js';
31
48
  import { installedPath, packageFile, workspacePackages, workspaceRootOf, } from './workspace.js';
32
49
  const GENERATED = 'src/dokebi.generated.ts';
33
50
  const EXAMPLE = 'dokebi.example.yaml';
51
+ /** Previous secrets `config secret --rotate` keeps after the new one in a list (AI-M05). */
52
+ /** An option key that holds an address (`url`, `uri`, `databaseUrl`, `endpointURI`). */
53
+ const URL_KEY = /^(url|uri)$|(Url|URL|Uri|URI)$/;
54
+ export const ROTATE_KEEP = 2;
55
+ /** The generated registration differs from what the CLI wrote (D-0116: a sentence and a fix). */
56
+ function modifiedRegistration() {
57
+ return new CliError('MODIFIED_REGISTRATION', `${GENERATED} was edited by hand; the CLI writes it and does not change a project whose registration differs`, {
58
+ details: {
59
+ path: GENERATED,
60
+ fix: `undo the edits to ${GENERATED} (for example from Git) and put your own code in src/main.ts or a plugin entry file, then repeat this command`,
61
+ },
62
+ phase: 'plan',
63
+ });
64
+ }
65
+ /**
66
+ * PACKAGE_CONFLICT (D-0116): package.json already depends on the npm package of a plugin the
67
+ * project has not registered, so the CLI does not take it over silently.
68
+ */
69
+ function packageConflict(name, range, requiredBy) {
70
+ return new CliError('PACKAGE_CONFLICT', `package.json already depends on ${name} (${range}), but it is not a registered plugin of this project${requiredBy ? `; ${requiredBy} requires it` : ''}`, {
71
+ details: {
72
+ package: name,
73
+ range,
74
+ ...(requiredBy ? { requiredBy } : {}),
75
+ fix: `npm uninstall ${name}, then npx dokebi add ${name} (the CLI installs and registers it in one transaction)`,
76
+ },
77
+ phase: 'plan',
78
+ });
79
+ }
80
+ /** REVERSE_DEPENDENCY (D-0116): `dependents` still require the plugin `remove` was asked for. */
81
+ function reverseDependency(id, dependents) {
82
+ return new CliError('REVERSE_DEPENDENCY', `${id} cannot be removed: ${dependents.join(', ')} ${dependents.length === 1 ? 'requires' : 'require'} it`, {
83
+ details: {
84
+ plugin: dependents[0],
85
+ dependents,
86
+ requires: [id],
87
+ fix: `first run ${dependents.map((item) => `npx dokebi remove ${item}`).join(', ')}, or keep ${id}`,
88
+ },
89
+ phase: 'plan',
90
+ });
91
+ }
92
+ /**
93
+ * MANIFEST_CHANGED during a change (D-0116): npm put another manifest of a registered plugin into
94
+ * the staged install than the one the project records.
95
+ */
96
+ function stagedManifestChanged(plugin, actual) {
97
+ return new CliError('MANIFEST_CHANGED', `npm installed ${plugin.packageName} ${actual.version} for this change, but the project records ${plugin.id} ${plugin.version} with another manifest`, {
98
+ details: {
99
+ plugin: plugin.id,
100
+ recorded: plugin.version,
101
+ staged: actual.version,
102
+ fix: `npx dokebi upgrade ${plugin.id} first (or pin ${plugin.packageName} to ${plugin.version} in package.json), then repeat this command`,
103
+ },
104
+ phase: 'plan',
105
+ });
106
+ }
107
+ /** INVALID_PLUGIN_OPTIONS (D-0116): options of a plugin break its schema (not only missing). */
108
+ function invalidPluginOptions(id, errors, phase,
109
+ /** The section of dokebi.yaml (options.logging for the logger host); `id` names the plugin. */
110
+ section = id,
111
+ /** D-0162: `rules` for the plugin's own checks (the schema passed); their message is said. */
112
+ broken = 'schema',
113
+ /** The options schema: the fix says what the refused field expects (community request D3). */
114
+ schema) {
115
+ const first = errors[0];
116
+ const where = first
117
+ ? `options.${section}${first.instancePath.replaceAll('/', '.')} (${broken === 'rules' && first.message ? first.message : first.keyword})`
118
+ : `options.${section}`;
119
+ return new CliError('INVALID_PLUGIN_OPTIONS', `The options of ${id} do not match its ${broken === 'rules' ? 'rules (the app refuses them at start)' : 'schema'}: ${where}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}`, {
120
+ details: {
121
+ plugin: id,
122
+ errors: errors.slice(0, 20),
123
+ errorCount: errors.length,
124
+ // D-0153: unknown keys get `config unset`, moved ones where they went.
125
+ fix: optionsFix(id, errors, `correct options.${section} in dokebi.yaml (npx dokebi config set ${section}.<path>=<value>; npx dokebi plugins info ${id} --json lists the option keys)`, section, schema),
126
+ },
127
+ phase,
128
+ });
129
+ }
130
+ /**
131
+ * INVALID_PLUGIN_OPTIONS for an old option name set together with the field it now sets (D-0071):
132
+ * the schema takes both, the plugin's options parser refuses them at start (`conflict`).
133
+ */
134
+ function aliasConflictError(id, section, conflicts) {
135
+ const [first] = conflicts;
136
+ return new CliError('INVALID_PLUGIN_OPTIONS', `options.${section}.${first?.alias} is the old name of ${first?.field}; ${id} refuses both set at start${conflicts.length > 1 ? ` (and ${conflicts.length - 1} more)` : ''}`, {
137
+ details: {
138
+ plugin: id,
139
+ errors: conflicts.map((conflict) => ({
140
+ instancePath: `/${conflict.alias.replaceAll('.', '/')}`,
141
+ keyword: 'conflict',
142
+ field: `${section}.${conflict.field}`,
143
+ })),
144
+ errorCount: conflicts.length,
145
+ fix: `keep one name: npx dokebi config unset ${conflicts.map((conflict) => `${section}.${conflict.alias}`).join(' ')} (the old name; its value then comes from the new one, so set that first if it should stay)`,
146
+ },
147
+ phase: 'arguments',
148
+ });
149
+ }
150
+ /**
151
+ * AI-M49 D2: `settings sync` with options the new schema refuses (a value set by hand above a
152
+ * lowered maximum): the code doctor gives them, with the paths and a fix that works before the
153
+ * sync (config set checks the old schema until then); nothing is written.
154
+ */
155
+ function settingsStillInvalid(section, errors, configFile) {
156
+ const paths = [
157
+ ...new Set(errors.map((error) => [section, ...error.instancePath.split('/').slice(1), error.additionalProperty ?? '']
158
+ .filter(Boolean)
159
+ .join('.'))),
160
+ ];
161
+ const first = errors[0];
162
+ return new CliError('INVALID_PLUGIN_OPTIONS', `The new declaration of ${section} refuses what ${configFile} holds: options.${paths[0] ?? section}${first ? ` (${first.keyword})` : ''}${paths.length > 1 ? ` (and ${paths.length - 1} more)` : ''}; nothing was written`, {
163
+ details: {
164
+ plugin: section,
165
+ errors: errors.slice(0, 20),
166
+ errorCount: errors.length,
167
+ paths,
168
+ fix: `bring each value within the new declaration: npx dokebi config unset ${paths.join(' ')} (the new default then applies), or correct it in ${configFile} by hand; then npx dokebi settings sync again`,
169
+ },
170
+ phase: 'plan',
171
+ });
172
+ }
173
+ /** UNKNOWN_APP (D-0116): the app command names an app the project does not register. */
174
+ function unknownApp(name, apps) {
175
+ return new CliError('UNKNOWN_APP', `The project has no app named ${name}`, {
176
+ details: {
177
+ app: name,
178
+ apps,
179
+ fix: apps.length
180
+ ? `use one of ${apps.join(', ')}, or create it with npx dokebi app add ${name} --domain auto`
181
+ : `create it with npx dokebi app add ${name} --domain auto`,
182
+ },
183
+ phase: 'arguments',
184
+ });
185
+ }
186
+ /** DOMAIN_IN_USE (D-0116): another app already serves the domain. */
187
+ function domainInUse(domain, app) {
188
+ return new CliError('DOMAIN_IN_USE', `The domain ${domain} already belongs to the app ${app}`, {
189
+ details: {
190
+ domain,
191
+ app,
192
+ fix: `choose another domain, or first npx dokebi app unlink ${domain}`,
193
+ },
194
+ phase: 'arguments',
195
+ });
196
+ }
197
+ /** A path of the change that holds something else (D-0116: a sentence, details and a fix). */
198
+ function fileConflict(path, why, fix, extra = {}) {
199
+ return new CliError('FILE_CONFLICT', `${path} ${why}`, {
200
+ details: { path, ...extra, fix },
201
+ phase: 'plan',
202
+ });
203
+ }
204
+ /** The plugin among `plugins` that declares `id` as a required dependency (D-0116). */
205
+ function requirerOf(plugins, id) {
206
+ const found = plugins.find((plugin) => plugin.dependencies.some((dependency) => dependency.name === id && !dependency.optional));
207
+ if (!found)
208
+ throw new Error(`No plugin of the change requires ${id}`);
209
+ return found;
210
+ }
211
+ /** The dependency entry by which a plugin of `plugins` requires `id` (D-0116). */
212
+ function declaredDependency(plugins, id) {
213
+ return requirerOf(plugins, id).dependencies.find((dependency) => dependency.name === id && !dependency.optional);
214
+ }
215
+ /** The value at `options.<id>.<path…>` of a dokebi.yaml source, or undefined. */
216
+ /** Levels core reads in options.logging (D-0047): `level` and the values of `plugins`. */
217
+ const LOGGING_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
218
+ /**
219
+ * The sections something reads — doctor's rule: core's (logging, reload), the conventions
220
+ * (application-runtime, …), a first admin's section, each installed plugin's section, the
221
+ * required plugins and the application's settings. A plugin id that is not its section (the
222
+ * logger's old dokebi-logger) is not read.
223
+ */
224
+ function readSections(state, options) {
225
+ return new Set([
226
+ 'logging',
227
+ RELOAD_OPTIONS_KEY,
228
+ ...CONVENTION_SECTIONS,
229
+ ...options.flatMap((item) => adminSections(item)),
230
+ ...state.plugins.map((plugin) => optionsKey(plugin)),
231
+ ...(state.settings ?? []),
232
+ ]);
233
+ }
234
+ /**
235
+ * The installed plugin whose options section `section` names: `options.<id>`, or
236
+ * `options.logging` for the logger host (D-0151 follow-up: `config set logging.level=debug`,
237
+ * `config secret logging.targets.studio.token --stdin`).
238
+ */
239
+ function sectionPlugin(plugins, section) {
240
+ return (plugins.find((item) => item.id === section) ??
241
+ plugins.find((item) => optionsKey(item) === section));
242
+ }
243
+ /** The section of dokebi.yaml of a plugin (options.logging for the logger host) or settings. */
244
+ function targetSection(target) {
245
+ return 'settingsSection' in target ? target.settingsSection : optionsKey(target);
246
+ }
247
+ /** Checks a section against the plugin's schema, or the settings schema file (D-0155). */
248
+ function checkTarget(validator, root, target, options) {
249
+ return 'settingsSection' in target
250
+ ? validator.checkSettings(root, target.settingsSection, options)
251
+ : validator.check(root, target, options);
252
+ }
253
+ /** options.reload (D-0154): core's declaration decides; a refused value names the paths only. */
254
+ function checkCoreReload(section) {
255
+ const checked = reloadOptions.check(section);
256
+ if (checked.ok)
257
+ return;
258
+ const paths = checked.issues.map((issue) => `reload.${issue.path}`);
259
+ throw new CliError('INVALID_SET', `${paths.join(', ')} is not a valid setting of options.reload`, {
260
+ details: {
261
+ paths,
262
+ issues: checked.issues.map((issue) => ({
263
+ path: `reload.${issue.path}`,
264
+ message: issue.message,
265
+ })),
266
+ allowed: 'reload.watch and reload.signal (true or false), reload.debounceMs (50–60000), reload.prepareTimeoutMs (1000–600000), reload.watchRetryMs (1000–3600000)',
267
+ },
268
+ phase: 'arguments',
269
+ });
270
+ }
271
+ /**
272
+ * options.logging without a logger plugin: core reads `level` and `plugins` only (D-0047), so
273
+ * `config set` takes those with core's levels and refuses the rest with the command that adds
274
+ * the logger.
275
+ */
276
+ function checkCoreLogging(sets) {
277
+ const level = (value) => typeof value === 'string' && LOGGING_LEVELS.includes(value);
278
+ for (const set of sets) {
279
+ const [first, name, ...rest] = set.path;
280
+ const valid = (first === 'level' && name === undefined && level(set.value)) ||
281
+ (first === 'plugins' &&
282
+ ((name === undefined &&
283
+ set.value !== null &&
284
+ typeof set.value === 'object' &&
285
+ !Array.isArray(set.value) &&
286
+ Object.values(set.value).every(level)) ||
287
+ (name !== undefined && !rest.length && level(set.value))));
288
+ if (!valid)
289
+ throw new CliError('INVALID_SET', `logging.${set.path.join('.')} is not a setting of options.logging without @dokebi/logger`, {
290
+ details: {
291
+ path: `logging.${set.path.join('.')}`,
292
+ allowed: `logging.level and logging.plugins.<plugin> (${LOGGING_LEVELS.join(', ')})`,
293
+ fix: `npx dokebi add ${LOGGER_PACKAGE} adds the logger whose options (targets, redact, …) live in options.logging too`,
294
+ },
295
+ phase: 'arguments',
296
+ });
297
+ }
298
+ }
299
+ function optionAt(source, id, path) {
300
+ let current = parseProjectConfig(source).options[id];
301
+ for (const key of path)
302
+ current =
303
+ current && typeof current === 'object' && Object.hasOwn(current, key)
304
+ ? current[key]
305
+ : undefined;
306
+ return current;
307
+ }
34
308
  /**
35
309
  * Follow-ups after init (D-0065): read the guide, pick a recipe and ask its questions, find
36
310
  * plugins, read the core contract, and verify before saying done.
@@ -53,9 +327,14 @@ export function initNextSteps(target) {
53
327
  command: 'npx dokebi plugins search <words> --json',
54
328
  why: 'outside a recipe: find a plugin; `absent` entries are features Dokebi does not have (do not invent them)',
55
329
  },
330
+ {
331
+ // AI-M04 F7: the exact path (agents guessed `plugins doc dokebi-core`).
332
+ command: 'read node_modules/@dokebi/core/AI.md',
333
+ why: 'the core guide before writing app code: entry points, lifecycle, configuration, logging (the same text: npx dokebi plugins doc @dokebi/core --json)',
334
+ },
56
335
  {
57
336
  command: 'npx dokebi verify --json',
58
- why: 'doctor, typecheck, build, start, HTTP checks and stop; say done only when ok is true',
337
+ why: "doctor, check, typecheck, build, the project's tests, start, HTTP checks and stop; say done only when ok is true",
59
338
  },
60
339
  ];
61
340
  }
@@ -116,21 +395,165 @@ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDepende
116
395
  function union(...lists) {
117
396
  return [...new Set(lists.flat())];
118
397
  }
398
+ /** D-0151: the app logger every new project gets. */
399
+ export const LOGGER_PACKAGE = '@dokebi/logger';
400
+ /**
401
+ * R4CA-3: what the project's .env sets that the app does not get from it (DOKEBI_CONFIG), as the
402
+ * warnings of the config commands: they work on the file the environment names.
403
+ */
404
+ function envWarnings(root) {
405
+ return envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint }));
406
+ }
407
+ /**
408
+ * A `file:<absolute>.tgz` spec of a tarball inside the project (`dokebi add ./vendor/x.tgz`,
409
+ * `upgrade --to vendor/x.tgz`) as the project-relative `file:vendor/x.tgz`, so npm writes it
410
+ * relative into package.json and package-lock.json like the tarballs the project lists (it was
411
+ * written absolute, R5C-3); any other spec as it is. Links are resolved first, and the relative
412
+ * path has none, so the staging copy (securePath) takes it; a tarball outside stays absolute.
413
+ */
414
+ async function projectSpec(root, spec) {
415
+ if (!spec.startsWith('file:') || !spec.endsWith('.tgz'))
416
+ return spec;
417
+ const file = spec.slice(5);
418
+ // A missing tarball is npm's error to give, as before.
419
+ if (!(await exists(file)))
420
+ return spec;
421
+ const real = await realpath(file);
422
+ const path = projectTarball(`file:${relative(await realpath(root), real)
423
+ .split(sep)
424
+ .join('/')}`);
425
+ if (path === undefined)
426
+ return spec;
427
+ debug('A tarball inside the project is installed by its relative path', { path });
428
+ return `file:${path}`;
429
+ }
430
+ /**
431
+ * A command run outside a Dokebi project (no .dokebi/state.json or dokebi.yaml) is NOT_A_PROJECT
432
+ * with the file and a fix, never an INTERNAL_ERROR with a bare ENOENT (R1: add, upgrade and remove
433
+ * in the wrong folder).
434
+ */
435
+ function notAProject(root, file) {
436
+ return new CliError('NOT_A_PROJECT', `No ${file}: run this in a Dokebi project (the folder with package.json, dokebi.yaml and .dokebi/state.json)`, {
437
+ phase: 'arguments',
438
+ details: {
439
+ root,
440
+ file,
441
+ fix: file === CONFIG_FILE
442
+ ? FRESH_COPY_FIX
443
+ : 'run the command in the project folder, or make a project first with npx -y @dokebi/cli init <name>; .dokebi/state.json is written by the CLI and belongs in version control',
444
+ },
445
+ });
446
+ }
447
+ /** The text of .dokebi/state.json; a missing file is NOT_A_PROJECT. */
448
+ async function projectStateText(root) {
449
+ const path = await securePath(root, '.dokebi/state.json');
450
+ try {
451
+ return await readFile(path, 'utf8');
452
+ }
453
+ catch (error) {
454
+ if (isMissing(error))
455
+ throw notAProject(root, '.dokebi/state.json');
456
+ throw new CliError('READ_FAILED', 'Cannot read .dokebi/state.json', { cause: error });
457
+ }
458
+ }
119
459
  export class Project {
120
460
  npm;
121
461
  constructor(npm = new NpmClient()) {
122
462
  this.npm = npm;
123
463
  }
124
- async init(target, core = '@dokebi/core@^0.4.0', dryRun = false) {
464
+ /**
465
+ * A new project (D-0065). D-0151: every new project gets @dokebi/logger, added by `add` once
466
+ * the project exists (stdout JSON, pretty output in dokebi dev, a file target to turn on for
467
+ * production). When that install fails (offline, a registry without it) the project stays on
468
+ * core's console logger and the result says so (warning LOGGER_NOT_INSTALLED and the command).
469
+ */
470
+ async init(target, core = '@dokebi/core@^0.4.0', dryRun = false, deploy = false, logger = true) {
471
+ // R4CB-3: a termination signal before the project is in place cancels init (INTERRUPTED,
472
+ // the staging folder removed); one that came while it was moved in skips the logger.
473
+ const interruption = dryRun ? undefined : Interruption.open();
474
+ let created;
475
+ try {
476
+ created = await this.#init(target, core, dryRun, deploy, interruption);
477
+ }
478
+ finally {
479
+ interruption?.close();
480
+ }
481
+ if (dryRun || !logger)
482
+ return created;
483
+ if (interruption?.interrupted)
484
+ return {
485
+ ...created,
486
+ warnings: [
487
+ ...(created.warnings ?? []),
488
+ {
489
+ code: 'LOGGER_NOT_INSTALLED',
490
+ path: LOGGER_PACKAGE,
491
+ hint: `INTERRUPTED: npx dokebi add ${LOGGER_PACKAGE} installs it later`,
492
+ },
493
+ ],
494
+ nextSteps: [
495
+ {
496
+ command: `npx dokebi add ${LOGGER_PACKAGE}`,
497
+ why: 'install the default app logger that init skipped after the termination signal',
498
+ },
499
+ ...created.nextSteps,
500
+ ],
501
+ };
502
+ try {
503
+ const added = await this.add(resolve(target), LOGGER_PACKAGE);
504
+ debug('Added the default logger to the new project', {
505
+ target,
506
+ changed: added.changed.length,
507
+ resolved: added.resolved?.version ?? null,
508
+ });
509
+ return added.warnings?.length
510
+ ? { ...created, warnings: [...(created.warnings ?? []), ...added.warnings] }
511
+ : created;
512
+ }
513
+ catch (error) {
514
+ // The project exists and runs with core's console logger; the caller sees why and how.
515
+ debug('The default logger could not be added; the project keeps the console logger', {
516
+ target,
517
+ error: describeError(error),
518
+ });
519
+ return {
520
+ ...created,
521
+ warnings: [
522
+ ...(created.warnings ?? []),
523
+ {
524
+ code: 'LOGGER_NOT_INSTALLED',
525
+ path: LOGGER_PACKAGE,
526
+ hint: `${error instanceof CliError ? error.code : 'ADD_FAILED'}: npx dokebi add ${LOGGER_PACKAGE} installs it later`,
527
+ },
528
+ ],
529
+ nextSteps: [
530
+ {
531
+ command: `npx dokebi add ${LOGGER_PACKAGE}`,
532
+ why: 'install the default app logger (rotating files, redaction, pretty dev output) that init could not add',
533
+ },
534
+ ...created.nextSteps,
535
+ ],
536
+ };
537
+ }
538
+ }
539
+ async #init(target, core, dryRun, deploy, interruption) {
125
540
  target = resolve(target);
126
541
  // D-0065 §11: an existing folder is accepted when it is empty or holds only entries that
127
542
  // are not part of a Node project (INIT_HARMLESS); anything else is PROJECT_EXISTS.
128
543
  const folder = await initFolder(target);
544
+ const deployment = deploy ? { files: Object.keys(DEPLOYMENT_FILES) } : undefined;
545
+ const nextSteps = initNextSteps(target);
546
+ if (deploy)
547
+ nextSteps.push({
548
+ command: 'read DEPLOY.md',
549
+ why: 'configure deployment, runtime credentials and infrastructure before building or starting containers',
550
+ });
129
551
  if (dryRun)
130
552
  return {
131
553
  changed: [target],
132
554
  preserved: folder.kept.map((name) => join(target, name)),
133
- nextSteps: initNextSteps(target),
555
+ nextSteps,
556
+ ...(deployment ? { deployment } : {}),
134
557
  };
135
558
  // A new folder is staged next to it and renamed into place (atomic). An existing folder is
136
559
  // staged inside itself (an agent sandbox may allow writes only there), then moved in.
@@ -142,6 +565,7 @@ export class Project {
142
565
  if (folder.kept.includes('.npmrc'))
143
566
  await stageNpmConfig(target, staging);
144
567
  await mkdir(join(staging, 'src'));
568
+ await mkdir(join(staging, 'tests'));
145
569
  await mkdir(join(staging, '.dokebi'));
146
570
  await writeFile(join(staging, 'package.json'), json({
147
571
  name: basename(target)
@@ -149,7 +573,7 @@ export class Project {
149
573
  .replace(/[^a-z0-9-]/g, '-') || FALLBACK_PROJECT_NAME,
150
574
  private: true,
151
575
  type: 'module',
152
- scripts: { start: 'node src/main.ts', typecheck: TYPECHECK_SCRIPT },
576
+ scripts: { start: 'node src/main.ts', typecheck: TYPECHECK_SCRIPT, test: TEST_SCRIPT },
153
577
  engines: { node: '>=24.0.0' },
154
578
  // npm installs these together with core (one npm run); --save-exact keeps them pinned.
155
579
  devDependencies: PROJECT_DEV_DEPENDENCIES,
@@ -160,20 +584,30 @@ export class Project {
160
584
  await writeFile(join(staging, GENERATED), generated);
161
585
  await writeFile(join(staging, 'src/main.ts'), MAIN_SOURCE);
162
586
  const requiredPlugins = await readRequiredPlugins(staging);
163
- await writeFile(join(staging, AGENTS_FILE), projectGuide(guideEntries(await this.coreVersion(staging), requiredPlugins, [], CLI_VERSION)));
587
+ await writeFile(join(staging, AGENTS_FILE), projectGuide(guideEntries(await this.coreVersion(staging), requiredPlugins, [], CLI_VERSION), await loadCatalog()));
164
588
  await writeFile(join(staging, CLAUDE_FILE), CLAUDE_GUIDE);
165
589
  await writeFile(join(staging, 'tsconfig.json'), TSCONFIG_SOURCE);
166
590
  await writeFile(join(staging, CONFIG_FILE), CONFIG_TEMPLATE, { mode: 0o600 });
167
591
  await writeFile(join(staging, EXAMPLE), CONFIG_EXAMPLE);
168
592
  await writeFile(join(staging, '.dokebi/state.json'), json(emptyState(hash(generated))));
169
- await writeFile(join(staging, '.gitignore'), ignoreConfig('node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n'));
593
+ await writeFile(join(staging, '.gitignore'),
594
+ // `*.mmdb`: IP geolocation databases (@dokebi/geoip) are licensed data, never committed.
595
+ ignoreConfig('node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n*.mmdb\n'));
596
+ if (deploy) {
597
+ debug('Writing deployment skeleton', { files: Object.keys(DEPLOYMENT_FILES) });
598
+ for (const [file, content] of Object.entries(DEPLOYMENT_FILES))
599
+ await writeFile(join(staging, file), content);
600
+ }
601
+ // The move below makes the project visible: a signal before it cancels init.
602
+ interruption?.check();
170
603
  if (folder.exists) {
171
604
  await this.#moveInto(staging, target, folder.kept);
172
605
  debug('Initialized the project in an existing folder', { target, kept: folder.kept });
173
606
  return {
174
607
  changed: [target],
175
608
  preserved: folder.kept.map((name) => join(target, name)),
176
- nextSteps: initNextSteps(target),
609
+ nextSteps,
610
+ ...(deployment ? { deployment } : {}),
177
611
  };
178
612
  }
179
613
  // Reserve the destination before moving anything so existing projects are never replaced.
@@ -190,7 +624,11 @@ export class Project {
190
624
  }
191
625
  throw cause;
192
626
  }
193
- return { changed: [target], preserved: [], nextSteps: initNextSteps(target) };
627
+ return { changed: [target], preserved: [], nextSteps, ...(deployment ? { deployment } : {}) };
628
+ }
629
+ catch (error) {
630
+ // npm got the signal from its forwarder and failed (or check() above): nothing is in place.
631
+ throw interruption ? interruption.failure(error) : error;
194
632
  }
195
633
  finally {
196
634
  await rm(staging, { recursive: true, force: true });
@@ -229,7 +667,7 @@ export class Project {
229
667
  }
230
668
  async migrate(root, dryRun = false) {
231
669
  root = await realpath(root);
232
- await readFile(await securePath(root, '.dokebi/state.json'));
670
+ await projectStateText(root);
233
671
  const tx = await Transaction.begin(root);
234
672
  try {
235
673
  for (const path of [CONFIG_FILE, CONFIG_BACKUP, EXAMPLE])
@@ -240,9 +678,15 @@ export class Project {
240
678
  throw new CliError('BACKUP_EXISTS', 'Preserve the existing legacy configuration backup before migrating');
241
679
  const state = readState(parseObject(await tx.read('.dokebi/state.json'), 'state'));
242
680
  if (hash(await tx.read(GENERATED)) !== state.generatedHash)
243
- throw new CliError('MODIFIED_REGISTRATION', GENERATED);
681
+ throw modifiedRegistration();
244
682
  const main = await tx.read('src/main.ts');
245
- if (main !== LEGACY_MAIN_SOURCE && main !== MAIN_SOURCE_V1 && main !== MAIN_SOURCE)
683
+ if (main !== LEGACY_MAIN_SOURCE &&
684
+ main !== MAIN_SOURCE_V1 &&
685
+ main !== MAIN_SOURCE_V2 &&
686
+ main !== MAIN_SOURCE_V3 &&
687
+ main !== MAIN_SOURCE_V4 &&
688
+ main !== MAIN_SOURCE_V5 &&
689
+ main !== MAIN_SOURCE)
246
690
  throw new CliError('MIGRATION_MAIN_REQUIRED', 'Automatic migration supports generated entry points only; migrate custom src/main.ts and configuration manually using the documented flow');
247
691
  const original = await tx.read(LEGACY_CONFIG_FILE);
248
692
  const source = migrateConfig(original);
@@ -252,7 +696,7 @@ export class Project {
252
696
  await validatePluginOptions(root, orderPlugins(state.plugins, version, required), config.options);
253
697
  await tx.write(CONFIG_BACKUP, original);
254
698
  await tx.replace(LEGACY_CONFIG_FILE);
255
- await this.writeConfiguration(tx, state, source);
699
+ await this.writeConfiguration(tx, state, source, CONFIG_FILE);
256
700
  if (main === LEGACY_MAIN_SOURCE)
257
701
  await tx.write('src/main.ts', MAIN_SOURCE);
258
702
  if (!(await exists(await securePath(root, EXAMPLE))))
@@ -265,6 +709,10 @@ export class Project {
265
709
  await tx.commit();
266
710
  return result;
267
711
  }
712
+ catch (error) {
713
+ // R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
714
+ throw tx.failure(error);
715
+ }
268
716
  finally {
269
717
  await tx.close();
270
718
  }
@@ -274,15 +722,17 @@ export class Project {
274
722
  const session = await this.#begin(root);
275
723
  const { tx, state } = session;
276
724
  try {
277
- const warnings = setWarnings(sets);
725
+ if (!session.workspace)
726
+ operation = await projectSpec(session.root, operation);
727
+ const warnings = [];
278
728
  // No version: the highest one this project's core supports (D-0065).
279
- const resolution = await this.#resolve(session, operation, warnings);
729
+ const { resolution, companions } = await this.#resolve(session, operation, warnings);
280
730
  if (resolution)
281
731
  operation = `${resolution.package}@${resolution.version}`;
282
732
  const dependencies = dependencyMap(session.pkg);
283
733
  let staging;
284
734
  try {
285
- staging = await this.#stage(session, session.pkg, dependencies, operation);
735
+ staging = await this.#stage(session, session.pkg, dependencies, operation, companions);
286
736
  }
287
737
  catch (error) {
288
738
  // Offline: say the version came from the snapshot (CATALOG_OFFLINE) next to npm's error.
@@ -312,8 +762,20 @@ export class Project {
312
762
  const added = await this.#readPackageManifest(staging, name);
313
763
  if (session.required.some((plugin) => plugin.id === added.id || plugin.packageName === added.packageName))
314
764
  throw new CliError('REQUIRED_PLUGIN', 'Core already supplies this required plugin');
765
+ // D-0155: the section of the plugin may not be an application settings section.
766
+ if ((state.settings ?? []).includes(optionsKey(added)))
767
+ throw new CliError('SETTINGS_SECTION_CONFLICT', `options.${optionsKey(added)} is the settings section of the application; ${added.packageName} reads the same section`, {
768
+ details: {
769
+ section: optionsKey(added),
770
+ plugin: added.id,
771
+ fix: `give the application settings another section (rename src/shared/settings/${optionsKey(added)}.ts, its defineSettings section and options.${optionsKey(added)}; npx dokebi settings remove ${optionsKey(added)}, then settings add <new>), then add the plugin`,
772
+ },
773
+ phase: 'plan',
774
+ });
315
775
  if (options.adapter !== undefined && added.id !== 'dokebi-db')
316
776
  throw new CliError('USAGE', '--adapter applies to @dokebi/db only');
777
+ if (options.startup !== undefined && options.adapter === undefined)
778
+ throw new CliError('USAGE', '--startup applies to @dokebi/db with --adapter only');
317
779
  const hostsApps = added.hosts?.includes('apps') === true;
318
780
  if (options.firstApp === false && !hostsApps)
319
781
  throw new CliError('USAGE', '--no-first-app applies only to the plugin that hosts apps (dokebi.hosts: ["apps"])');
@@ -322,6 +784,20 @@ export class Project {
322
784
  if (existing.id === added.id &&
323
785
  existing.packageName === added.packageName &&
324
786
  existing.version === added.version) {
787
+ // R4CB-5: the plugin is installed, so add writes no options: --set was dropped and the
788
+ // result said nothing. config set is the command that changes an installed plugin's.
789
+ if (sets.length) {
790
+ const paths = sets.map((set) => `${optionsKey(added)}.${set.path.join('.')}`);
791
+ debug('add --set refused: the plugin is installed', { plugin: added.id, paths });
792
+ throw new CliError('INVALID_SET', `${added.id} ${existing.version} is installed: add does not change its options, so --set would be dropped`, {
793
+ details: {
794
+ plugin: added.id,
795
+ paths,
796
+ fix: `npx dokebi config set ${paths.map((path) => `${path}=<value>`).join(' ')} (secrets: npx dokebi config secret <path>)`,
797
+ },
798
+ phase: 'arguments',
799
+ });
800
+ }
325
801
  if (options.adapter === undefined)
326
802
  return { changed: [], preserved: [] };
327
803
  // An installed db gets its adapter now (adapter file, connection, driver).
@@ -329,11 +805,12 @@ export class Project {
329
805
  configSource: session.configSource,
330
806
  fresh: false,
331
807
  warnings,
808
+ ...(options.startup === undefined ? {} : { startup: options.startup }),
332
809
  });
333
810
  await this.#syncExamples(tx, session.root, warnings, [
334
811
  { id: 'dokebi-db', update: { changes: adapted.changes } },
335
812
  ]);
336
- await this.writeConfiguration(tx, state, adapted.configSource);
813
+ await this.writeConfiguration(tx, state, adapted.configSource, session.configFile);
337
814
  await this.#replacePackages(session, staging, session.pkg);
338
815
  return await this.#finish(session, {
339
816
  changed: tx.paths,
@@ -342,10 +819,18 @@ export class Project {
342
819
  nextSteps: adapted.nextSteps,
343
820
  }, dryRun);
344
821
  }
345
- throw new CliError('UPGRADE_UNSUPPORTED', `Use dokebi upgrade ${added.id}`);
822
+ throw new CliError('UPGRADE_UNSUPPORTED', `${added.id} ${existing.version} is installed; add does not change its version (use dokebi upgrade ${added.id})`, {
823
+ details: {
824
+ plugin: added.id,
825
+ installed: existing.version,
826
+ requested: added.version,
827
+ fix: `npx dokebi upgrade ${added.id} --to ${added.version}`,
828
+ },
829
+ phase: 'plan',
830
+ });
346
831
  }
347
832
  if (Object.hasOwn(dependencies, name))
348
- throw new CliError('PACKAGE_CONFLICT', name);
833
+ throw packageConflict(name, dependencies[name]);
349
834
  // Required dependencies that name their npm package are installed in the same staging
350
835
  // and transaction, before the requested plugin (D-0030 §9).
351
836
  const automatic = await this.#automaticDependencies(staging, state, session, added, dependencies);
@@ -358,72 +843,65 @@ export class Project {
358
843
  const installed = await this.#installedDependencies(staging);
359
844
  await this.#verifyManifests(staging, state.plugins);
360
845
  const ordered = await this.#order(staging, [...state.plugins, ...automatic, added], session.required);
361
- let configSource = sets.length
362
- ? applySetOptions(session.configSource, added.id, sets)
363
- : session.configSource;
364
- let config = parseProjectConfig(configSource);
365
846
  const projectPackages = Object.keys(installed);
366
847
  const validator = new PluginOptionsValidator({ allErrors: true });
367
- const installations = { ...state.installations };
368
848
  const newIds = new Set([...automatic, added].map((plugin) => plugin.id));
369
- const examples = [];
370
849
  const nextSteps = [];
371
- const envSecrets = [];
850
+ const pending = {
851
+ configSource: sets.length
852
+ ? applySetOptions(session.configSource, added.id, sets)
853
+ : session.configSource,
854
+ installations: { ...state.installations },
855
+ examples: [],
856
+ envSecrets: [],
857
+ generatedSecrets: [],
858
+ warnings,
859
+ };
372
860
  // Plans run in dependency order so each plugin sees the ones it builds on.
373
- for (const plugin of ordered.filter((item) => newIds.has(item.id))) {
374
- const plan = await planLifecycle({
375
- base: staging,
376
- plugin,
377
- options: config.options,
378
- plugins: ordered,
379
- projectPackages,
380
- }, 'planInstall');
381
- for (const defaults of plan.defaults)
382
- configSource = mergeInstallDefaults(configSource, plugin.id, defaults);
383
- config = parseProjectConfig(configSource);
384
- const initial = await validator.check(staging, plugin, optionsOf(config.options, plugin.id));
385
- configSource = scaffoldConfig(configSource, plugin.id, initial.schema);
386
- config = parseProjectConfig(configSource);
387
- installations[plugin.id] = {
388
- requires: plan.requires,
389
- directories: plan.directories ?? [],
390
- ...(plan.entry ? { entry: plan.entry.target } : {}),
391
- };
392
- for (const variable of plan.envSecrets)
393
- envSecrets.push({ plugin: plugin.id, variable });
394
- for (const directory of plan.directories ?? [])
395
- await tx.ensureDirectory(directory);
396
- // `--adapter`: the db entry file is written with the adapter's connection instead of the
397
- // empty template (the template's hash is recorded, so upgrades keep this file).
398
- const override = new Map();
399
- if (adapter && plugin.id === 'dokebi-db' && plan.entry) {
400
- const adapted = await this.#dbAdapter(session, staging, adapter.name, {
401
- configSource,
402
- fresh: true,
403
- warnings,
404
- entry: plan.entry.target,
405
- });
406
- configSource = adapted.configSource;
407
- config = parseProjectConfig(configSource);
408
- if (adapted.entryContent !== undefined)
409
- override.set(plan.entry.target, adapted.entryContent);
410
- examples.push({ id: plugin.id, update: { changes: adapted.changes } });
411
- nextSteps.push(...adapted.nextSteps);
412
- }
413
- await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan), override);
414
- examples.push({
415
- id: plugin.id,
416
- update: { defaults: plan.defaults, schema: initial.schema },
861
+ for (const plugin of ordered.filter((item) => newIds.has(item.id)))
862
+ await this.#planInstall(session, staging, plugin, ordered, projectPackages, validator, pending, {
863
+ ...(plugin.id === added.id && sets.length ? { sets } : {}),
864
+ // `--adapter`: the db entry file is written with the adapter's connection instead of
865
+ // the empty template (the template's hash is recorded, so upgrades keep this file).
866
+ ...(adapter && plugin.id === 'dokebi-db'
867
+ ? {
868
+ entry: async (target, source) => {
869
+ const adapted = await this.#dbAdapter(session, staging, adapter.name, {
870
+ configSource: source,
871
+ fresh: true,
872
+ warnings,
873
+ entry: target,
874
+ ...(options.startup === undefined ? {} : { startup: options.startup }),
875
+ });
876
+ pending.examples.push({ id: plugin.id, update: { changes: adapted.changes } });
877
+ nextSteps.push(...adapted.nextSteps);
878
+ return {
879
+ configSource: adapted.configSource,
880
+ ...(adapted.entryContent !== undefined
881
+ ? { content: adapted.entryContent }
882
+ : {}),
883
+ };
884
+ },
885
+ }
886
+ : {}),
417
887
  });
418
- }
888
+ let configSource = pending.configSource;
889
+ let config = parseProjectConfig(configSource);
890
+ const { installations, examples, envSecrets, generatedSecrets } = pending;
419
891
  // The first app comes with the plugin, so a new multi-app project runs right away.
892
+ let firstApp;
420
893
  if (hostsApps && !(state.apps ?? []).length && options.firstApp !== false) {
421
- const first = await this.#addApp(tx, staging, added, state, configSource, 'main', {
422
- domains: ['localhost'],
894
+ firstApp = 'main';
895
+ // D-0102: a host of its own per project, so cookies of two projects never collide.
896
+ const domain = devHost('main', projectHostLabel(session.pkg.name, session.root));
897
+ const first = await this.#addApp(tx, staging, added, ordered, state, configSource, 'main', {
898
+ domains: [domain],
423
899
  });
424
900
  configSource = first.configSource;
425
901
  config = parseProjectConfig(configSource);
426
902
  examples.push({ id: added.id, update: { changes: first.changes } });
903
+ if (first.layout)
904
+ examples.push({ id: first.layout.engine, update: { changes: first.layout.changes } });
427
905
  }
428
906
  await this.#syncExamples(tx, session.root, warnings, examples);
429
907
  const configRequired = [];
@@ -431,8 +909,11 @@ export class Project {
431
909
  await this.#validateOptions(validator, staging, ordered, config, configRequired);
432
910
  state.plugins = ordered;
433
911
  state.installations = installations;
434
- await this.writeConfiguration(tx, state, configSource);
435
- const generatedSecrets = await this.#generateEnvSecrets(session, envSecrets);
912
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
913
+ if (added.hosts?.includes('logger'))
914
+ await this.#wireLogger(tx, session.root, added, warnings, nextSteps);
915
+ generatedSecrets.push(...(await this.#generateEnvSecrets(session, envSecrets)));
916
+ await this.#protectMigrations(session, ordered, warnings);
436
917
  await this.#syncGuide(session, staging, ordered, warnings);
437
918
  await this.#replacePackages(session, staging, session.pkg);
438
919
  // Read the installed guide of every plugin this command added before using it.
@@ -444,7 +925,7 @@ export class Project {
444
925
  });
445
926
  if (hostsApps && options.firstApp === false)
446
927
  nextSteps.push({
447
- command: 'npx dokebi app add <name> --domain localhost [--inertia [--ssr]]',
928
+ command: 'npx dokebi app add <name> --domain auto [--inertia [--ssr]]',
448
929
  why: `--no-first-app: no app exists yet; ${added.id} needs one before the project starts`,
449
930
  });
450
931
  const result = {
@@ -463,28 +944,90 @@ export class Project {
463
944
  : {}),
464
945
  ...(warnings.length ? { warnings } : {}),
465
946
  ...(generatedSecrets.length ? { generatedSecrets } : {}),
947
+ ...(firstApp ? { firstApp } : {}),
466
948
  nextSteps,
467
949
  };
468
950
  return await this.#finish(session, result, dryRun);
469
951
  }
952
+ catch (error) {
953
+ // R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
954
+ throw tx.failure(error);
955
+ }
470
956
  finally {
471
957
  await tx.close();
472
958
  }
473
959
  }
960
+ /**
961
+ * D-0151: the logger host writes only when src/main.ts passes its logger to the Application.
962
+ * An unchanged generated main.ts of before D-0151 (or of before D-0154, without configFile)
963
+ * is rewritten to the current one; a main.ts
964
+ * that already calls createLogger stays; any other gets warning LOGGER_MAIN_MANUAL and the
965
+ * line to write (the plugin also warns LOGGER_NOT_WIRED at startup until it is there).
966
+ */
967
+ async #wireLogger(tx, root, plugin, warnings, nextSteps) {
968
+ const path = 'src/main.ts';
969
+ if (!(await exists(await securePath(root, path)))) {
970
+ debug('No src/main.ts to wire the logger into', { plugin: plugin.id });
971
+ return;
972
+ }
973
+ const main = await tx.read(path);
974
+ if (main === MAIN_SOURCE_V2 || main === MAIN_SOURCE_V3) {
975
+ await tx.write(path, MAIN_SOURCE);
976
+ debug('Rewrote the generated src/main.ts to pass the app logger', { plugin: plugin.id });
977
+ return;
978
+ }
979
+ if (/\bcreateLogger\s*\(/.test(main)) {
980
+ debug('src/main.ts already passes the app logger', { plugin: plugin.id });
981
+ return;
982
+ }
983
+ warnings.push({
984
+ code: 'LOGGER_MAIN_MANUAL',
985
+ path,
986
+ plugin: plugin.id,
987
+ hint: "src/main.ts is your own: import { createLogger } from './dokebi.generated.ts' and create the app with new Application({ config: document.options, logger: createLogger(document.options) })",
988
+ });
989
+ nextSteps.push({
990
+ command: 'edit src/main.ts',
991
+ why: `pass createLogger(document.options) from ./dokebi.generated.ts as the Application logger, so ${plugin.packageName} writes the app's records (until then it warns LOGGER_NOT_WIRED)`,
992
+ });
993
+ }
474
994
  /**
475
995
  * The version for a registry spec without one (D-0065): highest compatible with the project
476
996
  * core, from the registry, else the CLI snapshot. Workspace packages and local archives keep
477
997
  * their own resolution (D-0035).
478
998
  */
479
- async #resolve(session, operation, warnings) {
999
+ async #resolve(session, operation, warnings, command = {
1000
+ next: `npx dokebi add ${operation}`,
1001
+ at: (version) => `npx dokebi add ${operation}@${version}`,
1002
+ }) {
480
1003
  if (operation.startsWith('file:'))
481
- return undefined;
1004
+ return { companions: [] };
482
1005
  const bare = /^(@[^/@]+\/[^@]+|[^@]+)$/.exec(operation)?.[1];
483
1006
  if (!bare || session.workspace?.packages.has(bare))
484
- return undefined;
1007
+ return { companions: [] };
485
1008
  const core = await this.coreVersion(session.root);
1009
+ // The version must fit what is installed: its peers accept the installed versions, and the
1010
+ // installed plugins' dependency ranges accept it (else npm stops with ERESOLVE).
1011
+ const constraints = this.#constraints(session, core, bare);
486
1012
  // npm's registry for this package as npm resolves it in the project (scoped .npmrc, env).
487
- const found = await resolveVersion(bare, core, await loadCatalog(), session.root);
1013
+ const found = await resolveVersion(bare, core, await loadCatalog(), session.root, {
1014
+ constraints,
1015
+ next: command.next,
1016
+ });
1017
+ if (found.heldBack && found.resolution) {
1018
+ const { version, conflicts } = found.heldBack;
1019
+ warnings.push({
1020
+ code: 'NEWER_VERSION_HELD_BACK',
1021
+ path: `${bare}@${version}`,
1022
+ hint: `${describeConflicts(bare, version, conflicts)}, so ${found.resolution.version} is used. For ${version}: ${conflictFix(conflicts, constraints, command.at(version))}`,
1023
+ });
1024
+ debug('A newer version does not fit the installed packages', {
1025
+ package: bare,
1026
+ heldBack: version,
1027
+ resolved: found.resolution.version,
1028
+ conflicts,
1029
+ });
1030
+ }
488
1031
  if (found.offline !== undefined) {
489
1032
  warnings.push({ code: 'CATALOG_OFFLINE', path: bare });
490
1033
  debug('Registry unreachable while resolving a version', {
@@ -500,7 +1043,39 @@ export class Project {
500
1043
  });
501
1044
  if (found.resolution)
502
1045
  debug('Resolved a version for add', { ...found.resolution });
503
- return found.resolution;
1046
+ // The required plugins it brings, at versions that fit too (npm would take the newest).
1047
+ const companions = found.manifest
1048
+ ? await companionVersions(found.manifest, core, constraints, session.root, command.next, (name) => session.workspace?.packages.has(name) === true)
1049
+ : [];
1050
+ return { ...(found.resolution ? { resolution: found.resolution } : {}), companions };
1051
+ }
1052
+ /**
1053
+ * What the installed project asks of a version of `name` (registry.ts ProjectConstraints): the
1054
+ * exact versions package.json pins and the registered plugins' versions (core's own), and the
1055
+ * ranges the registered plugins declare for each plugin id. `name` itself is left out, so an
1056
+ * upgrade is not held back by the version it replaces.
1057
+ */
1058
+ #constraints(session, core, name) {
1059
+ const installed = {};
1060
+ for (const [dependency, spec] of Object.entries(dependencyMap(session.pkg)))
1061
+ if (validVersion(spec))
1062
+ installed[dependency] = spec;
1063
+ const ids = {};
1064
+ const required = {};
1065
+ for (const plugin of session.state.plugins) {
1066
+ installed[plugin.packageName] = plugin.version;
1067
+ ids[plugin.packageName] = plugin.id;
1068
+ if (plugin.packageName === name)
1069
+ continue;
1070
+ for (const dependency of plugin.dependencies) {
1071
+ const list = required[dependency.name] ?? [];
1072
+ list.push({ plugin: plugin.id, range: dependency.version });
1073
+ required[dependency.name] = list;
1074
+ }
1075
+ }
1076
+ installed['@dokebi/core'] = core;
1077
+ delete installed[name];
1078
+ return { installed, ids, required };
504
1079
  }
505
1080
  /** The db variant (from the staged @dokebi/db, else the CLI snapshot) for `--adapter`. */
506
1081
  async #adapterVariant(base, adapter) {
@@ -560,8 +1135,10 @@ export class Project {
560
1135
  /**
561
1136
  * `add @dokebi/db --adapter <id>`: the adapter file (src/infra/db/adapters/<id>.ts, user code),
562
1137
  * the entry file content with a `main` connection, and options.dokebi-db.connections.main in
563
- * dokebi.yaml and the sample. The address is a placeholder until `dokebi dev-infra` (docker)
564
- * or the developer (url) writes it; secrets never come from the command line.
1138
+ * dokebi.yaml and the sample (the URL and the template's pool limits with their defaults,
1139
+ * `connectionOptions` of the variant, D-0112). The address is a placeholder until
1140
+ * `dokebi dev-infra` (docker) or the developer (url) writes it; secrets never come from the
1141
+ * command line.
565
1142
  */
566
1143
  async #dbAdapter(session, staging, adapter, input) {
567
1144
  const { tx, state } = session;
@@ -617,13 +1194,19 @@ export class Project {
617
1194
  {
618
1195
  op: 'default',
619
1196
  path: ['connections', 'main'],
620
- value: { adapter, options: { url: placeholder } },
1197
+ // D-0112: the template's limits with their defaults, so dokebi.yaml shows what to size.
1198
+ value: {
1199
+ adapter,
1200
+ // D-0127: only when --startup is given (required is the default and stays implicit).
1201
+ ...(input.startup === undefined ? {} : { startup: input.startup }),
1202
+ options: { url: placeholder, ...variant.connectionOptions },
1203
+ },
621
1204
  },
622
1205
  ];
623
1206
  if (connection && connection.adapter !== adapter)
624
1207
  input.warnings.push({
625
1208
  code: 'CONNECTION_KEPT',
626
- path: 'dokebi.yaml#options.dokebi-db.connections.main',
1209
+ path: `${session.configFile}#options.dokebi-db.connections.main`,
627
1210
  });
628
1211
  const configSource = applyConfigChanges(input.configSource, 'dokebi-db', changes);
629
1212
  if (!connection)
@@ -632,8 +1215,8 @@ export class Project {
632
1215
  ? 'npx dokebi dev-infra --json'
633
1216
  : `edit dokebi.yaml: options.dokebi-db.connections.main.options.url`,
634
1217
  why: variant.devInfra?.docker
635
- ? `writes compose.yaml, a generated password in .env and the ${variant.name} URL in dokebi.yaml (or write your own URL in dokebi.yaml; never pass it with --set)`
636
- : 'write the database URL (with its password) in dokebi.yaml; never pass it with --set',
1218
+ ? `writes compose.yaml and the ${variant.name} URL with a generated password only in dokebi.yaml (or write your own URL in dokebi.yaml with your file editor; never put it on a command line such as --set, sed or echo)`
1219
+ : 'write the database URL (with its password) in dokebi.yaml with your file editor; never put it on a command line such as --set, sed or echo',
637
1220
  });
638
1221
  return {
639
1222
  configSource,
@@ -642,6 +1225,140 @@ export class Project {
642
1225
  nextSteps,
643
1226
  };
644
1227
  }
1228
+ /**
1229
+ * The install plan of one plugin this command installs (add: the requested plugin and its
1230
+ * automatic dependencies; upgrade: the automatic dependencies its new version brings, D-0116):
1231
+ * install defaults, the options scaffold, generated secrets, directories and files, written
1232
+ * into the transaction and `pending`. Plugins are planned in dependency order.
1233
+ */
1234
+ async #planInstall(session, staging, plugin, ordered, projectPackages, validator, pending, hooks = {}) {
1235
+ const { tx, state } = session;
1236
+ let configSource = pending.configSource;
1237
+ const plan = await planLifecycle({
1238
+ base: staging,
1239
+ plugin,
1240
+ options: parseProjectConfig(configSource).options,
1241
+ plugins: ordered,
1242
+ projectPackages,
1243
+ }, 'planInstall');
1244
+ // D-0151: the logger host's options are options.logging (optionsKey), not options.<id>.
1245
+ const key = optionsKey(plugin);
1246
+ for (const defaults of plan.defaults)
1247
+ configSource = mergeInstallDefaults(configSource, key, defaults);
1248
+ if (plugin.hosts?.includes('logger'))
1249
+ configSource = addLoggingTargets(configSource);
1250
+ const initial = await validator.check(staging, plugin, optionsOf(parseProjectConfig(configSource).options, key));
1251
+ configSource = scaffoldConfig(configSource, key, initial.schema);
1252
+ if (initial.schema)
1253
+ session.schemas.set(key, initial.schema);
1254
+ // D-0073 addendum 2: generated secrets go into dokebi.yaml (0600, kept out of Git by init).
1255
+ for (const path of plan.configSecrets) {
1256
+ const option = `${plugin.id}.${path.join('.')}`;
1257
+ if (optionAt(configSource, key, path) !== undefined) {
1258
+ debug('Secret option already set; nothing generated', { option });
1259
+ continue;
1260
+ }
1261
+ const written = await this.#withGeneratedSecret(staging, validator, plugin, configSource, path);
1262
+ configSource = written.source;
1263
+ pending.generatedSecrets.push({ plugin: plugin.id, option, file: session.configFile });
1264
+ debug('Generated a secret option', { option, list: written.list });
1265
+ }
1266
+ if (plan.envSecrets.length) {
1267
+ // Kept for plugins published before configSecrets (session 0.4.1); removed in 0.5.
1268
+ debug('Install plan uses the deprecated envSecrets; secrets belong in dokebi.yaml', {
1269
+ plugin: plugin.id,
1270
+ variables: plan.envSecrets,
1271
+ });
1272
+ pending.warnings.push({
1273
+ code: 'DEPRECATED_PLAN_FIELD',
1274
+ plugin: plugin.id,
1275
+ deprecated: 'envSecrets',
1276
+ });
1277
+ }
1278
+ pending.installations[plugin.id] = {
1279
+ requires: plan.requires,
1280
+ directories: plan.directories ?? [],
1281
+ ...(plan.entry ? { entry: plan.entry.target } : {}),
1282
+ };
1283
+ for (const variable of plan.envSecrets)
1284
+ pending.envSecrets.push({ plugin: plugin.id, variable });
1285
+ for (const directory of plan.directories ?? [])
1286
+ await tx.ensureDirectory(directory);
1287
+ const override = new Map();
1288
+ if (hooks.entry && plan.entry) {
1289
+ const written = await hooks.entry(plan.entry.target, configSource);
1290
+ configSource = written.configSource;
1291
+ if (written.content !== undefined)
1292
+ override.set(plan.entry.target, written.content);
1293
+ }
1294
+ await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan), override);
1295
+ // The example's section is the same as dokebi.yaml's: options.logging for the logger host.
1296
+ pending.examples.push({
1297
+ id: key,
1298
+ update: {
1299
+ defaults: plan.defaults,
1300
+ schema: initial.schema,
1301
+ // The targets addLoggingTargets wrote into dokebi.yaml above (no secret in them).
1302
+ ...(plugin.hosts?.includes('logger') ? { loggingTargets: true } : {}),
1303
+ },
1304
+ });
1305
+ pending.configSource = configSource;
1306
+ }
1307
+ /**
1308
+ * D-0116: a required dependency of `plugins` that neither the project nor the change has and
1309
+ * whose manifest names no npm package cannot be installed by upgrade: MISSING_DEPENDENCY with
1310
+ * the catalog's package in `details.fix`, before anything is written.
1311
+ */
1312
+ async #refuseUnnamedDependencies(session, plugins, rerun) {
1313
+ const known = new Set([
1314
+ ...session.state.plugins.map((plugin) => plugin.id),
1315
+ ...session.required.map((plugin) => plugin.id),
1316
+ ...plugins.map((plugin) => plugin.id),
1317
+ ]);
1318
+ for (const plugin of plugins)
1319
+ for (const dependency of plugin.dependencies) {
1320
+ if (dependency.optional || dependency.package || known.has(dependency.name))
1321
+ continue;
1322
+ const listed = findPackage(await loadCatalog(), dependency.name);
1323
+ debug('Upgrade refused: a new required dependency names no package', {
1324
+ plugin: plugin.id,
1325
+ dependency: dependency.name,
1326
+ catalog: listed?.package ?? null,
1327
+ });
1328
+ throw missingDependency(plugin, dependency, {
1329
+ reason: 'no-package',
1330
+ ...(listed?.package ? { package: listed.package } : {}),
1331
+ why: 'its manifest names no npm package for it, so the upgrade cannot install it',
1332
+ afterwards: rerun,
1333
+ });
1334
+ }
1335
+ }
1336
+ /**
1337
+ * D-0116: an automatic dependency with more than one install variant (a choice such as the db
1338
+ * adapter) is the user's to choose: MISSING_DEPENDENCY, before anything is written.
1339
+ */
1340
+ async #refuseChoiceDependencies(staging, plugins, automatic, rerun) {
1341
+ for (const dependency of automatic) {
1342
+ const pkg = parseObject(await readFile(await packageFile(staging, dependency.packageName, 'package.json'), 'utf8'), dependency.packageName);
1343
+ const catalog = pkg.dokebiCatalog;
1344
+ const variants = Array.isArray(catalog?.variants)
1345
+ ? catalog.variants.map((variant) => String(variant.name))
1346
+ : [];
1347
+ if (variants.length < 2)
1348
+ continue;
1349
+ debug('Upgrade refused: a new required dependency needs a choice', {
1350
+ dependency: dependency.id,
1351
+ variants,
1352
+ });
1353
+ throw missingDependency(requirerOf(plugins, dependency.id), declaredDependency(plugins, dependency.id), {
1354
+ reason: 'needs-choice',
1355
+ package: dependency.packageName,
1356
+ why: `${dependency.id} needs a choice (${variants.join(', ')}), so the upgrade does not install it`,
1357
+ afterwards: rerun,
1358
+ extra: { variants },
1359
+ });
1360
+ }
1361
+ }
645
1362
  /**
646
1363
  * Installs, into the staging directory, every missing required dependency (recursively) whose
647
1364
  * manifest entry names an npm `package`. Returns their manifests; optional ones are skipped and
@@ -654,23 +1371,63 @@ export class Project {
654
1371
  added.id,
655
1372
  ]);
656
1373
  const result = [];
657
- const pending = [added];
658
- while (pending.length) {
659
- const plugin = pending.shift();
660
- for (const dependency of plugin.dependencies) {
661
- if (dependency.optional || !dependency.package || known.has(dependency.name))
662
- continue;
663
- if (result.length >= 16)
664
- throw new CliError('INVALID_MANIFEST', 'Too many automatic dependencies');
665
- if (Object.hasOwn(dependencies, dependency.package))
666
- throw new CliError('PACKAGE_CONFLICT', dependency.package);
667
- await this.#stageInstall(session, staging, `${dependency.package}@${dependency.version}`);
1374
+ // One npm run per dependency level, not per dependency (D-0116: @dokebi/app's three
1375
+ // automatic dependencies were three more full npm runs in staging).
1376
+ let level = [added];
1377
+ while (level.length) {
1378
+ const wanted = [];
1379
+ for (const plugin of level)
1380
+ for (const dependency of plugin.dependencies) {
1381
+ if (dependency.optional || !dependency.package || known.has(dependency.name))
1382
+ continue;
1383
+ if (result.length + wanted.length >= 16)
1384
+ throw new CliError('INVALID_MANIFEST', `${added.id} brings more than 16 automatic dependencies; the CLI installs at most 16 at once`, {
1385
+ details: {
1386
+ plugin: added.id,
1387
+ automatic: [
1388
+ ...result.map((item) => item.id),
1389
+ ...wanted.map((item) => item.dependency.name),
1390
+ ],
1391
+ fix: 'add some of these plugins first with npx dokebi add <package>, then repeat this command',
1392
+ },
1393
+ phase: 'plan',
1394
+ });
1395
+ if (Object.hasOwn(dependencies, dependency.package))
1396
+ throw packageConflict(dependency.package, dependencies[dependency.package], plugin.id);
1397
+ known.add(dependency.name);
1398
+ wanted.push({ plugin, dependency: dependency });
1399
+ }
1400
+ if (!wanted.length)
1401
+ break;
1402
+ // Already a direct dependency of the staged install (picked with the package itself by
1403
+ // companionVersions): installing the range again would let npm take the newest one.
1404
+ const staged = await this.#installedDependencies(staging);
1405
+ const install = wanted
1406
+ .filter(({ dependency }) => !Object.hasOwn(staged, dependency.package))
1407
+ .map(({ dependency }) => `${dependency.package}@${dependency.version}`);
1408
+ debug('Installing automatic dependencies into staging', {
1409
+ plugin: added.id,
1410
+ packages: install,
1411
+ staged: wanted.length - install.length,
1412
+ });
1413
+ if (install.length)
1414
+ await this.#stageInstall(session, staging, install);
1415
+ level = [];
1416
+ for (const { plugin, dependency } of wanted) {
668
1417
  const manifest = await this.#readPackageManifest(staging, dependency.package);
669
1418
  if (manifest.id !== dependency.name)
670
- throw new CliError('INVALID_MANIFEST', `${dependency.package} is not ${dependency.name}`);
671
- known.add(manifest.id);
1419
+ throw new CliError('INVALID_MANIFEST', `${plugin.id} names ${dependency.package} as the package of ${dependency.name}, but that package is the plugin ${manifest.id}`, {
1420
+ details: {
1421
+ plugin: plugin.id,
1422
+ dependency: dependency.name,
1423
+ package: dependency.package,
1424
+ actual: manifest.id,
1425
+ fix: `the manifest of ${plugin.packageName} ${plugin.version} is wrong: use another version, or report it to its authors`,
1426
+ },
1427
+ phase: 'plan',
1428
+ });
672
1429
  result.push(manifest);
673
- pending.push(manifest);
1430
+ level.push(manifest);
674
1431
  }
675
1432
  }
676
1433
  return result;
@@ -690,10 +1447,28 @@ export class Project {
690
1447
  if (session.required.some((plugin) => plugin.id === id || plugin.packageName === id))
691
1448
  throw new CliError('REQUIRED_PLUGIN', 'Core-required plugins cannot be removed');
692
1449
  const removed = state.plugins.find((plugin) => plugin.id === id);
1450
+ // R4CB-10: remove takes the plugin id. A package name of an installed plugin was "not
1451
+ // installed", so `remove @dokebi/web` answered ok with nothing changed and web stayed.
1452
+ const byPackage = removed
1453
+ ? undefined
1454
+ : state.plugins.find((plugin) => plugin.packageName === id);
1455
+ if (byPackage)
1456
+ throw new CliError('UNKNOWN_PLUGIN', `${id} is the package of the plugin ${byPackage.id}; remove takes the plugin id`, {
1457
+ details: {
1458
+ plugin: id,
1459
+ package: byPackage.packageName,
1460
+ installed: state.plugins.map((plugin) => plugin.id),
1461
+ fix: `npx dokebi remove ${byPackage.id}`,
1462
+ },
1463
+ phase: 'arguments',
1464
+ });
693
1465
  if (!removed)
694
1466
  return { changed: [], preserved: [] };
695
- if (state.plugins.some((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id && !dep.optional)))
696
- throw new CliError('REVERSE_DEPENDENCY', removed.id);
1467
+ const dependents = state.plugins
1468
+ .filter((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id && !dep.optional))
1469
+ .map((plugin) => plugin.id);
1470
+ if (dependents.length)
1471
+ throw reverseDependency(removed.id, dependents);
697
1472
  const dependencies = dependencyMap(session.pkg);
698
1473
  delete dependencies[removed.packageName];
699
1474
  const staging = await this.#stage(session, session.pkg, dependencies, undefined);
@@ -708,9 +1483,7 @@ export class Project {
708
1483
  const checks = await this.#runChecks(staging, ordered, config, installations, Object.keys(after));
709
1484
  for (const plugin of ordered) {
710
1485
  if (checks.get(plugin.id)?.requires.includes(removed.id))
711
- throw new CliError('REVERSE_DEPENDENCY', removed.id, {
712
- details: { plugin: plugin.id, requires: [removed.id] },
713
- });
1486
+ throw reverseDependency(removed.id, [plugin.id]);
714
1487
  }
715
1488
  for (const plugin of ordered)
716
1489
  installations[plugin.id] = {
@@ -737,10 +1510,11 @@ export class Project {
737
1510
  preserved.push(path);
738
1511
  delete state.files[path];
739
1512
  }
740
- const marked = markRemoved(configSource, removed.id);
1513
+ // The plugin's section, as the sample's mark below (options.logging for the logger host).
1514
+ const marked = markRemoved(configSource, optionsKey(removed));
741
1515
  configSource = marked.source;
742
1516
  if (marked.marked)
743
- preserved.push(`${CONFIG_FILE}#options.${removed.id}`);
1517
+ preserved.push(`${session.configFile}#options.${optionsKey(removed)}`);
744
1518
  for (const directory of state.installations[removed.id]?.directories ?? []) {
745
1519
  if (await exists(await securePath(session.root, directory)))
746
1520
  preserved.push(directory);
@@ -750,7 +1524,7 @@ export class Project {
750
1524
  const example = await tx.read(EXAMPLE);
751
1525
  let updated;
752
1526
  try {
753
- updated = markExampleRemoved(example, removed.id);
1527
+ updated = markExampleRemoved(example, optionsKey(removed));
754
1528
  }
755
1529
  catch {
756
1530
  // The removal still proceeds; the caller sees why the example was left as it was.
@@ -761,7 +1535,7 @@ export class Project {
761
1535
  }
762
1536
  state.plugins = ordered;
763
1537
  state.installations = installations;
764
- await this.writeConfiguration(tx, state, configSource);
1538
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
765
1539
  await this.#syncGuide(session, staging, ordered, warnings);
766
1540
  await this.#replacePackages(session, staging, session.pkg);
767
1541
  const result = {
@@ -772,6 +1546,10 @@ export class Project {
772
1546
  };
773
1547
  return await this.#finish(session, result, dryRun);
774
1548
  }
1549
+ catch (error) {
1550
+ // R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
1551
+ throw tx.failure(error);
1552
+ }
775
1553
  finally {
776
1554
  await tx.close();
777
1555
  }
@@ -782,39 +1560,122 @@ export class Project {
782
1560
  try {
783
1561
  const current = state.plugins.find((plugin) => plugin.id === id);
784
1562
  if (!current)
785
- throw new CliError('UNKNOWN_PLUGIN', id);
786
- // A bare version or range applies to the installed package; otherwise use a full spec.
787
- const operation = to === undefined
788
- ? `${current.packageName}@latest`
1563
+ throw new CliError('UNKNOWN_PLUGIN', `${id} is not a registered plugin of this project`, {
1564
+ details: {
1565
+ plugin: id,
1566
+ installed: state.plugins.map((plugin) => plugin.id),
1567
+ fix: 'upgrade takes a plugin id from details.installed; install a new plugin with npx dokebi add <package>',
1568
+ },
1569
+ phase: 'arguments',
1570
+ });
1571
+ const identityChanged = (actual) => new CliError('UPGRADE_IDENTITY_CHANGED', `${to ?? 'latest'} is ${actual}, not ${current.packageName} (${id}); upgrade keeps the package of a plugin`, {
1572
+ details: {
1573
+ plugin: id,
1574
+ package: current.packageName,
1575
+ requested: to ?? 'latest',
1576
+ actual,
1577
+ fix: `name a version or archive of ${current.packageName}; to switch packages, npx dokebi remove ${id} and npx dokebi add <package>`,
1578
+ },
1579
+ phase: 'plan',
1580
+ });
1581
+ // Without --to: the highest version that fits the core and the installed packages, as add
1582
+ // picks (a newer one they hold back is NEWER_VERSION_HELD_BACK), else npm's latest
1583
+ // (workspace packages, an unreachable registry).
1584
+ const resolveWarnings = [];
1585
+ const found = to === undefined
1586
+ ? await this.#resolve(session, current.packageName, resolveWarnings, {
1587
+ next: `npx dokebi upgrade ${id}`,
1588
+ at: (version) => `npx dokebi upgrade ${id} --to ${version}`,
1589
+ })
1590
+ : undefined;
1591
+ const resolved = found?.resolution;
1592
+ const picked = resolved?.source === 'registry' && !lt(resolved.version, current.version)
1593
+ ? resolved.version
1594
+ : undefined;
1595
+ // A bare version or range applies to the installed package; otherwise use a full spec.
1596
+ const requested = to === undefined
1597
+ ? `${current.packageName}@${picked ?? 'latest'}`
789
1598
  : to !== 'latest' && !to.endsWith('.tgz') && validRange(to) !== null
790
1599
  ? packageSpec(`${current.packageName}@${to}`, session.root)
791
1600
  : packageSpec(to, session.root);
1601
+ const operation = session.workspace ? requested : await projectSpec(session.root, requested);
792
1602
  const dependencies = dependencyMap(session.pkg);
793
- const staging = await this.#stage(session, session.pkg, dependencies, operation);
1603
+ const staging = await this.#stage(session, session.pkg, dependencies, operation, picked ? (found?.companions ?? []) : []);
794
1604
  const after = await this.#installedDependencies(staging);
795
- if (operation.startsWith('file:') &&
796
- Object.keys(after).find((key) => after[key] === operation) !== current.packageName)
797
- throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
1605
+ if (operation.startsWith('file:')) {
1606
+ const archive = Object.keys(after).find((key) => after[key] === operation);
1607
+ if (archive !== current.packageName)
1608
+ throw identityChanged(archive ?? operation);
1609
+ }
798
1610
  if (!operation.startsWith('file:') && !operation.startsWith(current.packageName + '@'))
799
- throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
1611
+ throw identityChanged(operation);
800
1612
  const upgraded = await this.#readPackageManifest(staging, current.packageName);
801
1613
  if (upgraded.id !== current.id || upgraded.packageName !== current.packageName)
802
- throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
1614
+ throw identityChanged(`${upgraded.packageName} (${upgraded.id})`);
803
1615
  if (lt(upgraded.version, current.version))
804
- throw new CliError('UPGRADE_DOWNGRADE', id);
1616
+ throw new CliError('UPGRADE_DOWNGRADE', `${id} ${current.version} is installed and ${to ?? 'latest'} is the older ${upgraded.version}; upgrade does not go back`, {
1617
+ details: {
1618
+ plugin: id,
1619
+ from: current.version,
1620
+ to: upgraded.version,
1621
+ fix: `name a version of ${current.packageName} at or above ${current.version}; to go back, npx dokebi remove ${id} and npx dokebi add ${current.packageName}@${upgraded.version}`,
1622
+ },
1623
+ phase: 'plan',
1624
+ });
805
1625
  // Same version and same manifest: nothing to do. Same version with a different manifest
806
1626
  // (a package changed without a version bump) is applied again so the project can follow
807
1627
  // what is installed (D-0037).
808
1628
  const sameVersion = upgraded.version === current.version;
809
- if (sameVersion && json(upgraded) === json(current))
810
- return { changed: [], preserved: [], from: current.version, to: upgraded.version };
1629
+ // An unpublished version keeps its number while its code changes (AGENTS.md): a rebuilt
1630
+ // tarball of the same version and manifest is still new code. The lock's integrity of the
1631
+ // package tells it apart (2026-10-28: web 0.4.3 from a tarball with a fix was a no-op).
1632
+ const contentChanged = sameVersion &&
1633
+ json(upgraded) === json(current) &&
1634
+ (await this.#contentChanged(session, staging, current.packageName));
1635
+ if (sameVersion && json(upgraded) === json(current) && !contentChanged)
1636
+ return {
1637
+ changed: [],
1638
+ preserved: [],
1639
+ ...(resolveWarnings.length ? { warnings: resolveWarnings } : {}),
1640
+ from: current.version,
1641
+ to: upgraded.version,
1642
+ };
811
1643
  const others = state.plugins.filter((plugin) => plugin.id !== id);
812
- await this.#verifyManifests(staging, others);
813
- const ordered = await this.#order(staging, state.plugins.map((plugin) => (plugin.id === id ? upgraded : plugin)), session.required);
1644
+ const warnings = [...resolveWarnings];
1645
+ // D-0116: a required dependency the new version declares and the project lacks is
1646
+ // installed with the upgrade, as `add` would (same staging, same transaction), when its
1647
+ // manifest names the package and it needs no choice or settings; otherwise the upgrade is
1648
+ // refused before anything is written, with the add command to run first.
1649
+ const rerun = `npx dokebi upgrade ${id}${to === undefined ? '' : ` --to ${to}`}`;
1650
+ await this.#refuseUnnamedDependencies(session, [upgraded], rerun);
1651
+ const automatic = await this.#automaticDependencies(staging, state, session, upgraded, dependencies);
1652
+ if (automatic.length) {
1653
+ await this.#refuseUnnamedDependencies(session, [upgraded, ...automatic], rerun);
1654
+ await this.#refuseChoiceDependencies(staging, [upgraded, ...automatic], automatic, rerun);
1655
+ debug('Upgrade installs new required dependencies', {
1656
+ plugin: id,
1657
+ to: upgraded.version,
1658
+ dependencies: automatic.map((plugin) => `${plugin.id}@${plugin.version}`),
1659
+ });
1660
+ }
1661
+ await this.#verifyUpgradeOthers(session, staging, others, warnings);
1662
+ const ordered = await this.#order(staging, [...state.plugins.map((plugin) => (plugin.id === id ? upgraded : plugin)), ...automatic], session.required);
814
1663
  const installation = state.installations[id] ?? defaultInstallation(current);
815
- let configSource = session.configSource;
1664
+ const projectPackages = Object.keys(await this.#installedDependencies(staging));
1665
+ const validator = new PluginOptionsValidator({ allErrors: true });
1666
+ const autoIds = new Set(automatic.map((plugin) => plugin.id));
1667
+ const pending = {
1668
+ configSource: session.configSource,
1669
+ installations: { ...state.installations },
1670
+ examples: [],
1671
+ envSecrets: [],
1672
+ generatedSecrets: [],
1673
+ warnings,
1674
+ };
1675
+ for (const plugin of ordered.filter((item) => autoIds.has(item.id)))
1676
+ await this.#planInstall(session, staging, plugin, ordered, projectPackages, validator, pending);
1677
+ let configSource = pending.configSource;
816
1678
  let config = parseProjectConfig(configSource);
817
- const projectPackages = Object.keys(after);
818
1679
  const plan = await planLifecycle({
819
1680
  base: staging,
820
1681
  plugin: upgraded,
@@ -828,59 +1689,154 @@ export class Project {
828
1689
  installation: installation.entry ? { entry: installation.entry } : {},
829
1690
  });
830
1691
  if (plan.entry && installation.entry && plan.entry.target !== installation.entry)
831
- throw new CliError('UPGRADE_ENTRY_MOVED', id);
832
- configSource = applyConfigChanges(configSource, id, plan.configChanges);
1692
+ throw new CliError('UPGRADE_ENTRY_MOVED', `${id} ${upgraded.version} moves its entry file from ${installation.entry} to ${plan.entry.target}; upgrade does not move user files`, {
1693
+ details: {
1694
+ plugin: id,
1695
+ from: current.version,
1696
+ to: upgraded.version,
1697
+ entry: installation.entry,
1698
+ target: plan.entry.target,
1699
+ fix: `move ${installation.entry} to ${plan.entry.target} yourself (it is your code), or stay on ${current.version}`,
1700
+ },
1701
+ phase: 'plan',
1702
+ });
1703
+ // The plugin's section: options.logging for the logger host (D-0151), else options.<id>.
1704
+ const key = optionsKey(upgraded);
1705
+ configSource = applyConfigChanges(configSource, key, plan.configChanges);
833
1706
  for (const defaults of plan.defaults)
834
- configSource = mergeInstallDefaults(configSource, id, defaults);
1707
+ configSource = mergeInstallDefaults(configSource, key, defaults);
835
1708
  config = parseProjectConfig(configSource);
836
- const validator = new PluginOptionsValidator({ allErrors: true });
837
- const initial = await validator.check(staging, upgraded, optionsOf(config.options, id));
838
- configSource = scaffoldConfig(configSource, id, initial.schema);
1709
+ const initial = await validator.check(staging, upgraded, optionsOf(config.options, key));
1710
+ configSource = scaffoldConfig(configSource, key, initial.schema);
1711
+ if (initial.schema)
1712
+ session.schemas.set(key, initial.schema);
839
1713
  config = parseProjectConfig(configSource);
840
1714
  const installations = {
841
- ...state.installations,
1715
+ ...pending.installations,
842
1716
  [id]: {
843
1717
  requires: plan.requires,
844
1718
  directories: union(installation.directories, plan.directories ?? []),
845
1719
  ...(plan.entry ? { entry: plan.entry.target } : {}),
846
1720
  },
847
1721
  };
848
- const warnings = [];
849
1722
  const configRequired = [];
850
1723
  await this.#check(staging, ordered, config, installations, projectPackages, { id, requires: plan.requires }, configRequired, warnings);
851
1724
  await this.#validateOptions(validator, staging, ordered, config, configRequired);
1725
+ // A new dependency that would be left without its required values is the user's to set up:
1726
+ // nothing is written, `add` reports the values (D-0116).
1727
+ const unset = configRequired.filter((item) => autoIds.has(item.plugin));
1728
+ if (unset.length) {
1729
+ const first = unset[0];
1730
+ const dependency = automatic.find((plugin) => plugin.id === first.plugin);
1731
+ throw missingDependency(requirerOf([upgraded, ...automatic], dependency.id), declaredDependency([upgraded, ...automatic], dependency.id), {
1732
+ reason: 'needs-settings',
1733
+ package: dependency.packageName,
1734
+ why: `${dependency.id} needs settings of its own (${unset.map((item) => item.plugin).join(', ')}), so the upgrade does not install it`,
1735
+ afterwards: rerun,
1736
+ extra: { configRequired: unset },
1737
+ });
1738
+ }
852
1739
  for (const directory of plan.directories ?? [])
853
1740
  await tx.ensureDirectory(directory);
854
1741
  const details = await this.#reconcileFiles(tx, staging, state, upgraded, this.#planFiles(plan));
855
- await this.#syncExample(tx, session.root, id, warnings, {
856
- changes: plan.configChanges,
857
- defaults: plan.defaults,
858
- schema: initial.schema,
859
- });
1742
+ await this.#syncExamples(tx, session.root, warnings, [
1743
+ ...pending.examples,
1744
+ {
1745
+ id: optionsKey(upgraded),
1746
+ update: {
1747
+ changes: plan.configChanges,
1748
+ defaults: plan.defaults,
1749
+ schema: initial.schema,
1750
+ },
1751
+ },
1752
+ ]);
860
1753
  state.plugins = ordered;
861
1754
  state.installations = installations;
862
- await this.writeConfiguration(tx, state, configSource);
1755
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
1756
+ const generatedSecrets = [
1757
+ ...pending.generatedSecrets,
1758
+ ...(await this.#generateEnvSecrets(session, pending.envSecrets)),
1759
+ ];
863
1760
  await this.#syncGuide(session, staging, ordered, warnings);
1761
+ // S-c: copies of this plugin's templates (owner: user) keep what they had; the fixes the
1762
+ // new templates carry and the copies lack are named, as doctor will (TEMPLATE_FIX_MISSING).
1763
+ const touched = new Set([id, ...autoIds]);
1764
+ const appsHost = ordered.find((plugin) => plugin.hosts?.includes('apps'));
1765
+ for (const item of await templateFixes(session.root, staging, {
1766
+ db: ordered.find((plugin) => plugin.id === 'dokebi-db')?.packageName,
1767
+ apps: appsHost ? { id: appsHost.id, packageName: appsHost.packageName } : undefined,
1768
+ inertia: ordered.some((plugin) => plugin.id === 'dokebi-template-inertia'),
1769
+ }))
1770
+ if (touched.has(item.plugin ?? '') ||
1771
+ (item.path.startsWith('vite.') && touched.has('dokebi-template-inertia')))
1772
+ warnings.push({
1773
+ code: item.code,
1774
+ path: item.path,
1775
+ ...(item.plugin ? { plugin: item.plugin } : {}),
1776
+ ...(item.hint ? { hint: item.hint } : {}),
1777
+ });
864
1778
  await this.#replacePackages(session, staging, session.pkg);
1779
+ const nextSteps = automatic
1780
+ .filter((plugin) => plugin.documentation)
1781
+ .map((plugin) => ({
1782
+ command: `npx dokebi plugins doc ${plugin.id} --json`,
1783
+ why: `${id} ${upgraded.version} requires it, so the upgrade installed it: read node_modules/${plugin.packageName}/${plugin.documentation} (the installed ${plugin.version} contract)`,
1784
+ }));
865
1785
  const result = {
866
1786
  changed: tx.paths,
867
1787
  preserved: details.map((detail) => detail.path),
868
1788
  ...(details.length ? { preservedDetails: details } : {}),
869
1789
  ...(configRequired.length ? { configRequired } : {}),
1790
+ ...(automatic.length
1791
+ ? {
1792
+ autoInstalled: automatic.map(({ id, packageName, version }) => ({
1793
+ id,
1794
+ packageName,
1795
+ version,
1796
+ })),
1797
+ }
1798
+ : {}),
870
1799
  ...(warnings.length ? { warnings } : {}),
871
- ...(sameVersion ? { reason: 'MANIFEST_CHANGED' } : {}),
1800
+ ...(generatedSecrets.length ? { generatedSecrets } : {}),
1801
+ ...(sameVersion
1802
+ ? {
1803
+ reason: contentChanged ? 'PACKAGE_CHANGED' : 'MANIFEST_CHANGED',
1804
+ }
1805
+ : {}),
872
1806
  from: current.version,
873
1807
  to: upgraded.version,
1808
+ ...(nextSteps.length ? { nextSteps } : {}),
874
1809
  };
875
1810
  return await this.#finish(session, result, dryRun);
876
1811
  }
1812
+ catch (error) {
1813
+ // R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
1814
+ throw tx.failure(error);
1815
+ }
877
1816
  finally {
878
1817
  await tx.close();
879
1818
  }
880
1819
  }
1820
+ /** Refresh only maintained AGENTS blocks; project instructions remain byte-for-byte intact. */
1821
+ async syncGuide(root, dryRun = false) {
1822
+ const session = await this.#begin(root);
1823
+ try {
1824
+ const warnings = [];
1825
+ if (!(await exists(await securePath(session.root, AGENTS_FILE))))
1826
+ throw new CliError('GUIDE_BLOCK_MISSING', 'AGENTS.md is missing; restore its project instructions before syncing');
1827
+ await this.#syncGuide(session, session.root, session.state.plugins, warnings);
1828
+ return await this.#finish(session, { changed: session.tx.paths, preserved: [], ...(warnings.length ? { warnings } : {}) }, dryRun);
1829
+ }
1830
+ finally {
1831
+ await session.tx.close();
1832
+ }
1833
+ }
881
1834
  /**
882
1835
  * Rewrites the installed list in AGENTS.md (between the dokebi:installed markers) in the same
883
- * transaction (D-0065). Missing markers leave the file alone with GUIDE_BLOCK_MISSING.
1836
+ * transaction (D-0065). Missing markers leave the file alone with GUIDE_BLOCK_MISSING. The
1837
+ * project block (dokebi:project markers, D-0077 G) is carried over as it is: only the installed
1838
+ * list is replaced, an older file without the block gets an empty one at the end, and broken
1839
+ * project markers leave the whole file alone with GUIDE_PROJECT_BLOCK_INVALID.
884
1840
  */
885
1841
  async #syncGuide(session, base, plugins, warnings) {
886
1842
  if (!(await exists(await securePath(session.root, AGENTS_FILE))))
@@ -889,11 +1845,26 @@ export class Project {
889
1845
  const devDependencies = object(session.pkg.devDependencies ?? {}, 'devDependencies');
890
1846
  const cli = devDependencies['@dokebi/cli'];
891
1847
  const block = installedBlock(guideEntries(await this.coreVersion(base), session.required, plugins, typeof cli === 'string' && /^\d+\.\d+\.\d+/.test(cli) ? cli : CLI_VERSION));
892
- const next = replaceInstalledBlock(text, block);
893
- if (next === undefined) {
1848
+ const replaced = replaceInstalledBlock(text, block);
1849
+ if (replaced === undefined) {
894
1850
  warnings.push({ code: 'GUIDE_BLOCK_MISSING', path: AGENTS_FILE });
895
1851
  return;
896
1852
  }
1853
+ const project = projectBlockState(text);
1854
+ if (project === 'invalid') {
1855
+ // Where the project's text begins and ends is not known: writing could cut into it.
1856
+ debug('AGENTS.md left alone: project block markers are broken', { path: AGENTS_FILE });
1857
+ warnings.push({ code: 'GUIDE_PROJECT_BLOCK_INVALID', path: AGENTS_FILE });
1858
+ return;
1859
+ }
1860
+ const updated = syncCapabilityBlock(replaced, capabilityBlock(await loadCatalog()));
1861
+ if (updated === undefined) {
1862
+ warnings.push({ code: 'GUIDE_CAPABILITIES_BLOCK_INVALID', path: AGENTS_FILE });
1863
+ return;
1864
+ }
1865
+ const next = project === 'missing' ? withProjectBlock(updated) : updated;
1866
+ if (project === 'missing')
1867
+ debug('AGENTS.md project block added', { path: AGENTS_FILE });
897
1868
  if (next !== text)
898
1869
  await session.tx.write(AGENTS_FILE, next);
899
1870
  }
@@ -906,22 +1877,43 @@ export class Project {
906
1877
  const { tx, state } = session;
907
1878
  try {
908
1879
  let configSource = session.configSource;
909
- const warnings = [];
1880
+ const warnings = envWarnings(session.root);
910
1881
  const byPlugin = new Map();
911
1882
  for (const { plugin, set } of assignments)
912
1883
  byPlugin.set(plugin, [...(byPlugin.get(plugin) ?? []), set]);
913
1884
  const configRequired = [];
914
1885
  const validator = new PluginOptionsValidator({ allErrors: true });
915
1886
  for (const [id, sets] of byPlugin) {
916
- const plugin = state.plugins.find((item) => item.id === id);
917
- if (!plugin)
918
- throw new CliError('UNKNOWN_PLUGIN', `${id} is not installed`, {
919
- details: { plugin: id, installed: state.plugins.map((item) => item.id) },
920
- phase: 'arguments',
921
- });
922
- configSource = applySetOptions(configSource, id, sets);
923
- warnings.push(...setWarnings(sets).map((warning) => ({ ...warning, plugin: id })));
924
- const checked = await validator.check(session.root, plugin, optionsOf(parseProjectConfig(configSource).options, id));
1887
+ const plugin = sectionPlugin(state.plugins, id);
1888
+ if (!plugin && id === 'logging') {
1889
+ // options.logging without the logger: core's level and plugins only (D-0047).
1890
+ checkCoreLogging(sets);
1891
+ configSource = applySetOptions(configSource, id, sets);
1892
+ continue;
1893
+ }
1894
+ if (!plugin && id === RELOAD_OPTIONS_KEY) {
1895
+ // options.reload: core's reload settings (D-0154), checked by core's declaration.
1896
+ configSource = applySetOptions(configSource, id, sets);
1897
+ checkCoreReload(optionsOf(parseProjectConfig(configSource).options, id));
1898
+ continue;
1899
+ }
1900
+ const settings = !plugin && (state.settings ?? []).includes(id);
1901
+ // D-0155: a section nobody declares cannot be checked, so nothing is written to it.
1902
+ if (!plugin && !settings)
1903
+ throw undeclaredSectionError(undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []));
1904
+ // The section of the plugin: options.<id>, options.logging for the logger (D-0151); an
1905
+ // application settings section is its own name (D-0155).
1906
+ const section = plugin ? optionsKey(plugin) : id;
1907
+ configSource = applySetOptions(configSource, section, sets);
1908
+ const value = optionsOf(parseProjectConfig(configSource).options, section);
1909
+ const checked = plugin
1910
+ ? await validator.check(session.root, plugin, value)
1911
+ : await validator.checkSettings(session.root, section, value);
1912
+ // An old name (D-0071 alias) and its field both set: valid by the schema, refused by the
1913
+ // plugin at start (INVALID_OPTIONS conflict), so never written.
1914
+ const conflicts = checked.schema ? aliasConflicts(value, checked.schema) : [];
1915
+ if (conflicts.length)
1916
+ throw aliasConflictError(plugin?.id ?? id, section, conflicts);
925
1917
  if (checked.errors.every((error) => error.keyword === 'required')) {
926
1918
  if (checked.errors.length)
927
1919
  configRequired.push({
@@ -931,28 +1923,486 @@ export class Project {
931
1923
  });
932
1924
  }
933
1925
  else
934
- throw new CliError('INVALID_PLUGIN_OPTIONS', id, {
935
- details: {
1926
+ throw invalidPluginOptions(plugin?.id ?? id, checked.errors, 'arguments', section, 'schema', checked.schema);
1927
+ // D-0162: the plugin's own rules (cross-field checks) once the schema passes, so a value
1928
+ // the app would refuse at start is never written (R2 Claude T4: unknownDomain = an app name).
1929
+ if (plugin && !checked.errors.length) {
1930
+ const verdict = (await checkPluginsOptions(session.root, [plugin], parseProjectConfig(configSource).options)).get(plugin.id);
1931
+ if (verdict && 'failed' in verdict)
1932
+ warnings.push({
1933
+ code: 'PLUGIN_OPTIONS_CHECK_FAILED',
936
1934
  plugin: id,
937
- errors: checked.errors.slice(0, 20),
938
- errorCount: checked.errors.length,
939
- },
1935
+ hint: `the plugin's own option rules could not run here (${verdict.failed}); npx dokebi start checks them too`,
1936
+ });
1937
+ else if (verdict?.issues.length)
1938
+ throw invalidPluginOptions(plugin.id, verdict.issues, 'arguments', section, 'rules');
1939
+ }
1940
+ }
1941
+ const installations = { ...state.installations };
1942
+ await this.#check(session.root, state.plugins, parseProjectConfig(configSource), installations, Object.keys(dependencyMap(session.pkg)), { id: '', requires: [] }, configRequired, warnings);
1943
+ state.installations = installations;
1944
+ const paths = filePaths([[session.configFile, session.configSource, configSource]]);
1945
+ this.#checkPaths(paths, assignments.map(({ plugin, set }) => requestedPath(session.configSource, plugin, set.path)), dryRun, warnings);
1946
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
1947
+ debug('Options set', { plugins: [...byPlugin.keys()], dryRun, file: session.configFile });
1948
+ return {
1949
+ ...(await this.#finish(session, {
1950
+ changed: tx.paths,
1951
+ preserved: [],
1952
+ paths,
1953
+ ...(configRequired.length ? { configRequired } : {}),
1954
+ ...(warnings.length ? { warnings } : {}),
1955
+ }, dryRun)),
1956
+ file: session.configFile,
1957
+ };
1958
+ }
1959
+ catch (error) {
1960
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
1961
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
1962
+ throw tx.failure(error);
1963
+ }
1964
+ finally {
1965
+ await tx.close();
1966
+ }
1967
+ }
1968
+ /**
1969
+ * `dokebi config unset <plugin>.<path>…` (D-0153): removes options from dokebi.yaml, and from
1970
+ * dokebi.example.yaml where the sample has them, in one transaction. A path that is not set is
1971
+ * a warning (OPTION_NOT_SET), not an error, so a repeated call changes nothing. Values are never
1972
+ * returned or logged; removing a secret is allowed and warned (SECRET_REMOVED: what it signed
1973
+ * no longer verifies). The plugin need not be installed (a section kept by `dokebi remove`).
1974
+ * Removing may only leave issues the options already had, or missing required values
1975
+ * (configRequired); issues that remain are a warning that carries the next fix.
1976
+ */
1977
+ async configUnset(root, targets, dryRun = false) {
1978
+ const session = await this.#begin(root);
1979
+ const { tx, state } = session;
1980
+ try {
1981
+ let configSource = session.configSource;
1982
+ const warnings = envWarnings(session.root);
1983
+ const configRequired = [];
1984
+ const removed = [];
1985
+ const byPlugin = new Map();
1986
+ for (const { plugin, path } of targets)
1987
+ byPlugin.set(plugin, [...(byPlugin.get(plugin) ?? []), [...path]]);
1988
+ const examplePath = await securePath(session.root, EXAMPLE);
1989
+ const exampleSource = (await exists(examplePath)) ? await tx.read(EXAMPLE) : undefined;
1990
+ let example = exampleSource;
1991
+ const validator = new PluginOptionsValidator({ allErrors: true });
1992
+ const key = (error) => `${error.keyword} ${error.instancePath} ${error.additionalProperty ?? ''}`;
1993
+ const exampleHas = (name) => example !== undefined && Object.hasOwn(parseProjectConfig(example).options, name);
1994
+ // The section each typed name means. Names that mean one section (the logger's plugin id
1995
+ // and options.logging) are one group: their list indexes are all of the file as it is, so
1996
+ // one removal never moves the item another index names.
1997
+ const bySection = new Map();
1998
+ for (const [section, paths] of byPlugin) {
1999
+ let plugin = sectionPlugin(state.plugins, section);
2000
+ let notRead;
2001
+ // A section typed by its plugin id that a file still holds as written (the sample's old
2002
+ // options.dokebi-logger, now options.logging): that section is meant, never the
2003
+ // plugin's live options.
2004
+ if (plugin &&
2005
+ optionsKey(plugin) !== section &&
2006
+ (Object.hasOwn(parseProjectConfig(configSource).options, section) || exampleHas(section))) {
2007
+ // Said once something of it is removed (below), never for a path it does not have.
2008
+ notRead = {
2009
+ code: 'SECTION_NOT_READ',
2010
+ plugin: section,
2011
+ hint: `options.${section} is not read: ${plugin.packageName} reads options.${optionsKey(plugin)}; removed as written (npx dokebi config unset ${optionsKey(plugin)}.<path> changes the live options)`,
2012
+ };
2013
+ plugin = undefined;
2014
+ }
2015
+ // The section of the plugin (options.logging for the logger, D-0151), else as typed.
2016
+ const id = plugin ? optionsKey(plugin) : section;
2017
+ const group = bySection.get(id);
2018
+ if (group)
2019
+ group.paths.push(...paths);
2020
+ else
2021
+ bySection.set(id, { plugin, notRead, paths: [...paths] });
2022
+ }
2023
+ for (const [id, { plugin, notRead, paths: asked }] of bySection) {
2024
+ // The same path typed twice through two names is one path.
2025
+ const paths = [...new Map(asked.map((path) => [path.join('.'), path])).values()];
2026
+ const before = parseProjectConfig(configSource);
2027
+ // D-0155: an application settings section is checked against its schema file too.
2028
+ const settings = !plugin && (state.settings ?? []).includes(id);
2029
+ const core = !plugin && (id === 'logging' || id === RELOAD_OPTIONS_KEY);
2030
+ // A whole section only when nothing reads it: a plugin's, the application's settings or
2031
+ // core's (logging, reload) are live options.
2032
+ const read = plugin ||
2033
+ settings ||
2034
+ core ||
2035
+ readSections(state, [
2036
+ before.options,
2037
+ example === undefined ? {} : parseProjectConfig(example).options,
2038
+ ]).has(id);
2039
+ if (paths.some((path) => path.length === 0) && read)
2040
+ throw new CliError('INVALID_SET', plugin
2041
+ ? `options.${id} is read by ${plugin.packageName}: name the options to remove, or remove the plugin (npx dokebi remove ${plugin.packageName})`
2042
+ : settings
2043
+ ? `options.${id} is the application's settings section: name the options to remove, or remove the section (npx dokebi settings remove ${id})`
2044
+ : core
2045
+ ? `options.${id} is read by core: name the options to remove`
2046
+ : `options.${id} is read by the application (a convention section such as application-runtime, or the first admin's): name the options to remove`, { phase: 'arguments', details: { section: id } });
2047
+ if (!plugin && !settings && !core) {
2048
+ // A section nobody declares: removing what is there needs no schema (the section a
2049
+ // removed plugin left, D-0153), so it proceeds with the code config set would refuse.
2050
+ const found = undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []);
2051
+ if (!Object.hasOwn(before.options, id) && !exampleHas(id))
2052
+ throw undeclaredSectionError(found);
2053
+ if (!notRead)
2054
+ warnings.push({
2055
+ code: found.code,
2056
+ plugin: id,
2057
+ hint: `${found.message}; ${found.details.fix}`,
2058
+ });
2059
+ }
2060
+ const checkSection = (options) => plugin
2061
+ ? validator.check(session.root, plugin, optionsOf(options, id))
2062
+ : validator.checkSettings(session.root, id, optionsOf(options, id));
2063
+ const checkedBefore = plugin || settings ? await checkSection(before.options) : undefined;
2064
+ const edited = applyUnsetOptions(configSource, id, paths);
2065
+ // Secret or not is decided on the values being removed; nothing of them is kept.
2066
+ for (const path of edited.removed) {
2067
+ const value = optionAtPath(configSource, id, path);
2068
+ if (commandLineSecret(path, value, checkedBefore?.schema ?? undefined))
2069
+ warnings.push({
2070
+ code: 'SECRET_REMOVED',
2071
+ plugin: id,
2072
+ path: [id, ...path].join('.'),
2073
+ hint: `what this secret signed no longer verifies once the app restarts; to replace a secret instead use npx dokebi config secret ${[id, ...path].join('.')} --rotate`,
2074
+ });
2075
+ }
2076
+ configSource = edited.source;
2077
+ let inExample = [];
2078
+ if (example !== undefined) {
2079
+ // A list item named by its index in dokebi.yaml is the same item in the sample only by
2080
+ // its name (or type): the two lists may have another order. One without either is not
2081
+ // touched in the sample (SAMPLE_ITEM_UNKNOWN).
2082
+ const pairs = [];
2083
+ for (const path of paths) {
2084
+ const named = namedItems(session.configSource, id, path);
2085
+ if (named)
2086
+ pairs.push({ asked: path, sample: named });
2087
+ else
2088
+ warnings.push({
2089
+ code: 'SAMPLE_ITEM_UNKNOWN',
2090
+ plugin: id,
2091
+ path: [id, ...path].join('.'),
2092
+ hint: 'the list item has no name or type, so the same item cannot be found in dokebi.example.yaml; it was left there (npx dokebi config diff names what differs)',
2093
+ });
2094
+ }
2095
+ // One item named two ways (`targets.studio`, `targets.3`) is one path in the sample.
2096
+ const sample = applyUnsetOptions(example, id, [
2097
+ ...new Map(pairs.map((pair) => [pair.sample.join('.'), pair.sample])).values(),
2098
+ ]);
2099
+ example = sample.source;
2100
+ // Reported as asked (the index of dokebi.yaml), whatever the sample's order.
2101
+ const gone = new Set(sample.removed.map((removedPath) => removedPath.join('.')));
2102
+ inExample = pairs
2103
+ .filter((pair) => gone.has(pair.sample.join('.')))
2104
+ .map((pair) => pair.asked);
2105
+ }
2106
+ if (notRead && (edited.removed.length || inExample.length))
2107
+ warnings.push(notRead);
2108
+ for (const path of paths) {
2109
+ const option = [id, ...path].join('.');
2110
+ const files = [
2111
+ ...(edited.removed.some((item) => item.join('.') === path.join('.'))
2112
+ ? [session.configFile]
2113
+ : []),
2114
+ ...(inExample.some((item) => item.join('.') === path.join('.')) ? [EXAMPLE] : []),
2115
+ ];
2116
+ if (files.length)
2117
+ removed.push({ option, files });
2118
+ else
2119
+ warnings.push({ code: 'OPTION_NOT_SET', plugin: id, path: option });
2120
+ }
2121
+ if (!checkedBefore)
2122
+ continue;
2123
+ const checked = await checkSection(parseProjectConfig(configSource).options);
2124
+ const had = new Set(checkedBefore.errors.map(key));
2125
+ const added = checked.errors.filter((error) => error.keyword !== 'required' && !had.has(key(error)));
2126
+ if (added.length)
2127
+ throw invalidPluginOptions(plugin?.id ?? id, added, 'arguments', id, 'schema', checked.schema);
2128
+ const required = checked.errors.filter((error) => error.keyword === 'required');
2129
+ if (required.length)
2130
+ configRequired.push({
2131
+ plugin: id,
2132
+ errors: required.slice(0, 20),
2133
+ errorCount: required.length,
2134
+ });
2135
+ const left = checked.errors.filter((error) => error.keyword !== 'required');
2136
+ if (left.length)
2137
+ warnings.push({
2138
+ code: 'OPTIONS_STILL_INVALID',
2139
+ plugin: id,
2140
+ hint: optionsFix(plugin?.id ?? id, left, `correct options.${id} in dokebi.yaml (npx dokebi plugins info ${plugin?.id ?? id} --json lists the option keys)`, id, checked.schema),
940
2141
  });
941
2142
  }
942
- if (configSource !== session.configSource)
943
- await tx.write(CONFIG_FILE, configSource);
944
- debug('Options set', { plugins: [...byPlugin.keys()], dryRun });
945
- return await this.#finish(session, {
946
- changed: tx.paths,
947
- preserved: [],
948
- ...(configRequired.length ? { configRequired } : {}),
949
- ...(warnings.length ? { warnings } : {}),
950
- }, dryRun);
2143
+ const installations = { ...state.installations };
2144
+ await this.#check(session.root, state.plugins, parseProjectConfig(configSource), installations, Object.keys(dependencyMap(session.pkg)), { id: '', requires: [] }, configRequired, warnings);
2145
+ const requirementsChanged = json(installations) !== json(state.installations);
2146
+ state.installations = installations;
2147
+ const paths = filePaths([
2148
+ [session.configFile, session.configSource, configSource],
2149
+ [EXAMPLE, exampleSource, example],
2150
+ ]);
2151
+ this.#checkPaths(paths, targets.map(({ plugin, path }) => requestedPath(session.configSource, plugin, path)), dryRun, warnings);
2152
+ // Nothing set: nothing written (a requirement refreshed by the checks is written too).
2153
+ if (configSource !== session.configSource || requirementsChanged)
2154
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
2155
+ if (example !== undefined && example !== exampleSource)
2156
+ await tx.write(EXAMPLE, example);
2157
+ debug('Options unset', {
2158
+ plugins: [...byPlugin.keys()],
2159
+ removed: removed.length,
2160
+ dryRun,
2161
+ });
2162
+ return {
2163
+ ...(await this.#finish(session, {
2164
+ changed: tx.paths,
2165
+ preserved: [],
2166
+ paths,
2167
+ ...(configRequired.length ? { configRequired } : {}),
2168
+ ...(warnings.length ? { warnings } : {}),
2169
+ }, dryRun)),
2170
+ file: session.configFile,
2171
+ removed,
2172
+ };
2173
+ }
2174
+ catch (error) {
2175
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
2176
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
2177
+ throw tx.failure(error);
2178
+ }
2179
+ finally {
2180
+ await tx.close();
2181
+ }
2182
+ }
2183
+ /**
2184
+ * A config command may change only what it was asked for: a changed option path outside the
2185
+ * requested ones (an alias that reaches another section, a renamed or old section) is refused
2186
+ * before anything is written, or warned in a dry run so the plan shows it (PATH_MISMATCH).
2187
+ */
2188
+ #checkPaths(paths, requested, dryRun, warnings) {
2189
+ const outside = outsideRequested(paths, requested);
2190
+ if (!outside.length)
2191
+ return;
2192
+ const asked = requested.map((path) => path.join('.'));
2193
+ const message = `The command would change ${outside.join(', ')}, which it was not asked for (asked: ${asked.join(', ')})`;
2194
+ const fix = 'name the options as the files spell them (npx dokebi config show lists them; the logger options are logging.<path>), then run it again';
2195
+ debug('Config change reaches options it was not asked for', { outside, asked, dryRun });
2196
+ if (!dryRun)
2197
+ throw new CliError('PATH_MISMATCH', message, {
2198
+ phase: 'plan',
2199
+ details: { requested: asked, outside, paths, fix },
2200
+ });
2201
+ warnings.push({ code: 'PATH_MISMATCH', hint: `${message}; ${fix}` });
2202
+ }
2203
+ /**
2204
+ * `dokebi config diff [<id>] --write` (AI-M46): copies the options only in dokebi.yaml into
2205
+ * dokebi.example.yaml without their secrets, in one transaction. Paths both files set keep the
2206
+ * sample's value (a sample may differ) unless `values` (`--values`, Studio v2) writes
2207
+ * dokebi.yaml's; secrets inside a copied path are left out. The written sample is compared
2208
+ * again and refused if it would hold a secret (fail closed).
2209
+ */
2210
+ async configSample(root, section, dryRun = false, prune = false, values = false) {
2211
+ const session = await this.#begin(root);
2212
+ const { tx } = session;
2213
+ try {
2214
+ const examplePath = await securePath(session.root, EXAMPLE);
2215
+ if (!(await exists(examplePath)))
2216
+ throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to write (it is the sample of dokebi.yaml without secrets)`, { phase: 'arguments', details: { root: session.root, file: EXAMPLE } });
2217
+ const exampleSource = await tx.read(EXAMPLE);
2218
+ const found = await sampleAdditions(session.root, session.configSource, exampleSource, section, session.configFile);
2219
+ let example = exampleSource;
2220
+ const bySection = new Map();
2221
+ const queue = (op, items) => {
2222
+ for (const { path, value } of items) {
2223
+ const [id, ...rest] = path;
2224
+ if (id === undefined)
2225
+ continue;
2226
+ // A whole section the sample lacks goes in by its keys: edit creates options.<id>
2227
+ // first, so a default at the section itself found it there and wrote nothing.
2228
+ const entries = rest.length === 0 &&
2229
+ value !== null &&
2230
+ typeof value === 'object' &&
2231
+ !Array.isArray(value)
2232
+ ? Object.entries(value).map(([key, item]) => ({ op, path: [key], value: item }))
2233
+ : [{ op, path: rest, value }];
2234
+ bySection.set(id, [...(bySection.get(id) ?? []), ...entries]);
2235
+ }
2236
+ };
2237
+ queue('default', found.additions);
2238
+ if (values)
2239
+ queue('set', found.changes);
2240
+ // A section the sample lacks is created (edit makes options.<id> a mapping first).
2241
+ for (const [id, changes] of bySection)
2242
+ example = applyConfigChanges(example, id, changes);
2243
+ // --prune removes from the sample only what nothing reads — a section no installed plugin,
2244
+ // settings declaration or core reads (an old options.dokebi-logger), and the keys a schema
2245
+ // does not declare (an option a plugin dropped) — and secrets with a value in it. A
2246
+ // declared option only the sample shows stays (a documented value is the sample's job).
2247
+ // dokebi.yaml is never touched.
2248
+ const pruned = [];
2249
+ if (prune) {
2250
+ const targets = new Map();
2251
+ const add = (id, path) => targets.set(id, [...(targets.get(id) ?? []), path]);
2252
+ const validator = new PluginOptionsValidator({ allErrors: true });
2253
+ const options = parseProjectConfig(example).options;
2254
+ const read = readSections(session.state, [
2255
+ parseProjectConfig(session.configSource).options,
2256
+ options,
2257
+ ]);
2258
+ const schemas = new Map();
2259
+ /** Keys of the sample the schema refuses as unknown (`additionalProperties`), dotted. */
2260
+ const unknownKeys = new Map();
2261
+ const schemaOf = async (id) => {
2262
+ if (schemas.has(id))
2263
+ return schemas.get(id);
2264
+ const plugin = session.state.plugins.find((item) => optionsKey(item) === id);
2265
+ const settings = (session.state.settings ?? []).includes(id);
2266
+ const checked = plugin
2267
+ ? await validator.check(session.root, plugin, optionsOf(options, id))
2268
+ : settings
2269
+ ? await validator.checkSettings(session.root, id, optionsOf(options, id))
2270
+ : undefined;
2271
+ const schema = checked?.schema && typeof checked.schema === 'object'
2272
+ ? checked.schema
2273
+ : undefined;
2274
+ schemas.set(id, schema);
2275
+ unknownKeys.set(id, new Set(unknownOptionPaths(id, checked?.errors ?? []).paths));
2276
+ return schema;
2277
+ };
2278
+ // Leaked secrets: what config diff calls one (a schema mark, a name — unless the schema
2279
+ // marks it plain — or a URL with a password).
2280
+ for (const [id, ...rest] of found.prunable)
2281
+ if (id !== undefined)
2282
+ add(id, rest);
2283
+ for (const id of Object.keys(options)) {
2284
+ if (section !== undefined && id !== section)
2285
+ continue;
2286
+ if (!read.has(id)) {
2287
+ add(id, []);
2288
+ continue;
2289
+ }
2290
+ const schema = await schemaOf(id);
2291
+ // Only a schema that lists its keys at the top can say which key it does not declare.
2292
+ if (!schema ||
2293
+ typeof schema.properties !== 'object' ||
2294
+ ['$ref', 'allOf', 'anyOf', 'oneOf'].some((key) => key in schema))
2295
+ continue;
2296
+ // A key no property declares is pruned only when the schema also refuses it: an object
2297
+ // that leaves additionalProperties out takes any key (JSON Schema), so it is an option.
2298
+ for (const path of pruneUndeclared(example, id, null, schema).kept)
2299
+ if (unknownKeys.get(id)?.has(`${id}.${path}`))
2300
+ add(id, path.split('.'));
2301
+ }
2302
+ // Never what this same call copied in.
2303
+ const copied = new Set([...found.additions, ...(values ? found.changes : [])].map((item) => item.path.join('.')));
2304
+ for (const [id, paths] of targets)
2305
+ targets.set(id, paths.filter((path) => {
2306
+ const at = [id, ...path].join('.');
2307
+ return ![...copied].some((written) => written === at || written.startsWith(`${at}.`) || at.startsWith(`${written}.`));
2308
+ }));
2309
+ for (const [id, paths] of targets) {
2310
+ // A whole section first covers whatever was named inside it.
2311
+ const unique = paths.some((path) => path.length === 0)
2312
+ ? [[]]
2313
+ : [...new Map(paths.map((path) => [path.join('.'), path])).values()];
2314
+ const removed = applyUnsetOptions(example, id, unique);
2315
+ example = removed.source;
2316
+ for (const path of removed.removed)
2317
+ pruned.push([id, ...path].join('.'));
2318
+ }
2319
+ }
2320
+ const after = await sampleAdditions(session.root, session.configSource, example, section, session.configFile);
2321
+ if (after.result.exampleSecrets.length > (prune ? 0 : found.result.exampleSecrets.length))
2322
+ throw new CliError('SAMPLE_SECRET', `The copy would put a secret into ${EXAMPLE}; nothing was written`, { details: { paths: after.result.exampleSecrets } });
2323
+ if (example !== exampleSource)
2324
+ await tx.write(EXAMPLE, example);
2325
+ const paths = filePaths([[EXAMPLE, exampleSource, example]]);
2326
+ const written = found.additions.map((item) => item.path.join('.'));
2327
+ const valuesWritten = values ? found.changes.map((item) => item.path.join('.')) : undefined;
2328
+ debug('Sample settings written', {
2329
+ section: section ?? null,
2330
+ written: written.length,
2331
+ ...(valuesWritten ? { valuesWritten: valuesWritten.length } : {}),
2332
+ dryRun,
2333
+ });
2334
+ const warnings = envWarnings(session.root);
2335
+ return {
2336
+ ...(await this.#finish(session, { changed: tx.paths, preserved: [], paths, ...(warnings.length ? { warnings } : {}) }, dryRun)),
2337
+ file: session.configFile,
2338
+ written,
2339
+ pruned,
2340
+ ...(valuesWritten ? { valuesWritten } : {}),
2341
+ diff: after.result,
2342
+ };
2343
+ }
2344
+ catch (error) {
2345
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
2346
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
2347
+ throw tx.failure(error);
951
2348
  }
952
2349
  finally {
953
2350
  await tx.close();
954
2351
  }
955
2352
  }
2353
+ /**
2354
+ * D-0145: keeps the migrations folder out of the project's code formatters, so an applied
2355
+ * migration keeps its bytes: a CLI-owned entry in .prettierignore, `!<folder>` in biome.json
2356
+ * files.includes. A formatter the CLI cannot configure (biome.jsonc with comments, an ESLint
2357
+ * config with formatting rules) is a warning with the fix. Only for a project with migrations
2358
+ * (dokebi-migrate installed, or the folder present).
2359
+ */
2360
+ async #protectMigrations(session, plugins, warnings) {
2361
+ const { root, tx } = session;
2362
+ const migrations = await migrationsFolder(root);
2363
+ // R1 measurement: the registration the CLI writes, too (a reformatted copy is
2364
+ // MODIFIED_REGISTRATION); the migrations folder only in a project with migrations.
2365
+ const targets = [GENERATED_FILE];
2366
+ if (plugins.some((plugin) => plugin.id === 'dokebi-migrate') ||
2367
+ (await exists(await securePath(root, migrations))))
2368
+ targets.push(migrations);
2369
+ // Each config file is written once (the transaction refuses a second write of one path): the
2370
+ // texts are built over every target first.
2371
+ const texts = new Map();
2372
+ const text = async (path, fallback) => {
2373
+ const known = texts.get(path);
2374
+ if (known !== undefined)
2375
+ return known;
2376
+ return (await exists(await securePath(root, path))) ? await tx.read(path) : fallback;
2377
+ };
2378
+ for (const folder of targets)
2379
+ for (const use of await formatterUses(root, folder)) {
2380
+ if (use.covered)
2381
+ continue;
2382
+ if (use.tool === 'prettier') {
2383
+ texts.set(PRETTIER_IGNORE, prettierIgnoreText(await text(PRETTIER_IGNORE, ''), folder));
2384
+ debug('Kept out of prettier', { folder, config: use.config });
2385
+ continue;
2386
+ }
2387
+ if (use.tool === 'biome') {
2388
+ const edited = biomeConfigText(await text(use.config, ''), folder, biomeMajor(session.pkg) === 1);
2389
+ if (edited !== undefined) {
2390
+ texts.set(use.config, edited);
2391
+ debug('Kept out of biome', { folder, config: use.config });
2392
+ continue;
2393
+ }
2394
+ }
2395
+ warnings.push({
2396
+ code: folder === GENERATED_FILE
2397
+ ? 'FORMATTER_FORMATS_GENERATED'
2398
+ : 'FORMATTER_FORMATS_MIGRATIONS',
2399
+ path: use.config,
2400
+ hint: formatterFix(use, folder),
2401
+ });
2402
+ }
2403
+ for (const [path, content] of texts)
2404
+ await tx.write(path, content);
2405
+ }
956
2406
  /** Adds `.env` to .gitignore unless a line already ignores it (D-0065, D-0073). */
957
2407
  async #ignoreEnv(session) {
958
2408
  const { tx } = session;
@@ -962,9 +2412,10 @@ export class Project {
962
2412
  await tx.write('.gitignore', `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}${ENV_FILE}\n`);
963
2413
  }
964
2414
  /**
965
- * D-0073: a generated secret (48 random bytes, base64url) in .env for each variable an install
966
- * plan names (`envSecrets`), unless the CLI's environment or .env already has it. The value is
967
- * never returned, printed or logged; .env is git-ignored and read by dev, start and verify.
2415
+ * D-0073 §2, deprecated (addendum 2; removed in 0.5): a generated secret (48 random bytes,
2416
+ * base64url) in .env for each variable an install plan names (`envSecrets`), unless the CLI's
2417
+ * environment or .env already has it. The value is never returned, printed or logged; .env is
2418
+ * git-ignored and read by dev, start and verify.
968
2419
  */
969
2420
  async #generateEnvSecrets(session, requests) {
970
2421
  if (!requests.length)
@@ -972,9 +2423,9 @@ export class Project {
972
2423
  const { tx } = session;
973
2424
  const envPath = await securePath(session.root, ENV_FILE);
974
2425
  const original = (await exists(envPath)) ? await tx.read(ENV_FILE) : '';
975
- // Never append to a file the apps could not read (ENV_FILE_INVALID names the line).
976
- checkEnvText(original);
977
- const present = parseEnv(original);
2426
+ // Never append to a file the apps could not read (ENV_FILE_INVALID names the line); read as
2427
+ // the apps read it (R4CA-11: a byte order mark is not part of the first name).
2428
+ const present = parseEnvText(original);
978
2429
  let text = original;
979
2430
  const generated = [];
980
2431
  for (const { plugin, variable } of requests) {
@@ -998,9 +2449,10 @@ export class Project {
998
2449
  return generated;
999
2450
  }
1000
2451
  /**
1001
- * `dokebi dev-infra` (D-0065): compose.yaml for the database of connection `main`, a generated
1002
- * password in .env (git-ignored) and the matching URL in dokebi.yaml. An existing compose.yaml
1003
- * with a `db` service and an existing password are kept; nothing is started here.
2452
+ * `dokebi dev-infra` (D-0065): compose.yaml for the database of connection `main` and the URL
2453
+ * with a generated password in dokebi.yaml (no .env is written; a legacy .env password is reused
2454
+ * only when the YAML has no URL). An existing compose.yaml with a `db` service and an existing
2455
+ * password are kept; nothing is started here.
1004
2456
  */
1005
2457
  async devInfra(root, options = {}) {
1006
2458
  const session = await this.#begin(root);
@@ -1022,17 +2474,105 @@ export class Project {
1022
2474
  });
1023
2475
  const warnings = [];
1024
2476
  let port = options.dbPort ?? docker.port;
2477
+ // D-0103: a MongoDB is generated as a single-node replica set; a kept file decides.
2478
+ let replicaSet = replicaSetOf(docker);
1025
2479
  const composePath = await securePath(session.root, COMPOSE_FILE);
2480
+ let composeUnset = [];
1026
2481
  if (await exists(composePath)) {
1027
- const existing = parse(await tx.read(COMPOSE_FILE));
2482
+ const composeSource = await tx.read(COMPOSE_FILE);
2483
+ // R2 Codex T2: a kept file may need variables dev-infra up does not set; the plan says
2484
+ // so before it names dev-infra up as the next step.
2485
+ const envPath = await securePath(session.root, ENV_FILE);
2486
+ composeUnset = composeUnsetVariables(composeSource, [
2487
+ // D-0165: the passwords of dokebi.yaml that dev-infra up passes.
2488
+ ...Object.keys(connectionSecrets(parseProjectConfig(session.configSource).options)),
2489
+ ...((await exists(envPath)) ? envKeys(await readFile(envPath, 'utf8')) : []),
2490
+ ]);
2491
+ if (composeUnset.length)
2492
+ warnings.push({
2493
+ code: 'COMPOSE_VARIABLES_UNSET',
2494
+ path: COMPOSE_FILE,
2495
+ hint: composeVariablesHint(composeUnset),
2496
+ });
2497
+ const existing = parse(composeSource);
1028
2498
  const service = existing?.services?.[DB_SERVICE];
1029
2499
  if (!service)
1030
2500
  throw new CliError('COMPOSE_CONFLICT', `${COMPOSE_FILE} exists without a "${DB_SERVICE}" service; add the service yourself or move the file`, { details: { file: COMPOSE_FILE } });
1031
- // The kept file decides the published port.
1032
- const published = String(service.ports?.[0] ?? '').match(/(?:^|:)(\d+):\d+$/)?.[1];
1033
- if (published)
1034
- port = Number(published);
1035
- warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
2501
+ // The kept file decides the published port, unless --db-port changes it: then only that
2502
+ // port entry is rewritten (the rest of the user's file stays as it is).
2503
+ const published = composePublishedPort(composeSource);
2504
+ const portFix = `set the first ports entry of services.${DB_SERVICE} in ${COMPOSE_FILE} to "127.0.0.1:<host port>:${docker.port}" yourself, then run npx dokebi dev-infra --json again`;
2505
+ if (options.dbPort !== undefined && options.dbPort !== published) {
2506
+ const rewritten = withComposePort(composeSource, options.dbPort);
2507
+ if (rewritten === undefined)
2508
+ throw new CliError('COMPOSE_PORT_UNKNOWN', `--db-port ${options.dbPort} was not applied: ${COMPOSE_FILE} publishes the ${DB_SERVICE} port in a form the CLI does not rewrite`, { phase: 'arguments', details: { file: COMPOSE_FILE, fix: portFix } });
2509
+ if (await portInUse(options.dbPort)) {
2510
+ const alternative = await freePortAfter(options.dbPort);
2511
+ throw new CliError('PORT_IN_USE', `Port ${options.dbPort} on 127.0.0.1 is in use`, {
2512
+ phase: 'arguments',
2513
+ details: {
2514
+ port: options.dbPort,
2515
+ ...(alternative ? { alternative } : {}),
2516
+ hint: `run again with --db-port ${alternative ?? '<free port>'}; compose.yaml and dokebi.yaml get the same port`,
2517
+ ...portQuestion({
2518
+ what: 'Database',
2519
+ port: options.dbPort,
2520
+ alternative,
2521
+ flag: '--db-port',
2522
+ delegated: false,
2523
+ }),
2524
+ },
2525
+ });
2526
+ }
2527
+ await tx.write(COMPOSE_FILE, rewritten);
2528
+ port = options.dbPort;
2529
+ debug('Kept compose.yaml gets another published port', { from: published, to: port });
2530
+ warnings.push({
2531
+ code: 'COMPOSE_PORT_CHANGED',
2532
+ path: COMPOSE_FILE,
2533
+ hint: `only the published port of services.${DB_SERVICE} changed (${published} to ${port}); npx dokebi dev-infra up recreates a running container on the new port and keeps its data volume`,
2534
+ });
2535
+ }
2536
+ else if (published !== undefined) {
2537
+ port = published;
2538
+ warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
2539
+ }
2540
+ else {
2541
+ // Unreadable (a variable, a range): the port of the URL already in dokebi.yaml stays
2542
+ // (the catalog port for a first URL), and the warning says which one.
2543
+ const configured = main.options?.url;
2544
+ if (typeof configured === 'string' && !configured.startsWith(URL_PLACEHOLDER_PREFIX)) {
2545
+ let kept;
2546
+ try {
2547
+ kept = Number(new URL(configured).port) || undefined;
2548
+ }
2549
+ catch (error) {
2550
+ // The password-free reason only; the URL check below refuses it with its code.
2551
+ debug('The main database URL could not be read for its port', {
2552
+ code: error.code,
2553
+ });
2554
+ }
2555
+ if (kept)
2556
+ port = kept;
2557
+ }
2558
+ warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
2559
+ warnings.push({
2560
+ code: 'COMPOSE_PORT_UNKNOWN',
2561
+ path: COMPOSE_FILE,
2562
+ hint: `the published port of services.${DB_SERVICE} could not be read, so the URL in dokebi.yaml uses port ${port}; if the file publishes another one, ${portFix}`,
2563
+ });
2564
+ }
2565
+ if (replicaSet && !runsReplicaSet(service)) {
2566
+ // Written before D-0103: the URL stays a standalone one, or the driver would wait for
2567
+ // a replica set that is not there. Transactions need the regenerated file.
2568
+ debug('Kept compose.yaml runs a standalone MongoDB', { file: COMPOSE_FILE });
2569
+ replicaSet = undefined;
2570
+ warnings.push({
2571
+ code: 'DEV_INFRA_STANDALONE',
2572
+ path: COMPOSE_FILE,
2573
+ hint: `${COMPOSE_FILE} runs a standalone MongoDB, which refuses multi-document transactions. To make it the single-node replica set ${replicaSetOf(docker)}: npx dokebi dev-infra down, move ${COMPOSE_FILE} away (carry over your own changes later), npx dokebi dev-infra (a new file on the same port with --db-port ${port}; the password and the data volume are kept), npx dokebi dev-infra up`,
2574
+ });
2575
+ }
1036
2576
  }
1037
2577
  else {
1038
2578
  // The container would not start on a taken port: refuse, and name a free one.
@@ -1057,42 +2597,65 @@ export class Project {
1057
2597
  }
1058
2598
  await tx.write(COMPOSE_FILE, composeText(composeProject(String(session.pkg.name ?? '')), variant.name, docker, port));
1059
2599
  }
1060
- const envPath = await securePath(session.root, ENV_FILE);
1061
- const envText = (await exists(envPath)) ? await tx.read(ENV_FILE) : '';
1062
- let password = envValue(envText, PASSWORD_VARIABLE);
1063
- if (!password) {
1064
- password = generatePassword();
1065
- await tx.write(ENV_FILE, withEnv(envText, PASSWORD_VARIABLE, password, 'development database password (dokebi dev-infra); not committed'));
2600
+ // Reuse the YAML credential on reruns. Read legacy .env only when no YAML URL exists;
2601
+ // never rotate an existing database password or rewrite the user's legacy file.
2602
+ const currentUrl = main.options?.url;
2603
+ let password;
2604
+ if (typeof currentUrl === 'string' && !currentUrl.startsWith(URL_PLACEHOLDER_PREFIX)) {
2605
+ try {
2606
+ password = decodeURIComponent(new URL(currentUrl).password);
2607
+ }
2608
+ catch {
2609
+ // dokebi-check: allow missing-cause the URL error carries its input, which holds the database password
2610
+ throw new CliError('DEV_INFRA_CONFIG_INVALID', 'The main database URL is invalid');
2611
+ }
2612
+ }
2613
+ else {
2614
+ const envPath = await securePath(session.root, ENV_FILE);
2615
+ if (await exists(envPath))
2616
+ password = envValue(await tx.read(ENV_FILE), PASSWORD_VARIABLE);
1066
2617
  }
1067
- const url = connectionUrl(docker, password, port);
2618
+ password ??= generatePassword();
2619
+ const url = connectionUrl(docker, password, port, replicaSet);
1068
2620
  const current = main.options?.url;
1069
2621
  if (current !== url) {
1070
2622
  if (typeof current === 'string' && !current.startsWith(URL_PLACEHOLDER_PREFIX))
1071
2623
  warnings.push({
1072
2624
  code: 'URL_REPLACED',
1073
- path: 'dokebi.yaml#options.dokebi-db.connections.main',
2625
+ path: `${session.configFile}#options.dokebi-db.connections.main`,
1074
2626
  });
1075
- await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-db', [
2627
+ await tx.write(session.configFile, applyConfigChanges(session.configSource, 'dokebi-db', [
1076
2628
  { op: 'remove', path: ['connections', 'main', 'options', 'url'] },
1077
2629
  { op: 'default', path: ['connections', 'main', 'options', 'url'], value: url },
1078
2630
  ]));
1079
2631
  }
1080
2632
  await this.#ignoreEnv(session);
1081
- debug('Development database prepared', { variant: variant.name, port, image: docker.image });
2633
+ debug('Development database prepared', {
2634
+ variant: variant.name,
2635
+ port,
2636
+ image: docker.image,
2637
+ replicaSet: replicaSet ?? null,
2638
+ });
1082
2639
  return {
1083
2640
  ...(await this.#finish(session, {
1084
2641
  changed: tx.paths,
1085
- preserved: warnings.map((warning) => warning.path ?? '').filter(Boolean),
2642
+ // What was kept as it is: a compose.yaml the run did not touch (a replaced URL or a
2643
+ // rewritten port is a change, not a kept file).
2644
+ preserved: warnings.some((warning) => warning.code === 'COMPOSE_KEPT')
2645
+ ? [COMPOSE_FILE]
2646
+ : [],
1086
2647
  ...(warnings.length ? { warnings } : {}),
1087
2648
  nextSteps: [
1088
2649
  {
1089
- command: 'docker compose up -d --wait',
1090
- why: `start the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`,
2650
+ command: 'npx dokebi dev-infra up --json',
2651
+ why: composeUnset.length
2652
+ ? `first give ${composeUnset.join(', ')} a default in ${COMPOSE_FILE} or a value in ${ENV_FILE} (COMPOSE_VARIABLES_UNSET); then it starts the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`
2653
+ : `start the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`,
1091
2654
  },
1092
2655
  { command: 'npx dokebi verify --json', why: 'start the app against it and check it' },
1093
2656
  {
1094
- command: 'docker compose down',
1095
- why: 'stop it when you are done (add -v to delete the data volume)',
2657
+ command: 'npx dokebi dev-infra down --json',
2658
+ why: 'stop it when you are done (data volumes are retained)',
1096
2659
  },
1097
2660
  ],
1098
2661
  }, options.dryRun)),
@@ -1101,9 +2664,10 @@ export class Project {
1101
2664
  variant: variant.name,
1102
2665
  image: docker.image,
1103
2666
  port,
2667
+ ...(replicaSet ? { replicaSet } : {}),
1104
2668
  url: redactUrl(url),
1105
2669
  compose: COMPOSE_FILE,
1106
- passwordIn: ENV_FILE,
2670
+ passwordIn: session.configFile,
1107
2671
  },
1108
2672
  };
1109
2673
  }
@@ -1111,55 +2675,395 @@ export class Project {
1111
2675
  await tx.close();
1112
2676
  }
1113
2677
  }
2678
+ /**
2679
+ * dokebi.yaml source with a generated random secret (32 bytes, base64url) at `<id>.<path>`: a
2680
+ * string, or a one-item list when the schema wants a list. Shared by `config secret` and the
2681
+ * `configSecrets` of `add`. Schema errors the source already had do not count against a shape.
2682
+ */
2683
+ async #withGeneratedSecret(root, validator, plugin, source, path, secret = randomBytes(32).toString('base64url')) {
2684
+ // The plugin's section: options.<id>, options.logging for the logger host (D-0151).
2685
+ const id = targetSection(plugin);
2686
+ const where = `${id}.${path.join('.')}`;
2687
+ const key = (error) => `${error.keyword} ${error.instancePath}`;
2688
+ const options = (text) => optionsOf(parseProjectConfig(text).options, id);
2689
+ const before = new Set((await checkTarget(validator, root, plugin, options(source))).errors.map(key));
2690
+ for (const value of [secret, [secret]]) {
2691
+ const candidate = applySetOptions(source, id, [{ path: [...path], value }]);
2692
+ const checked = await checkTarget(validator, root, plugin, options(candidate));
2693
+ if (checked.errors.every((error) => error.keyword === 'required' || before.has(key(error))))
2694
+ return { source: candidate, list: Array.isArray(value) };
2695
+ }
2696
+ throw new CliError('INVALID_PLUGIN_OPTIONS', `${where} does not take a secret string`, {
2697
+ details: { plugin: id, path: where },
2698
+ });
2699
+ }
1114
2700
  /**
1115
2701
  * `dokebi config secret <plugin>.<path>`: a generated random secret (32 bytes, base64url) in
1116
2702
  * dokebi.yaml, as a one-item list when the schema wants a list. The value is never returned or
1117
- * logged; an existing value is kept (SECRET_KEPT). Secrets never pass the command line.
2703
+ * logged; an existing value is kept (SECRET_KEPT) unless `rotate` (`--rotate`, AI-M05): a string
2704
+ * is replaced; a list gets the new secret first (it signs) and keeps the previous ones after it,
2705
+ * at most ROTATE_KEEP of them (older ones are dropped: SECRETS_DROPPED), so what the old ones
2706
+ * signed still verifies during the rotation. Secrets never pass the command line. `value`
2707
+ * (`--stdin`, D-0151 follow-up): a secret someone else issued, such as the token of a log
2708
+ * receiver (`logging.targets.studio.token`), written instead of a generated one; never returned
2709
+ * or logged either. A list item is named by its `name` (else `type`) or its index.
1118
2710
  */
1119
- async configSecret(root, id, path, dryRun = false) {
2711
+ async configSecret(root, id, path, dryRun = false, rotate = false, value) {
1120
2712
  const session = await this.#begin(root);
1121
2713
  const { tx, state } = session;
1122
2714
  try {
1123
- const plugin = state.plugins.find((item) => item.id === id);
1124
- if (!plugin)
1125
- throw new CliError('UNKNOWN_PLUGIN', `${id} is not installed`, {
1126
- details: { plugin: id, installed: state.plugins.map((item) => item.id) },
2715
+ const installed = sectionPlugin(state.plugins, id);
2716
+ // D-0155: an application settings section takes secrets like a plugin's.
2717
+ const plugin = installed ?? ((state.settings ?? []).includes(id) ? { settingsSection: id } : undefined);
2718
+ if (!plugin && id === RELOAD_OPTIONS_KEY)
2719
+ throw new CliError('INVALID_SET', 'options.reload holds no secrets', {
2720
+ details: { section: id, fix: 'set its values with npx dokebi config set reload.<key>=…' },
1127
2721
  phase: 'arguments',
1128
2722
  });
2723
+ // D-0155: a section nobody declares cannot say which value is a secret: refused.
2724
+ if (!plugin)
2725
+ throw undeclaredSectionError(undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []));
1129
2726
  const where = `${id}.${path.join('.')}`;
1130
- let current = parseProjectConfig(session.configSource).options[id];
1131
- for (const key of path)
1132
- current =
1133
- current && typeof current === 'object'
1134
- ? current[key]
1135
- : undefined;
1136
- if (current !== undefined)
2727
+ const section = targetSection(plugin);
2728
+ // A path through a list must name an item that is there (INVALID_SET with the names).
2729
+ applySetOptions(session.configSource, section, [{ path: [...path], value: '' }]);
2730
+ // R4CA-5: an empty value (`token:`, `token: ''`, `secrets: []`) holds no secret: it is
2731
+ // filled, never kept (SECRET_KEPT) or rotated. R5 Codex T4 review: nor does the sample's
2732
+ // placeholder (`<set the postgres URL here …>`, what `config init` lists as placeholders).
2733
+ const found = optionAtPath(session.configSource, section, path);
2734
+ const current = found === null ||
2735
+ found === '' ||
2736
+ isPlaceholder(found) ||
2737
+ (Array.isArray(found) && (found.length === 0 || found.every(isPlaceholder)))
2738
+ ? undefined
2739
+ : found;
2740
+ if (current !== undefined && !rotate)
1137
2741
  return {
1138
- ...(await this.#finish(session, { changed: [], preserved: [], warnings: [{ code: 'SECRET_KEPT', path: where }] }, dryRun)),
2742
+ ...(await this.#finish(session, {
2743
+ changed: [],
2744
+ preserved: [],
2745
+ warnings: [
2746
+ ...envWarnings(session.root),
2747
+ {
2748
+ code: 'SECRET_KEPT',
2749
+ path: where,
2750
+ hint: `npx dokebi config secret ${where} --rotate replaces it (a list keeps the previous ones after the new one)`,
2751
+ },
2752
+ ],
2753
+ }, dryRun)),
2754
+ file: session.configFile,
1139
2755
  secret: { path: where, list: Array.isArray(current), generated: false },
1140
2756
  };
1141
- const secret = randomBytes(32).toString('base64url');
2757
+ // A random string is never a working address: a URL (a placeholder or a rotated one) is
2758
+ // the user's, piped in (R5 Codex T4 review: filling the placeholder opened this path).
2759
+ if (value === undefined && URL_KEY.test(path.at(-1) ?? ''))
2760
+ throw new CliError('INVALID_SET', `${where} is an address, not a generated secret`, {
2761
+ details: {
2762
+ path: where,
2763
+ fix: `npx dokebi config secret ${where} --stdin < <file with the URL>`,
2764
+ },
2765
+ phase: 'arguments',
2766
+ });
1142
2767
  const validator = new PluginOptionsValidator({ allErrors: true });
1143
- let chosen;
1144
- let list = false;
1145
- for (const value of [secret, [secret]]) {
1146
- const source = applySetOptions(session.configSource, id, [{ path: [...path], value }]);
1147
- const checked = await validator.check(session.root, plugin, optionsOf(parseProjectConfig(source).options, id));
1148
- if (checked.errors.every((error) => error.keyword === 'required')) {
1149
- chosen = source;
1150
- list = Array.isArray(value);
1151
- break;
2768
+ const rotated = current !== undefined && Array.isArray(current)
2769
+ ? await this.#rotatedList(session.root, validator, plugin, session.configSource, path, current, ...(value === undefined ? [] : [value]))
2770
+ : undefined;
2771
+ const { source: chosen, list } = rotated ??
2772
+ (await this.#withGeneratedSecret(session.root, validator, plugin, session.configSource, path, ...(value === undefined ? [] : [value])));
2773
+ // R4CA-6: a list no kept-secrets list fits (the schema then took one new string or a
2774
+ // one-item list) lost every previous secret: said like any other drop.
2775
+ const dropped = rotated?.dropped ?? (Array.isArray(current) ? current.length : 0);
2776
+ const warnings = [
2777
+ ...envWarnings(session.root),
2778
+ ...(dropped ? [{ code: 'SECRETS_DROPPED', path: where, count: dropped }] : []),
2779
+ ];
2780
+ const paths = filePaths([[session.configFile, session.configSource, chosen]]);
2781
+ this.#checkPaths(paths, [requestedPath(session.configSource, id, path)], dryRun, warnings);
2782
+ await tx.write(session.configFile, chosen);
2783
+ const outcome = {
2784
+ path: where,
2785
+ list,
2786
+ generated: value === undefined,
2787
+ ...(value === undefined ? {} : { stdin: true }),
2788
+ ...(current !== undefined ? { rotated: true, kept: rotated?.kept ?? 0, dropped } : {}),
2789
+ };
2790
+ debug(current === undefined ? 'Wrote a secret option' : 'Rotated a secret option', {
2791
+ path: where,
2792
+ list,
2793
+ source: value === undefined ? 'generated' : 'stdin',
2794
+ ...(current !== undefined ? { kept: outcome.kept, dropped: outcome.dropped } : {}),
2795
+ });
2796
+ return {
2797
+ ...(await this.#finish(session, {
2798
+ changed: tx.paths,
2799
+ preserved: [],
2800
+ paths,
2801
+ ...(warnings.length ? { warnings } : {}),
2802
+ ...(current !== undefined
2803
+ ? {
2804
+ nextSteps: [
2805
+ {
2806
+ command: 'npx dokebi verify --json',
2807
+ why: `restart the app (dev restarts by itself on a dokebi.yaml change; a started app needs npx dokebi stop and start): it uses the new secret from then on. ${list
2808
+ ? `What the previous secret${outcome.kept === 1 ? '' : 's'} signed keeps verifying while ${outcome.kept === 1 ? 'it stays' : 'they stay'} in the list`
2809
+ : 'What the old value signed or opened no longer works'}; the plugin AI.md says what a rotation changes. An environment override of this option, where the plugin has one, still wins`,
2810
+ },
2811
+ ],
2812
+ }
2813
+ : {}),
2814
+ }, dryRun)),
2815
+ file: session.configFile,
2816
+ secret: outcome,
2817
+ };
2818
+ }
2819
+ catch (error) {
2820
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
2821
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
2822
+ throw tx.failure(error);
2823
+ }
2824
+ finally {
2825
+ await tx.close();
2826
+ }
2827
+ }
2828
+ /**
2829
+ * `dokebi config init` (D-0163, R3 Claude T4 review): a fresh copy of a project (a clone, a CI
2830
+ * checkout) has no dokebi.yaml, which stays out of version control, and no command made one:
2831
+ * `dev-infra up` failed with INTERNAL_ERROR. Writes it from dokebi.example.yaml with a generated
2832
+ * secret for every required secret option the sample leaves out (the sample holds none), and
2833
+ * names the placeholders (`<set … here>`) still to fill. Never replaces an existing file.
2834
+ */
2835
+ async configInit(root, dryRun = false) {
2836
+ root = await realpath(root);
2837
+ await projectStateText(root);
2838
+ const target = await configTarget(root);
2839
+ if (!target.inside)
2840
+ throw configOutsideProject(target);
2841
+ if (await exists(target.path))
2842
+ throw new CliError('CONFIG_EXISTS', `${target.file} already exists; config init writes a new one only`, {
2843
+ phase: 'arguments',
2844
+ details: {
2845
+ file: target.file,
2846
+ fix: 'nothing to do: npx dokebi config diff compares it with dokebi.example.yaml, npx dokebi config secret <id>.<path> fills a missing secret',
2847
+ },
2848
+ });
2849
+ let sample;
2850
+ try {
2851
+ sample = await readFile(await securePath(root, EXAMPLE), 'utf8');
2852
+ }
2853
+ catch (error) {
2854
+ if (!isMissing(error))
2855
+ throw new CliError('READ_FAILED', `Cannot read ${EXAMPLE}`, { cause: error });
2856
+ throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to start ${target.file} from`, {
2857
+ phase: 'arguments',
2858
+ details: {
2859
+ file: EXAMPLE,
2860
+ fix: `restore ${EXAMPLE} from version control (the CLI writes it and it belongs there)`,
2861
+ },
2862
+ });
2863
+ }
2864
+ const session = await this.#begin(root, sample);
2865
+ const { tx, state } = session;
2866
+ try {
2867
+ const validator = new PluginOptionsValidator({ allErrors: true });
2868
+ let source = session.configSource;
2869
+ const generatedSecrets = [];
2870
+ const generate = async (item, path) => {
2871
+ const section = targetSection(item);
2872
+ // As config secret (R4CA-5): an empty value (`token:`, `''`, `[]`) holds no secret.
2873
+ const found = optionAtPath(source, section, path);
2874
+ if (found !== undefined &&
2875
+ found !== null &&
2876
+ found !== '' &&
2877
+ !(Array.isArray(found) && !found.length))
2878
+ return;
2879
+ source = (await this.#withGeneratedSecret(session.root, validator, item, source, path))
2880
+ .source;
2881
+ const option = `${section}.${path.join('.')}`;
2882
+ generatedSecrets.push({
2883
+ plugin: 'settingsSection' in item ? item.settingsSection : item.id,
2884
+ option,
2885
+ file: session.configFile,
2886
+ });
2887
+ debug('config init generated a secret option', { option });
2888
+ };
2889
+ // A plugin's secrets are the ones `add` generates: its install plan's configSecrets (a
2890
+ // secret the user supplies, such as an SMTP password, is never made up).
2891
+ const projectPackages = Object.keys(await this.#installedDependencies(session.root));
2892
+ for (const plugin of state.plugins) {
2893
+ const plan = await planLifecycle({
2894
+ base: session.root,
2895
+ plugin,
2896
+ options: parseProjectConfig(source).options,
2897
+ plugins: state.plugins,
2898
+ projectPackages,
2899
+ }, 'planInstall');
2900
+ for (const path of plan.configSecrets)
2901
+ await generate(plugin, path);
2902
+ }
2903
+ // An application settings section (D-0155) has no install plan: its required options that
2904
+ // are secrets by schema mark or name (as `config show` hides them).
2905
+ for (const settingsSection of state.settings ?? []) {
2906
+ const item = { settingsSection };
2907
+ const checked = await checkTarget(validator, session.root, item, optionsOf(parseProjectConfig(source).options, settingsSection));
2908
+ for (const issue of checked.errors)
2909
+ if (issue.keyword === 'required' && issue.missingProperty) {
2910
+ const path = [...issuePathOf(issue.instancePath), issue.missingProperty];
2911
+ if (secretInSchema(checked.schema, path))
2912
+ await generate(item, path);
2913
+ }
2914
+ }
2915
+ const placeholders = placeholderPaths(parseProjectConfig(source).options);
2916
+ debug('config init writes the settings file from the sample', {
2917
+ file: session.configFile,
2918
+ secrets: generatedSecrets.length,
2919
+ placeholders: placeholders.length,
2920
+ });
2921
+ await tx.write(session.configFile, source);
2922
+ const databaseUrl = placeholders.some((path) => /^dokebi-db\..*\.url$/.test(path));
2923
+ const nextSteps = [
2924
+ ...(databaseUrl
2925
+ ? [
2926
+ {
2927
+ command: 'npx dokebi dev-infra --json',
2928
+ why: 'writes the development database URL into the placeholder (and compose.yaml when the project has none); then npx dokebi dev-infra up starts the database',
2929
+ },
2930
+ ]
2931
+ : []),
2932
+ ...(placeholders.some((path) => !(databaseUrl && /^dokebi-db\..*\.url$/.test(path)))
2933
+ ? [
2934
+ {
2935
+ command: 'npx dokebi config show --json',
2936
+ why: `fill the other placeholders with your file editor or ask the user for them: ${placeholders.filter((path) => !/^dokebi-db\..*\.url$/.test(path) || !databaseUrl).join(', ')}`,
2937
+ },
2938
+ ]
2939
+ : []),
2940
+ {
2941
+ command: 'npx dokebi doctor --json',
2942
+ why: "checks the new file against every plugin; the project's README says how its database is migrated and seeded",
2943
+ },
2944
+ ];
2945
+ return {
2946
+ ...(await this.#finish(session, {
2947
+ changed: tx.paths,
2948
+ preserved: [],
2949
+ ...(generatedSecrets.length ? { generatedSecrets } : {}),
2950
+ nextSteps,
2951
+ }, dryRun)),
2952
+ file: session.configFile,
2953
+ generatedSecrets,
2954
+ placeholders,
2955
+ };
2956
+ }
2957
+ catch (error) {
2958
+ throw tx.failure(error);
2959
+ }
2960
+ finally {
2961
+ await tx.close();
2962
+ }
2963
+ }
2964
+ /**
2965
+ * dokebi.yaml source with a new generated secret first in the list at `<id>.<path>` and the
2966
+ * previous ones after it (at most ROTATE_KEEP; fewer when the schema allows fewer items).
2967
+ * Undefined when no such list passes the schema (the caller then writes a single new secret).
2968
+ */
2969
+ async #rotatedList(root, validator, plugin, source, path, current, secret = randomBytes(32).toString('base64url')) {
2970
+ const id = targetSection(plugin);
2971
+ const key = (error) => `${error.keyword} ${error.instancePath}`;
2972
+ const options = (text) => optionsOf(parseProjectConfig(text).options, id);
2973
+ const before = new Set((await checkTarget(validator, root, plugin, options(source))).errors.map(key));
2974
+ for (let keep = Math.min(current.length, ROTATE_KEEP); keep >= 0; keep--) {
2975
+ const candidate = applySetOptions(source, id, [
2976
+ { path: [...path], value: [secret, ...current.slice(0, keep)] },
2977
+ ]);
2978
+ const checked = await checkTarget(validator, root, plugin, options(candidate));
2979
+ if (checked.errors.every((error) => error.keyword === 'required' || before.has(key(error))))
2980
+ return { source: candidate, list: true, kept: keep, dropped: current.length - keep };
2981
+ debug('Rotated secret list refused by the schema; keeping fewer', {
2982
+ path: `${id}.${path.join('.')}`,
2983
+ keep,
2984
+ errors: checked.errors.map(key),
2985
+ });
2986
+ }
2987
+ return undefined;
2988
+ }
2989
+ /**
2990
+ * User code a recipe writes (D-0076 §6, `recipe blog --admin login`), in one transaction under
2991
+ * the project lock. A new file is written; a file with the same content is left alone; a file
2992
+ * marked `replaceTemplate` is replaced only while it still is the untouched template its plugin
2993
+ * installed (the hash `state.files` recorded); anything else is RECIPE_FILE_CONFLICT. `edits`
2994
+ * change existing files (they return the text unchanged when already applied). `config` sets
2995
+ * values of a dokebi.yaml section no plugin owns; a `secret` value is generated (never returned
2996
+ * or logged) and an existing one is kept. Nothing is written when nothing changes.
2997
+ */
2998
+ async writeRecipeFiles(root, request) {
2999
+ const session = await this.#begin(root);
3000
+ const { tx, state } = session;
3001
+ try {
3002
+ const written = [];
3003
+ const kept = [];
3004
+ for (const file of request.files) {
3005
+ await tx.capture(file.path);
3006
+ const target = await securePath(session.root, file.path);
3007
+ const current = await fingerprint(target);
3008
+ if (current === hash(file.content)) {
3009
+ kept.push(file.path);
3010
+ continue;
3011
+ }
3012
+ if (current !== null && !(file.replaceTemplate && current === state.files[file.path]?.hash))
3013
+ throw new CliError('RECIPE_FILE_CONFLICT', `${file.path} exists and is not what the recipe writes`, {
3014
+ details: {
3015
+ path: file.path,
3016
+ fix: `${file.path} was edited or made elsewhere: the recipe never overwrites it. Move it away and run the same recipe command again, or write the change yourself`,
3017
+ },
3018
+ phase: 'plan',
3019
+ });
3020
+ const directory = dirname(file.path);
3021
+ if (directory !== '.')
3022
+ await tx.ensureDirectory(directory);
3023
+ await tx.write(file.path, file.content);
3024
+ written.push(file.path);
3025
+ }
3026
+ for (const change of request.edits ?? []) {
3027
+ const before = await tx.read(change.path);
3028
+ const after = change.edit(before);
3029
+ if (after === before)
3030
+ kept.push(change.path);
3031
+ else {
3032
+ await tx.write(change.path, after);
3033
+ written.push(change.path);
3034
+ }
3035
+ }
3036
+ let configSource = session.configSource;
3037
+ const generatedSecrets = [];
3038
+ if (request.config) {
3039
+ const { section, values } = request.config;
3040
+ if (state.plugins.some((plugin) => plugin.id === section))
3041
+ throw new CliError('INVALID_SET', `${section} is a plugin's options, not a recipe section`);
3042
+ for (const { key, value } of values) {
3043
+ if (optionAt(configSource, section, [key]) !== undefined)
3044
+ continue;
3045
+ // A value of undefined is a secret: generated here, never returned or logged.
3046
+ configSource = applySetOptions(configSource, section, [
3047
+ { path: [key], value: value ?? generatePassword() },
3048
+ ]);
3049
+ if (value === undefined)
3050
+ generatedSecrets.push({
3051
+ plugin: section,
3052
+ option: `${section}.${key}`,
3053
+ file: session.configFile,
3054
+ });
3055
+ }
3056
+ if (configSource !== session.configSource) {
3057
+ await tx.write(session.configFile, configSource);
3058
+ written.push(session.configFile);
1152
3059
  }
1153
3060
  }
1154
- if (chosen === undefined)
1155
- throw new CliError('INVALID_PLUGIN_OPTIONS', `${where} does not take a secret string`, {
1156
- details: { plugin: id, path: where },
1157
- });
1158
- await tx.write(CONFIG_FILE, chosen);
1159
- debug('Generated a secret option', { path: where, list });
3061
+ debug('Recipe files written', { written, kept, secrets: generatedSecrets.length });
1160
3062
  return {
1161
- ...(await this.#finish(session, { changed: tx.paths, preserved: [] }, dryRun)),
1162
- secret: { path: where, list, generated: true },
3063
+ ...(await this.#finish(session, { changed: tx.paths, preserved: [] })),
3064
+ ...(generatedSecrets.length ? { generatedSecrets } : {}),
3065
+ written,
3066
+ kept,
1163
3067
  };
1164
3068
  }
1165
3069
  finally {
@@ -1174,11 +3078,32 @@ export class Project {
1174
3078
  throw new CliError('CORE_UPGRADE_REQUIRED', 'Dokebi YAML projects require @dokebi/core 0.2.0 or newer');
1175
3079
  return pkg.version;
1176
3080
  }
1177
- /** Takes the project lock and reads the common state every mutation needs. */
1178
- async #begin(root) {
3081
+ /**
3082
+ * Takes the project lock and reads the common state every mutation needs. `fresh`: the text a
3083
+ * missing settings file starts from (config init, D-0163); every other command needs the file.
3084
+ */
3085
+ async #begin(root, fresh) {
1179
3086
  root = await realpath(root);
1180
3087
  // Do not create control files in arbitrary non-Dokebi projects.
1181
- await readFile(await securePath(root, '.dokebi/state.json'));
3088
+ await projectStateText(root);
3089
+ // M50: every changing command works on the settings file the app reads — DOKEBI_CONFIG when
3090
+ // set (from the project folder), else dokebi.yaml. Never a file outside the project
3091
+ // (UNSAFE_PATH, as a linked dokebi.yaml), never dokebi.yaml in place of a missing named file.
3092
+ const settings = await configTarget(root);
3093
+ if (!settings.inside)
3094
+ throw configOutsideProject(settings);
3095
+ if (settings.fromEnvironment && !(await exists(settings.path)))
3096
+ throw new CliError('READ_FAILED', `Cannot read ${settings.file}, the settings file DOKEBI_CONFIG names`, {
3097
+ phase: 'arguments',
3098
+ details: {
3099
+ file: settings.file,
3100
+ fix: 'point DOKEBI_CONFIG at the settings file the app reads, or unset it to use dokebi.yaml',
3101
+ },
3102
+ });
3103
+ const missing = !settings.fromEnvironment && !(await exists(settings.path));
3104
+ if (missing && fresh === undefined)
3105
+ throw notAProject(root, CONFIG_FILE);
3106
+ const configFile = settings.file;
1182
3107
  const workspaceRoot = await workspaceRootOf(root);
1183
3108
  const tx = await Transaction.begin(root);
1184
3109
  try {
@@ -1189,40 +3114,210 @@ export class Project {
1189
3114
  ? 'CONFIG_CONFLICT'
1190
3115
  : 'MIGRATION_REQUIRED', 'Run dokebi migrate after resolving conflicting configuration files');
1191
3116
  }
1192
- const configSource = await tx.read(CONFIG_FILE);
3117
+ let configSource;
3118
+ if (missing && fresh !== undefined) {
3119
+ await tx.capture(configFile);
3120
+ configSource = fresh;
3121
+ }
3122
+ else
3123
+ configSource = await tx.read(configFile);
1193
3124
  parseProjectConfig(configSource);
3125
+ // D-0154: what the CLI writes into dokebi.yaml and the example marks reloadable options:
3126
+ // with the schemas of the installed plugins and those staged in this session.
3127
+ const schemas = new Map();
3128
+ tx.transformText = async (path, text) => {
3129
+ if (path !== CONFIG_FILE && path !== configFile && path !== EXAMPLE)
3130
+ return text;
3131
+ const known = await reloadSchemas(root, state.plugins, state.settings ?? []);
3132
+ for (const [section, schema] of schemas)
3133
+ known.set(section, schema);
3134
+ return markReloadable(text, known);
3135
+ };
1194
3136
  const required = await readRequiredPlugins(root);
1195
3137
  const pkg = parseObject(await tx.read('package.json'), 'package.json');
1196
3138
  validateProjectPackage(pkg);
1197
3139
  if (hash(await tx.read(GENERATED)) !== state.generatedHash)
1198
- throw new CliError('MODIFIED_REGISTRATION', GENERATED);
3140
+ throw modifiedRegistration();
1199
3141
  if (!workspaceRoot)
1200
- return { root, tx, state, configSource, required, pkg };
3142
+ return { root, tx, schemas, state, configSource, configFile, required, pkg };
1201
3143
  const workspace = {
1202
3144
  root: workspaceRoot,
1203
3145
  packages: await workspacePackages(workspaceRoot),
1204
3146
  lock: await readOptional(join(workspaceRoot, 'package-lock.json')),
1205
3147
  };
1206
- return { root, tx, state, configSource, required, pkg, workspace };
3148
+ return { root, tx, schemas, state, configSource, configFile, required, pkg, workspace };
1207
3149
  }
1208
3150
  catch (error) {
1209
3151
  await tx.close();
1210
3152
  throw error;
1211
3153
  }
1212
3154
  }
1213
- async #stage(session, pkg, dependencies, spec) {
3155
+ async #stage(session, pkg, dependencies, spec, companions = []) {
3156
+ try {
3157
+ return await this.#stageProject(session, pkg, dependencies, spec, companions);
3158
+ }
3159
+ catch (error) {
3160
+ // Ctrl+C during the staging install: npm got the signal and failed; nothing was applied.
3161
+ if (session.tx.interrupted)
3162
+ throw interruptedError(session.tx.interrupted, error);
3163
+ const mismatch = await this.#registryMismatch(session, error);
3164
+ throw mismatch === error ? await this.#peerConflict(session, spec, error) : mismatch;
3165
+ }
3166
+ }
3167
+ /**
3168
+ * npm's ERESOLVE for a named version (`add <package>@<version>`, `upgrade --to <version>`)
3169
+ * whose peers do not accept what the project has installed (a session that needs a newer
3170
+ * @dokebi/web): the error keeps npm's words and gets `details.conflicts` and a `fix` that
3171
+ * upgrades the installed package first. Anything else is returned as it was.
3172
+ */
3173
+ async #peerConflict(session, spec, error) {
3174
+ if (!(error instanceof CliError) || error.code !== 'NPM_FAILED' || !spec)
3175
+ return error;
3176
+ const details = (error.details ?? {});
3177
+ const match = /^((?:@[^/@]+\/)?[^@]+)@(.+)$/.exec(spec);
3178
+ if (details.npmCode !== 'ERESOLVE' || !match?.[1] || !match[2] || !validVersion(match[2]))
3179
+ return error;
3180
+ const [, name, version] = match;
3181
+ const found = await fetchPackument(name, session.root);
3182
+ const manifest = found.status === 'found' ? found.versions[version] : undefined;
3183
+ if (!manifest)
3184
+ return error;
3185
+ const constraints = this.#constraints(session, await this.coreVersion(session.root), name);
3186
+ const conflicts = versionConflicts(version, manifest, constraints);
3187
+ debug('Peer ranges of the named version checked after ERESOLVE', { spec, conflicts });
3188
+ if (!conflicts.length)
3189
+ return error;
3190
+ return new CliError('NPM_FAILED', `${error.message}: ${describeConflicts(name, version, conflicts)}`, {
3191
+ cause: error,
3192
+ details: {
3193
+ ...Object.fromEntries(Object.entries(details).filter(([key]) => key !== 'hint')),
3194
+ conflicts,
3195
+ fix: conflictFix(conflicts, constraints, 'repeat this command'),
3196
+ },
3197
+ ...(error.phase ? { phase: error.phase } : {}),
3198
+ });
3199
+ }
3200
+ /**
3201
+ * D-0138: npm failed in staging. When packages this project has installed (exact registry
3202
+ * versions) are missing from the registry npm uses now, that is the cause, whatever npm says
3203
+ * (ERESOLVE "Found: @dokebi/core@undefined" in AI-M27): REGISTRY_MISMATCH names them, the
3204
+ * registry and where the lock says they came from, so nobody retries with --legacy-peer-deps
3205
+ * (which installed @dokebi/module 0.4.0 from the public registry next to the run's 0.4.2 core).
3206
+ * Any other failure is returned as it was.
3207
+ */
3208
+ async #registryMismatch(session, error) {
3209
+ if (!(error instanceof CliError) || error.code !== 'NPM_FAILED')
3210
+ return error;
3211
+ const details = (error.details ?? {});
3212
+ if (details.network === true || details.timedOut === true)
3213
+ return error;
3214
+ let lock = {};
3215
+ const lockText = session.workspace
3216
+ ? session.workspace.lock
3217
+ : (await exists(await securePath(session.tx.root, 'package-lock.json')))
3218
+ ? await session.tx.read('package-lock.json')
3219
+ : undefined;
3220
+ if (typeof lockText === 'string') {
3221
+ try {
3222
+ lock = object(JSON.parse(lockText).packages ?? {}, 'lock packages');
3223
+ }
3224
+ catch (cause) {
3225
+ debug('package-lock.json not readable; installed origins unknown', {
3226
+ error: describeError(cause),
3227
+ });
3228
+ }
3229
+ }
3230
+ const installed = DEPENDENCY_SECTIONS.flatMap((section) => Object.entries(object(session.pkg[section] ?? {}, section)).flatMap(([name, spec]) => {
3231
+ if (!name.startsWith('@dokebi/') || session.workspace?.packages.has(name))
3232
+ return [];
3233
+ if (typeof spec !== 'string' || validVersion(spec) !== spec)
3234
+ return [];
3235
+ const entry = lock[`node_modules/${name}`];
3236
+ const resolved = typeof entry?.resolved === 'string' ? entry.resolved : undefined;
3237
+ return [{ package: name, version: spec, ...(resolved ? { resolved } : {}) }];
3238
+ }));
3239
+ const missing = await missingOnRegistry(installed, session.root);
3240
+ debug('Installed packages checked against the registry after an npm failure', {
3241
+ checked: installed.length,
3242
+ missing,
3243
+ });
3244
+ if (!missing.length)
3245
+ return error;
3246
+ // The core first (every add picks its version for it), then at most two more by name.
3247
+ const named = [...missing].sort((a, b) => a.package === '@dokebi/core' ? -1 : b.package === '@dokebi/core' ? 1 : 0);
3248
+ const list = `${named
3249
+ .slice(0, 3)
3250
+ .map((item) => `${item.package} ${item.version}`)
3251
+ .join(', ')}${named.length > 3 ? ` and ${named.length - 3} more (details.missing)` : ''}`;
3252
+ const registries = [...new Set(missing.map((item) => item.registry))].join(', ');
3253
+ const origins = [
3254
+ ...new Set(named.flatMap((item) => (item.installedFrom ? [item.installedFrom] : []))),
3255
+ ];
3256
+ return new CliError('REGISTRY_MISMATCH', `npm now installs from ${registries}, which does not have ${list} that this project has installed${origins.length ? ` (package-lock.json: ${origins[0]})` : ''}`, {
3257
+ cause: error,
3258
+ details: {
3259
+ // npm's words stay; its generic ERESOLVE hint gives way to the fix below.
3260
+ ...Object.fromEntries(Object.entries(details).filter(([key]) => key !== 'hint')),
3261
+ stage: 'staging',
3262
+ missing,
3263
+ fix: `run dokebi with the npm configuration that installed this project: npm config get @dokebi:registry in the project folder must name the registry in package-lock.json's resolved URLs (the project .npmrc, npm_config_userconfig or npm_config_@dokebi:registry), then run the same command again. Do not retry with --legacy-peer-deps or --force: npm would install packages from ${registries} next to a core that registry does not have.`,
3264
+ },
3265
+ phase: 'install',
3266
+ });
3267
+ }
3268
+ async #stageProject(session, pkg, dependencies, spec, companions = []) {
1214
3269
  const { tx, workspace } = session;
1215
3270
  const staging = join(tx.directory, 'npm');
1216
3271
  await mkdir(staging);
1217
3272
  // npm in staging reads the project's .npmrc too (the registry resolution did, D-0065).
1218
3273
  await stageNpmConfig(workspace?.root ?? session.root, staging);
1219
3274
  if (!workspace) {
3275
+ // Tarballs the project names by a relative path (`file:vendor/x.tgz`): the same path in
3276
+ // staging, so npm resolves them there and the files it writes back keep them relative.
3277
+ const copy = async (path) => {
3278
+ // Checked before anything is created: no folder is made for a path that is refused.
3279
+ const source = await securePath(tx.root, path);
3280
+ // One the folder lacks (an ignored vendor/): npm in staging names it with NPM_FAILED, as
3281
+ // it does an absolute path (R6-cli-2: a bare ENOENT of the copy was INTERNAL_ERROR).
3282
+ if (!(await exists(source))) {
3283
+ debug('A project tarball is missing; the staging install reports it', { path });
3284
+ return;
3285
+ }
3286
+ // A folder (or another non-file) at the tarball's path: npm cannot install it either, and
3287
+ // the copy failed with a bare EISDIR or ENOTSUP, an INTERNAL_ERROR (R7-cli-2). securePath
3288
+ // has refused a symbolic link already.
3289
+ if (!(await lstat(source)).isFile()) {
3290
+ debug('A project tarball path is not a file; refused', { path });
3291
+ throw new CliError('UNSUPPORTED_PROJECT', `A file: tarball is not a file: ${path}`, {
3292
+ details: {
3293
+ path,
3294
+ fix: `put the package's .tgz at ${path}, or change its dependency in package.json`,
3295
+ },
3296
+ });
3297
+ }
3298
+ await mkdir(dirname(join(staging, path)), { recursive: true });
3299
+ await copyFile(source, join(staging, path));
3300
+ };
3301
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
3302
+ const listed = (section === 'dependencies' ? dependencies : pkg[section]) ?? {};
3303
+ for (const value of Object.values(listed)) {
3304
+ const path = typeof value === 'string' ? projectTarball(value) : undefined;
3305
+ if (path === undefined)
3306
+ continue;
3307
+ await copy(path);
3308
+ }
3309
+ }
3310
+ // A tarball named on the command line inside the project, made relative by projectSpec.
3311
+ const named = spec === undefined ? undefined : projectTarball(spec);
3312
+ if (named !== undefined)
3313
+ await copy(named);
1220
3314
  await writeFile(join(staging, 'package.json'), json({ ...pkg, dependencies }));
1221
3315
  const lock = await securePath(tx.root, 'package-lock.json');
1222
3316
  if (await exists(lock))
1223
3317
  await writeFile(join(staging, 'package-lock.json'), await tx.read('package-lock.json'));
1224
3318
  await tx.capture('node_modules');
1225
- await this.npm.install(staging, spec);
3319
+ // Required plugins the package brings, pinned in the same run (companionVersions).
3320
+ await this.npm.install(staging, companions.length ? [spec ?? '', ...companions].filter(Boolean) : spec);
1226
3321
  return staging;
1227
3322
  }
1228
3323
  // A standalone copy of the member. Workspace packages (maybe unpublished) come in as packed
@@ -1242,7 +3337,7 @@ export class Project {
1242
3337
  ...Object.fromEntries(packs),
1243
3338
  };
1244
3339
  await writeFile(join(staging, 'package.json'), json(staged));
1245
- await this.#stageInstall(session, staging, spec);
3340
+ await this.#stageInstall(session, staging, companions.length ? [spec ?? '', ...companions].filter(Boolean) : spec);
1246
3341
  return staging;
1247
3342
  }
1248
3343
  /**
@@ -1251,23 +3346,31 @@ export class Project {
1251
3346
  * differs from the direct dependency) and installed from there (D-0035 §3).
1252
3347
  */
1253
3348
  async #stageInstall(session, staging, spec) {
3349
+ const specs = spec === undefined ? [] : typeof spec === 'string' ? [spec] : [...spec];
1254
3350
  const workspace = session.workspace;
1255
- const local = workspace && spec !== undefined && this.#workspacePackage(workspace, spec);
1256
- if (!local)
1257
- return this.npm.install(staging, spec);
1258
- const packs = await this.#packWorkspace(session, [local.name]);
3351
+ const locals = workspace
3352
+ ? specs.flatMap((item) => {
3353
+ const found = this.#workspacePackage(workspace, item);
3354
+ return found ? [{ spec: item, name: found.name }] : [];
3355
+ })
3356
+ : [];
3357
+ // One npm run for every spec (D-0116: a dependency level of add or upgrade at once).
3358
+ const remote = specs.filter((item) => !locals.some((local) => local.spec === item));
3359
+ if (!locals.length)
3360
+ return this.npm.install(staging, remote.length ? remote : undefined);
3361
+ const packs = await this.#packWorkspace(session, locals.map((local) => local.name));
1259
3362
  const path = join(staging, 'package.json');
1260
3363
  const staged = parseObject(await readFile(path, 'utf8'), 'staged');
1261
3364
  staged.dependencies = {
1262
3365
  ...object(staged.dependencies ?? {}, 'dependencies'),
1263
- [local.name]: packs.get(local.name),
3366
+ ...Object.fromEntries(locals.map((local) => [local.name, packs.get(local.name)])),
1264
3367
  };
1265
3368
  staged.overrides = {
1266
3369
  ...object(staged.overrides ?? {}, 'overrides'),
1267
3370
  ...Object.fromEntries(packs),
1268
3371
  };
1269
3372
  await writeFile(path, json(staged));
1270
- await this.npm.install(staging);
3373
+ await this.npm.install(staging, remote.length ? remote : undefined);
1271
3374
  }
1272
3375
  /**
1273
3376
  * `file:` archives of the named workspace packages and every workspace package they depend on
@@ -1314,6 +3417,28 @@ export class Project {
1314
3417
  throw new CliError('WORKSPACE_VERSION_MISMATCH', `${match[1]} is ${found.version} in the workspace`, { details: { package: match[1], workspace: found.version, requested: range } });
1315
3418
  return { name: match[1], spec: `file:${found.directory}` };
1316
3419
  }
3420
+ /**
3421
+ * Whether the staged install holds other content of `name` than the project's: the
3422
+ * package-lock integrity of `node_modules/<name>` differs (both must be known; a workspace
3423
+ * member's lock lives at the workspace root and is not compared).
3424
+ */
3425
+ async #contentChanged(session, staging, name) {
3426
+ if (session.workspace)
3427
+ return false;
3428
+ const integrity = (text) => {
3429
+ if (text === undefined || text === null)
3430
+ return undefined;
3431
+ const packages = (parseObject(text, 'package-lock.json').packages ?? {});
3432
+ const value = packages[`node_modules/${name}`]?.integrity;
3433
+ return typeof value === 'string' ? value : undefined;
3434
+ };
3435
+ const lock = await securePath(session.tx.root, 'package-lock.json');
3436
+ const before = integrity((await exists(lock)) ? await session.tx.read('package-lock.json') : undefined);
3437
+ const after = integrity(await readOptional(join(staging, 'package-lock.json')));
3438
+ const changed = before !== undefined && after !== undefined && before !== after;
3439
+ debug('Same-version upgrade compares the package content', { name, changed });
3440
+ return changed;
3441
+ }
1317
3442
  async #installedDependencies(staging) {
1318
3443
  return dependencyMap(parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'installed package'));
1319
3444
  }
@@ -1325,7 +3450,38 @@ export class Project {
1325
3450
  for (const plugin of plugins) {
1326
3451
  const actual = await this.#readPackageManifest(staging, plugin.packageName);
1327
3452
  if (json(actual) !== json(plugin))
1328
- throw new CliError('MANIFEST_CHANGED', plugin.id);
3453
+ throw stagedManifestChanged(plugin, actual);
3454
+ }
3455
+ }
3456
+ /**
3457
+ * The other registered plugins of an upgrade come out of staging as recorded, or as the project
3458
+ * has them installed already. The second case is a package.json that moved several plugins at
3459
+ * once (`npm install`/`npm update`, doctor MANIFEST_CHANGED for each): such a plugin stays
3460
+ * recorded as it was and is reported with its own `dokebi upgrade`, so the plugins can be
3461
+ * upgraded one after another. A plugin that npm changes in staging is still MANIFEST_CHANGED.
3462
+ */
3463
+ async #verifyUpgradeOthers(session, staging, others, warnings) {
3464
+ for (const plugin of others) {
3465
+ const actual = await this.#readPackageManifest(staging, plugin.packageName);
3466
+ if (json(actual) === json(plugin))
3467
+ continue;
3468
+ const file = await packageFile(session.root, plugin.packageName, 'package.json');
3469
+ const installed = (await exists(file))
3470
+ ? readManifest(parseObject(await readFile(file, 'utf8'), plugin.packageName))
3471
+ : undefined;
3472
+ if (!installed || json(installed) !== json(actual))
3473
+ throw stagedManifestChanged(plugin, actual);
3474
+ debug('Upgrade leaves a plugin whose installed package already changed', {
3475
+ plugin: plugin.id,
3476
+ recorded: plugin.version,
3477
+ installed: installed.version,
3478
+ });
3479
+ warnings.push({
3480
+ code: 'MANIFEST_CHANGED',
3481
+ path: `node_modules/${plugin.packageName}/package.json`,
3482
+ plugin: plugin.id,
3483
+ hint: `dokebi upgrade ${plugin.id}`,
3484
+ });
1329
3485
  }
1330
3486
  }
1331
3487
  async #order(staging, plugins, required) {
@@ -1358,10 +3514,19 @@ export class Project {
1358
3514
  const result = checks.get(plugin.id) ?? { requires: [], diagnostics: [] };
1359
3515
  const requires = plugin.id === target.id ? union(target.requires, result.requires) : result.requires;
1360
3516
  const missing = requires.filter((id) => !installed.has(id));
1361
- if (missing.length)
1362
- throw new CliError('MISSING_REQUIRED_PLUGIN', plugin.id, {
1363
- details: { plugin: plugin.id, requires: missing },
3517
+ if (missing.length) {
3518
+ const catalog = await loadCatalog();
3519
+ const packages = missing.map((id) => findPackage(catalog, id)?.package ?? id);
3520
+ throw new CliError('MISSING_REQUIRED_PLUGIN', `${plugin.id} ${plugin.version} requires ${missing.join(', ')} with the current settings, and ${missing.length === 1 ? 'it is' : 'they are'} not installed`, {
3521
+ details: {
3522
+ plugin: plugin.id,
3523
+ version: plugin.version,
3524
+ requires: missing,
3525
+ fix: `${packages.map((name) => `npx dokebi add ${name}`).join(', then ')}, or change the options of ${plugin.id} that need ${missing.length === 1 ? 'it' : 'them'}`,
3526
+ },
3527
+ phase: 'plan',
1364
3528
  });
3529
+ }
1365
3530
  installations[plugin.id] = {
1366
3531
  ...(installations[plugin.id] ?? defaultInstallation(plugin)),
1367
3532
  requires,
@@ -1387,14 +3552,22 @@ export class Project {
1387
3552
  });
1388
3553
  continue;
1389
3554
  }
1390
- throw new CliError('PLUGIN_CHECK_FAILED', plugin.id, {
1391
- details: { plugin: plugin.id, diagnostics: errors.slice(0, 20) },
3555
+ const first = errors[0];
3556
+ throw new CliError('PLUGIN_CHECK_FAILED', `The check of ${plugin.id} ${plugin.version} failed: ${first.code}${first.optionPath?.length ? ` at options.${optionsKey(plugin)}.${first.optionPath.join('.')}` : ''}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}`, {
3557
+ details: {
3558
+ plugin: plugin.id,
3559
+ diagnostics: errors.slice(0, 20),
3560
+ fix: typeof first.hint === 'string'
3561
+ ? first.hint
3562
+ : `correct what details.diagnostics names (npx dokebi plugins doc ${plugin.id} --json), then repeat this command`,
3563
+ },
3564
+ phase: 'plan',
1392
3565
  });
1393
3566
  }
1394
3567
  }
1395
3568
  async #validateOptions(validator, staging, ordered, config, configRequired) {
1396
3569
  for (const plugin of ordered) {
1397
- const checked = await validator.check(staging, plugin, optionsOf(config.options, plugin.id));
3570
+ const checked = await validator.check(staging, plugin, optionsOf(config.options, optionsKey(plugin)));
1398
3571
  if (!checked.errors.length)
1399
3572
  continue;
1400
3573
  // Only missing required values (this plugin or one installed earlier) do not block the
@@ -1406,15 +3579,8 @@ export class Project {
1406
3579
  errorCount: checked.errors.length,
1407
3580
  });
1408
3581
  }
1409
- else {
1410
- throw new CliError('INVALID_PLUGIN_OPTIONS', plugin.id, {
1411
- details: {
1412
- plugin: plugin.id,
1413
- errors: checked.errors.slice(0, 20),
1414
- errorCount: checked.errors.length,
1415
- },
1416
- });
1417
- }
3582
+ else
3583
+ throw invalidPluginOptions(plugin.id, checked.errors, 'plan', optionsKey(plugin), 'schema', checked.schema);
1418
3584
  }
1419
3585
  }
1420
3586
  #planFiles(plan) {
@@ -1434,12 +3600,12 @@ export class Project {
1434
3600
  if (owned) {
1435
3601
  // A shared file needs the same content and the same owner class from every plugin.
1436
3602
  if (current !== digest || owned.hash !== digest || owned.owner !== owner)
1437
- throw new CliError('FILE_CONFLICT', file.target);
3603
+ throw fileConflict(file.target, `is shared with ${owned.owners.join(', ')}, and ${plugin.id} ${plugin.version} brings other content or another owner for it`, `the packages disagree about this file: install versions that agree, or report it to the authors of ${plugin.packageName}`, { plugin: plugin.id, owners: owned.owners });
1438
3604
  owned.owners.push(plugin.id);
1439
3605
  }
1440
3606
  else {
1441
3607
  if (current !== null)
1442
- throw new CliError('FILE_CONFLICT', file.target);
3608
+ throw fileConflict(file.target, `already exists and ${plugin.id} ${plugin.version} would write it`, `move ${file.target} away (it is not the CLI's), then repeat this command; put your content back into the new file afterwards`, { plugin: plugin.id });
1443
3609
  await tx.write(file.target, override.get(file.target) ?? content);
1444
3610
  state.files[file.target] = { hash: digest, owners: [plugin.id], owner };
1445
3611
  }
@@ -1479,6 +3645,12 @@ export class Project {
1479
3645
  details.push({ path, reason: 'SHARED', ...(update ? { update } : {}) });
1480
3646
  continue;
1481
3647
  }
3648
+ if (source === undefined && owned.owner === 'user') {
3649
+ // Retiring a scaffold is not authorization to delete the user's resource.
3650
+ details.push({ path, reason: 'RETIRED_USER_FILE' });
3651
+ delete state.files[path];
3652
+ continue;
3653
+ }
1482
3654
  if (source === undefined) {
1483
3655
  await tx.replace(path);
1484
3656
  delete state.files[path];
@@ -1494,11 +3666,8 @@ export class Project {
1494
3666
  await this.#addFiles(tx, staging, state, plugin, [...planned.values()]);
1495
3667
  return details;
1496
3668
  }
1497
- async #syncExample(tx, root, id, warnings, update) {
1498
- await this.#syncExamples(tx, root, warnings, [{ id, update }]);
1499
- }
1500
3669
  /** Applies several plugin updates to dokebi.example.yaml and writes it once. */
1501
- async #syncExamples(tx, root, warnings, updates) {
3670
+ async #syncExamples(tx, root, warnings, updates, before = (source) => source) {
1502
3671
  if (!(await exists(await securePath(root, EXAMPLE)))) {
1503
3672
  warnings.push({ code: 'EXAMPLE_MISSING', path: EXAMPLE });
1504
3673
  return;
@@ -1506,6 +3675,7 @@ export class Project {
1506
3675
  const source = await tx.read(EXAMPLE);
1507
3676
  let next = source;
1508
3677
  try {
3678
+ next = before(next);
1509
3679
  for (const { id, update } of updates)
1510
3680
  next = syncExample(next, id, update);
1511
3681
  }
@@ -1596,12 +3766,33 @@ export class Project {
1596
3766
  await this.npm.installWorkspace(workspace.root);
1597
3767
  }
1598
3768
  catch (cause) {
1599
- throw new CliError('WORKSPACE_SYNC_FAILED', 'Project files were rolled back; run npm install at the workspace root', {
1600
- cause: new AggregateError([failure, cause]),
1601
- details: { workspaceRoot: workspace.root },
3769
+ // A rollback that kept user changes (RECOVERY_REQUIRED) stays in the cause and message.
3770
+ const recovery = error instanceof CliError && error.code === 'RECOVERY_REQUIRED' ? error : undefined;
3771
+ throw new CliError('WORKSPACE_SYNC_FAILED', recovery
3772
+ ? `Project rollback needs recovery (${recovery.message}); then run npm install at the workspace root`
3773
+ : 'Project files were rolled back; run npm install at the workspace root', {
3774
+ cause: new AggregateError(recovery ? [recovery, failure, cause] : [failure, cause]),
3775
+ details: {
3776
+ workspaceRoot: workspace.root,
3777
+ ...(recovery ? { recovery: recovery.details } : {}),
3778
+ },
1602
3779
  });
1603
3780
  }
1604
- throw error instanceof CliError && error.code === 'RECOVERY_REQUIRED' ? error : failure;
3781
+ if (error instanceof CliError && error.code === 'RECOVERY_REQUIRED')
3782
+ throw error;
3783
+ // R4CB-9: the project files, the root lock and node_modules are back, which is what
3784
+ // COMMIT_FAILED says (mutation rolled-back); the root install's own error was thrown
3785
+ // before, and an envelope then said mutation unknown although nothing stayed changed.
3786
+ const failed = failure instanceof CliError ? failure : undefined;
3787
+ throw new CliError('COMMIT_FAILED', `Changes were rolled back: ${failed?.message ?? describeError(failure).split('\n')[0]}`, {
3788
+ cause: failure,
3789
+ details: {
3790
+ failed: failed?.code ?? 'INTERNAL_ERROR',
3791
+ ...(failed?.details && typeof failed.details === 'object' ? failed.details : {}),
3792
+ workspaceRoot: workspace.root,
3793
+ },
3794
+ phase: 'commit',
3795
+ });
1605
3796
  }
1606
3797
  return result;
1607
3798
  }
@@ -1613,24 +3804,70 @@ export class Project {
1613
3804
  const session = await this.#begin(root);
1614
3805
  const { tx, state } = session;
1615
3806
  try {
3807
+ if (options.view !== undefined && options.inertia)
3808
+ throw new CliError('USAGE', '--view and --inertia both choose the view: give one', {
3809
+ details: {
3810
+ fix: 'an Inertia app: --inertia; EJS pages: --view ejs; no views: --view none',
3811
+ },
3812
+ phase: 'arguments',
3813
+ });
3814
+ // D-0098: an app of a project without screens is a JSON API too, unless asked otherwise.
3815
+ const recipeView = options.inertia || options.view ? undefined : await projectView(session.root);
3816
+ const view = options.inertia
3817
+ ? undefined
3818
+ : (options.view ?? (recipeView === NO_VIEW ? 'none' : 'ejs'));
3819
+ if (view === 'none' && (options.template ?? 'default') !== 'default')
3820
+ throw new CliError('USAGE', 'An app without views (--view none) has no templates (--template)', {
3821
+ details: { fix: 'leave out --template, or choose EJS pages with --view ejs' },
3822
+ phase: 'arguments',
3823
+ });
3824
+ debug('app add: the view of the app', {
3825
+ app: name,
3826
+ view: view ?? 'inertia',
3827
+ from: options.view === undefined && !options.inertia ? 'recipe' : 'flags',
3828
+ });
1616
3829
  const host = await this.#requireHost(state, 'apps');
1617
- const added = await this.#addApp(tx, session.root, host, state, session.configSource, name, options);
3830
+ // D-0102: `--domain auto` is this project's development host `<app>.<project>.localhost`.
3831
+ const label = projectHostLabel(session.pkg.name, session.root);
3832
+ const domains = options.domains?.map((domain) => domain === AUTO_DOMAIN ? devHost(name, label) : domain);
3833
+ const added = await this.#addApp(tx, session.root, host, state.plugins, state, session.configSource, name, {
3834
+ ...options,
3835
+ ...(domains ? { domains } : {}),
3836
+ ...(view ? { view } : {}),
3837
+ });
1618
3838
  const warnings = [];
1619
3839
  if (options.inertia && !(await this.#providesInstalled(state, 'view.inertia')))
1620
3840
  warnings.push({ code: 'INERTIA_PLUGIN_MISSING', path: `src/apps/${name}` });
3841
+ // A sealed domain without the provider fails the start (SEAL_PROVIDER_MISSING).
3842
+ if (options.seal && !(await this.#providesInstalled(state, 'security.seal')))
3843
+ warnings.push({ code: 'SEAL_PLUGIN_MISSING', path: `src/apps/${name}` });
1621
3844
  if (options.ssr && !options.inertia)
1622
3845
  throw new CliError('USAGE', 'Server rendering (--ssr) needs --inertia');
3846
+ // D-0062 §5: SSR writes the page props into plain HTML, so @dokebi/template-inertia refuses
3847
+ // to start an SSR scope on a seal host. Refuse the pair here instead of at the first start.
3848
+ if (options.ssr && options.seal)
3849
+ throw new CliError('USAGE', 'Server rendering (--ssr) cannot be sealed (--seal): SSR writes the page props into the first HTML as plain text, so the start fails with SEAL_SSR_CONFLICT. Choose one with the user.');
1623
3850
  if (options.inertia)
1624
3851
  await this.#inertiaBuild(tx, session, host, warnings, options.ssr === true);
1625
3852
  // Domains are not secrets: the shared sample keeps them so `setup` scripts see them too.
1626
3853
  await this.#syncExamples(tx, session.root, warnings, [
1627
3854
  { id: host.id, update: { changes: added.changes } },
3855
+ ...(added.layout
3856
+ ? [{ id: added.layout.engine, update: { changes: added.layout.changes } }]
3857
+ : []),
1628
3858
  ]);
1629
- await this.writeConfiguration(tx, state, parseAndRender(added.configSource));
3859
+ await this.writeConfiguration(tx, state, parseAndRender(added.configSource), session.configFile);
1630
3860
  const result = {
1631
3861
  changed: tx.paths,
1632
3862
  preserved: [],
1633
3863
  ...(warnings.length ? { warnings } : {}),
3864
+ // What verify's tellUser will say about this app's view (view-choice.ts recordAppView).
3865
+ appView: {
3866
+ value: appViewValue(options.inertia === true, options.ssr === true, view === 'none'),
3867
+ from: options.view === undefined && !options.inertia && recipeView === NO_VIEW
3868
+ ? 'recipe'
3869
+ : 'flags',
3870
+ },
1634
3871
  ...(options.inertia
1635
3872
  ? {
1636
3873
  nextSteps: [
@@ -1655,7 +3892,7 @@ export class Project {
1655
3892
  try {
1656
3893
  const host = await this.#requireHost(state, 'apps');
1657
3894
  if (!(state.apps ?? []).includes(name))
1658
- throw new CliError('UNKNOWN_APP', name);
3895
+ throw unknownApp(name, state.apps ?? []);
1659
3896
  const template = options.template ?? 'default';
1660
3897
  if (!APP_NAME.test(template))
1661
3898
  throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
@@ -1663,14 +3900,16 @@ export class Project {
1663
3900
  throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
1664
3901
  const current = domainTarget(parseProjectConfig(session.configSource).options, host.id, domain);
1665
3902
  if (current && current !== name)
1666
- throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
3903
+ throw domainInUse(domain, current);
1667
3904
  const views = `src/apps/${name}/views/${template}`;
1668
3905
  if (template !== 'default' && !(await exists(await securePath(tx.root, views))))
1669
3906
  await tx.ensureDirectory(views);
1670
- const changes = domainChanges([domain], template === 'default' ? { app: name } : { app: name, template });
3907
+ const changes = domainChanges([domain], domainTargetOf(name, template, options.seal));
1671
3908
  const warnings = [];
3909
+ if (options.seal && !(await this.#providesInstalled(state, 'security.seal')))
3910
+ warnings.push({ code: 'SEAL_PLUGIN_MISSING', path: `src/apps/${name}` });
1672
3911
  await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
1673
- await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, host.id, changes));
3912
+ await tx.write(session.configFile, applyConfigChanges(session.configSource, host.id, changes));
1674
3913
  const result = {
1675
3914
  changed: tx.paths,
1676
3915
  preserved: [],
@@ -1689,11 +3928,17 @@ export class Project {
1689
3928
  try {
1690
3929
  const host = await this.#requireHost(state, 'apps');
1691
3930
  if (!domainTarget(parseProjectConfig(session.configSource).options, host.id, domain))
1692
- throw new CliError('UNKNOWN_DOMAIN', domain);
3931
+ throw new CliError('UNKNOWN_DOMAIN', `No app is linked to the domain ${domain}`, {
3932
+ details: {
3933
+ domain,
3934
+ fix: 'npx dokebi app list --json lists the linked domains; unlink one of those',
3935
+ },
3936
+ phase: 'arguments',
3937
+ });
1693
3938
  const changes = [{ op: 'remove', path: ['domains', domain] }];
1694
3939
  const warnings = [];
1695
3940
  await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
1696
- await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, host.id, changes));
3941
+ await tx.write(session.configFile, applyConfigChanges(session.configSource, host.id, changes));
1697
3942
  const result = {
1698
3943
  changed: tx.paths,
1699
3944
  preserved: [],
@@ -1712,10 +3957,17 @@ export class Project {
1712
3957
  try {
1713
3958
  const host = await this.#requireHost(state, 'apps');
1714
3959
  if (!(state.apps ?? []).includes(name))
1715
- throw new CliError('UNKNOWN_APP', name);
3960
+ throw unknownApp(name, state.apps ?? []);
1716
3961
  const domains = appDomains(parseProjectConfig(session.configSource).options, host.id, name);
1717
3962
  if (domains.length)
1718
- throw new CliError('APP_IN_USE', name, { details: { app: name, domains } });
3963
+ throw new CliError('APP_IN_USE', `The app ${name} still serves ${domains.map((entry) => entry.domain).join(', ')}; remove its domains first`, {
3964
+ details: {
3965
+ app: name,
3966
+ domains,
3967
+ fix: `${domains.map((entry) => `npx dokebi app unlink ${entry.domain}`).join(', then ')}, then repeat this command`,
3968
+ },
3969
+ phase: 'arguments',
3970
+ });
1719
3971
  state.apps = (state.apps ?? []).filter((app) => app !== name);
1720
3972
  if (!state.apps.length)
1721
3973
  delete state.apps;
@@ -1736,13 +3988,26 @@ export class Project {
1736
3988
  if (next !== example)
1737
3989
  await tx.write(EXAMPLE, next);
1738
3990
  }
1739
- await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, host.id, name));
3991
+ await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, host.id, name), session.configFile);
3992
+ // The folder is user code and stays; say so (studio request Q: an `ok` alone read as if
3993
+ // everything went).
3994
+ const folder = `src/apps/${name}`;
3995
+ if (await exists(await securePath(session.root, folder)))
3996
+ warnings.push({ code: 'APP_FOLDER_KEPT', path: folder });
1740
3997
  const result = {
1741
3998
  changed: tx.paths,
1742
- preserved: [`src/apps/${name}`],
3999
+ preserved: [folder],
1743
4000
  ...(warnings.length ? { warnings } : {}),
1744
4001
  };
1745
- return await this.#finish(session, result, dryRun);
4002
+ const finished = await this.#finish(session, result, dryRun);
4003
+ // The view choice recorded for the app (D-0098) goes too, so verify's tellUser never
4004
+ // relays a decision of an app that no longer exists.
4005
+ if (!dryRun) {
4006
+ const { forgetAppChoices } = await import('./recipe-record.js');
4007
+ if (await forgetAppChoices(session.root, name))
4008
+ return { ...finished, changed: [...finished.changed, '.dokebi/recipe.json'] };
4009
+ }
4010
+ return finished;
1746
4011
  }
1747
4012
  finally {
1748
4013
  await tx.close();
@@ -1750,8 +4015,9 @@ export class Project {
1750
4015
  }
1751
4016
  /** `dokebi app list`: apps with the domains and templates that show them. */
1752
4017
  async appList(root) {
1753
- const state = readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
1754
- const config = parseProjectConfig(await readFile(await securePath(root, CONFIG_FILE), 'utf8')).options;
4018
+ const state = readState(parseObject(await projectStateText(root), 'state'));
4019
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
4020
+ const config = parseProjectConfig((await readProjectSettings(root)).source).options;
1755
4021
  const host = findHost(state.plugins, 'apps');
1756
4022
  return {
1757
4023
  apps: (state.apps ?? []).map((name) => ({
@@ -1773,11 +4039,17 @@ export class Project {
1773
4039
  if (!APP_NAME.test(name))
1774
4040
  throw new CliError('INVALID_MODULE', 'Module names match ' + APP_NAME.source);
1775
4041
  if ((state.modules ?? []).includes(name))
1776
- throw new CliError('MODULE_EXISTS', name);
4042
+ throw new CliError('MODULE_EXISTS', `The project already has a module named ${name}`, {
4043
+ details: {
4044
+ module: name,
4045
+ fix: 'choose another name; npx dokebi module list --json lists the modules',
4046
+ },
4047
+ phase: 'arguments',
4048
+ });
1777
4049
  const folder = `src/shared/modules/${name}`;
1778
4050
  templateTarget(folder + '/module.ts');
1779
4051
  if (await exists(await securePath(tx.root, folder)))
1780
- throw new CliError('FILE_CONFLICT', folder);
4052
+ throw fileConflict(folder, `already exists, so module add does not write the module ${name} there`, `choose another name, or move ${folder} away and repeat this command`, { module: name });
1781
4053
  const skeleton = options.views === false ? 'module-api' : 'module';
1782
4054
  const source = await packageFile(session.root, host.packageName, `templates/${skeleton}`);
1783
4055
  if (!(await exists(source)))
@@ -1791,7 +4063,7 @@ export class Project {
1791
4063
  : content);
1792
4064
  }
1793
4065
  state.modules = [...(state.modules ?? []), name];
1794
- await this.writeConfiguration(tx, state, session.configSource);
4066
+ await this.writeConfiguration(tx, state, session.configSource, session.configFile);
1795
4067
  const result = {
1796
4068
  changed: tx.paths,
1797
4069
  preserved: [],
@@ -1814,11 +4086,18 @@ export class Project {
1814
4086
  const { tx, state } = session;
1815
4087
  try {
1816
4088
  if (!(state.modules ?? []).includes(name))
1817
- throw new CliError('UNKNOWN_MODULE', name);
4089
+ throw new CliError('UNKNOWN_MODULE', `The project has no module named ${name}`, {
4090
+ details: {
4091
+ module: name,
4092
+ modules: state.modules ?? [],
4093
+ fix: 'name one of details.modules (npx dokebi module list --json)',
4094
+ },
4095
+ phase: 'arguments',
4096
+ });
1818
4097
  state.modules = (state.modules ?? []).filter((module) => module !== name);
1819
4098
  if (!state.modules.length)
1820
4099
  delete state.modules;
1821
- await this.writeConfiguration(tx, state, session.configSource);
4100
+ await this.writeConfiguration(tx, state, session.configSource, session.configFile);
1822
4101
  const result = {
1823
4102
  changed: tx.paths,
1824
4103
  preserved: [`src/shared/modules/${name}`],
@@ -1831,7 +4110,7 @@ export class Project {
1831
4110
  }
1832
4111
  /** `dokebi module list`: the registered modules in registration order. */
1833
4112
  async moduleList(root) {
1834
- const state = readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
4113
+ const state = readState(parseObject(await projectStateText(root), 'state'));
1835
4114
  return {
1836
4115
  modules: (state.modules ?? []).map((name) => ({
1837
4116
  name,
@@ -1839,21 +4118,297 @@ export class Project {
1839
4118
  })),
1840
4119
  };
1841
4120
  }
4121
+ /**
4122
+ * D-0155: `dokebi settings add <section>`: the settings of the application in options.<section>.
4123
+ * Writes src/shared/settings/<section>.ts (or keeps one that is already there and default-exports
4124
+ * defineSettings for this section), its schema file from the declaration, the defaults of the
4125
+ * section in dokebi.yaml and the example (with the reloadable marks), and registers it so the
4126
+ * app checks the section at start. A plugin's section, logging or reload is refused.
4127
+ */
4128
+ async settingsAdd(root, section, options = {}) {
4129
+ const session = await this.#begin(root);
4130
+ const { tx, state } = session;
4131
+ try {
4132
+ if (!SETTINGS_SECTION.test(section))
4133
+ throw new CliError('INVALID_SETTINGS', `Settings sections match ${SETTINGS_SECTION.source}`, {
4134
+ details: { section },
4135
+ phase: 'arguments',
4136
+ });
4137
+ const owner = state.plugins.find((plugin) => plugin.id === section || optionsKey(plugin) === section)
4138
+ ?.id ?? session.required.find((plugin) => plugin.id === section)?.id;
4139
+ if (owner !== undefined || RESERVED_SECTIONS.has(section))
4140
+ throw new CliError('SETTINGS_SECTION_CONFLICT', `options.${section} belongs to ${owner ?? 'core'}, not to the application`, {
4141
+ details: {
4142
+ section,
4143
+ owner: owner ?? 'core',
4144
+ fix: 'choose another section name for the settings of the application',
4145
+ },
4146
+ phase: 'arguments',
4147
+ });
4148
+ if ((state.settings ?? []).includes(section))
4149
+ throw new CliError('SETTINGS_EXISTS', `The application already has settings ${section}`, {
4150
+ details: {
4151
+ section,
4152
+ fix: 'edit the declaration, then npx dokebi settings sync; npx dokebi settings list lists them',
4153
+ },
4154
+ phase: 'arguments',
4155
+ });
4156
+ const file = settingsFile(section);
4157
+ templateTarget(file);
4158
+ const existing = await exists(await securePath(tx.root, file));
4159
+ let schemaText;
4160
+ if (existing)
4161
+ schemaText = await renderSettingsSchema(session.root, section);
4162
+ else {
4163
+ // The declaration is read from a copy beside where it goes (same imports), then written.
4164
+ const draft = join(session.root, SETTINGS_FOLDER, `.${section}.${process.pid}.draft.ts`);
4165
+ // R4CA-8: the folders made for the draft go with it (a dry run applies nothing; the
4166
+ // transaction makes them again for the real files).
4167
+ const made = await mkdir(dirname(draft), { recursive: true });
4168
+ try {
4169
+ await writeFile(draft, settingsSource(section));
4170
+ schemaText = await renderSettingsSchemaFrom(session.root, section, draft);
4171
+ }
4172
+ finally {
4173
+ await rm(draft, { force: true });
4174
+ if (made !== undefined)
4175
+ await rm(made, { recursive: true, force: true });
4176
+ }
4177
+ await tx.write(file, settingsSource(section));
4178
+ }
4179
+ await tx.write(settingsSchemaFile(section), schemaText);
4180
+ const schema = JSON.parse(schemaText);
4181
+ session.schemas.set(section, schema);
4182
+ const configSource = scaffoldConfig(session.configSource, section, schema);
4183
+ const warnings = [];
4184
+ await this.#syncExamples(tx, session.root, warnings, [{ id: section, update: { schema } }]);
4185
+ state.settings = [...(state.settings ?? []), section];
4186
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
4187
+ const identifier = settingsIdentifier(section);
4188
+ const result = {
4189
+ changed: tx.paths,
4190
+ preserved: existing ? [file] : [],
4191
+ ...(warnings.length ? { warnings } : {}),
4192
+ nextSteps: [
4193
+ {
4194
+ command: `edit ${file}`,
4195
+ why: `declare the options of options.${section} (every count, size and time with minimum, maximum and default; reloadable: true where a running app may take a change), then npx dokebi settings sync`,
4196
+ },
4197
+ {
4198
+ command: `import ${identifier} from '<relative path>/shared/settings/${section}.ts' and read ${identifier}.read(context.app.config)`,
4199
+ why: 'typed settings, cached per configuration and new after a reload; never read options.<section> by hand',
4200
+ },
4201
+ ],
4202
+ };
4203
+ return await this.#finish(session, result, options.dryRun);
4204
+ }
4205
+ catch (error) {
4206
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
4207
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
4208
+ throw tx.failure(error);
4209
+ }
4210
+ finally {
4211
+ await tx.close();
4212
+ }
4213
+ }
4214
+ /**
4215
+ * D-0155: `dokebi settings sync`: writes the schema file of every settings section again from
4216
+ * its declaration and adds the defaults of new options to dokebi.yaml and the example (with the
4217
+ * reloadable marks). Nothing is written when every schema is current. AI-M49 D2: a value that
4218
+ * is still the old default (the CLI wrote it) follows a changed default (`followed`), and the
4219
+ * options are checked against the new schema as doctor checks them: a missing required value
4220
+ * is `configRequired`, anything else INVALID_PLUGIN_OPTIONS and nothing is written.
4221
+ */
4222
+ async settingsSync(root, dryRun = false) {
4223
+ const session = await this.#begin(root);
4224
+ const { tx, state } = session;
4225
+ try {
4226
+ const sections = [];
4227
+ const validator = new PluginOptionsValidator({ allErrors: true });
4228
+ const configRequired = [];
4229
+ let configSource = session.configSource;
4230
+ const examples = [];
4231
+ const warnings = [];
4232
+ /** The schema before this sync, per section: what the CLI wrote as defaults (S-b). */
4233
+ const previous = new Map();
4234
+ for (const section of state.settings ?? []) {
4235
+ const text = await renderSettingsSchema(session.root, section);
4236
+ const path = settingsSchemaFile(section);
4237
+ const current = (await exists(await securePath(tx.root, path)))
4238
+ ? await tx.read(path)
4239
+ : undefined;
4240
+ const changed = current !== text;
4241
+ if (changed)
4242
+ await tx.write(path, text);
4243
+ const schema = JSON.parse(text);
4244
+ session.schemas.set(section, schema);
4245
+ const before = current === undefined ? null : JSON.parse(current);
4246
+ previous.set(section, before);
4247
+ // Options the declaration dropped: defaults the CLI wrote go, values set by hand are named.
4248
+ const pruned = pruneUndeclared(configSource, section, before, schema);
4249
+ // A default the CLI wrote follows the new default; a value set by hand stays (D2).
4250
+ const followed = followDefaults(pruned.source, section, before, schema);
4251
+ configSource = scaffoldConfig(followed.source, section, schema);
4252
+ for (const key of pruned.kept)
4253
+ warnings.push({
4254
+ code: 'SETTINGS_KEY_UNDECLARED',
4255
+ path: `${session.configFile}#options.${section}.${key}`,
4256
+ hint: `${settingsFile(section)} no longer declares it and its value was set by hand: npx dokebi config unset ${section}.${key}, or declare it again`,
4257
+ });
4258
+ // What doctor would refuse is never answered ok (D2: a lowered maximum under a value set
4259
+ // by hand). A missing required value is reported, as add and config set report it.
4260
+ const checked = await validator.checkSettingsWith(section, schema, optionsOf(parseProjectConfig(configSource).options, section));
4261
+ const required = checked.errors.filter((error) => error.keyword === 'required');
4262
+ const invalid = checked.errors.filter((error) => error.keyword !== 'required');
4263
+ if (invalid.length)
4264
+ throw settingsStillInvalid(section, invalid, session.configFile);
4265
+ if (required.length)
4266
+ configRequired.push({
4267
+ plugin: section,
4268
+ errors: required.slice(0, 20),
4269
+ errorCount: required.length,
4270
+ });
4271
+ if (followed.updated.length)
4272
+ debug('Settings defaults the CLI wrote follow the new declaration', {
4273
+ section,
4274
+ paths: followed.updated,
4275
+ });
4276
+ examples.push({ id: section, update: { schema } });
4277
+ sections.push({
4278
+ section,
4279
+ changed,
4280
+ ...(followed.updated.length
4281
+ ? { followed: followed.updated.map((key) => `${section}.${key}`) }
4282
+ : {}),
4283
+ });
4284
+ }
4285
+ // The example's values of dropped options go too (they are the old defaults), in the same
4286
+ // write as the rest of its sync.
4287
+ if (examples.length)
4288
+ await this.#syncExamples(tx, session.root, warnings, examples, (source) => examples.reduce((next, { id, update }) => {
4289
+ const pruned = pruneUndeclared(next, id, previous.get(id) ?? null, update.schema);
4290
+ // R10: a value of a dropped option the example keeps (not the old default) is named too.
4291
+ for (const key of pruned.kept)
4292
+ warnings.push({
4293
+ code: 'SETTINGS_KEY_UNDECLARED',
4294
+ path: `${EXAMPLE}#options.${id}.${key}`,
4295
+ hint: `the declaration no longer has it: remove it from ${EXAMPLE}`,
4296
+ });
4297
+ // D2: the sample's copies of the CLI's defaults follow too.
4298
+ return followDefaults(pruned.source, id, previous.get(id) ?? null, update.schema)
4299
+ .source;
4300
+ }, source));
4301
+ if (configSource !== session.configSource || sections.some((item) => item.changed))
4302
+ await this.writeConfiguration(tx, state, configSource, session.configFile);
4303
+ debug('Settings schemas synchronized', {
4304
+ sections: sections.length,
4305
+ changed: sections.filter((item) => item.changed).length,
4306
+ });
4307
+ const result = await this.#finish(session, {
4308
+ changed: tx.paths,
4309
+ preserved: [],
4310
+ ...(configRequired.length ? { configRequired } : {}),
4311
+ ...(warnings.length ? { warnings } : {}),
4312
+ }, dryRun);
4313
+ return { ...result, sections };
4314
+ }
4315
+ catch (error) {
4316
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
4317
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
4318
+ throw tx.failure(error);
4319
+ }
4320
+ finally {
4321
+ await tx.close();
4322
+ }
4323
+ }
4324
+ /** `dokebi settings remove <section>`: unregisters it; its files and its options stay. */
4325
+ async settingsRemove(root, section, dryRun = false) {
4326
+ const session = await this.#begin(root);
4327
+ const { tx, state } = session;
4328
+ try {
4329
+ if (!(state.settings ?? []).includes(section))
4330
+ throw new CliError('UNKNOWN_SETTINGS', `The application has no settings ${section}`, {
4331
+ details: {
4332
+ section,
4333
+ settings: state.settings ?? [],
4334
+ fix: 'name one of details.settings (npx dokebi settings list --json)',
4335
+ },
4336
+ phase: 'arguments',
4337
+ });
4338
+ state.settings = (state.settings ?? []).filter((item) => item !== section);
4339
+ if (!state.settings.length)
4340
+ delete state.settings;
4341
+ await this.writeConfiguration(tx, state, session.configSource, session.configFile);
4342
+ return await this.#finish(session, {
4343
+ changed: tx.paths,
4344
+ preserved: [
4345
+ settingsFile(section),
4346
+ settingsSchemaFile(section),
4347
+ `${session.configFile}#options.${section}`,
4348
+ ],
4349
+ }, dryRun);
4350
+ }
4351
+ catch (error) {
4352
+ // R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
4353
+ // or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
4354
+ throw tx.failure(error);
4355
+ }
4356
+ finally {
4357
+ await tx.close();
4358
+ }
4359
+ }
4360
+ /** `dokebi settings list`: the settings sections with their declaration and schema files. */
4361
+ async settingsList(root) {
4362
+ const state = readState(parseObject(await projectStateText(root), 'state'));
4363
+ return {
4364
+ settings: (state.settings ?? []).map((section) => ({
4365
+ section,
4366
+ file: settingsFile(section),
4367
+ schema: settingsSchemaFile(section),
4368
+ })),
4369
+ };
4370
+ }
1842
4371
  /**
1843
4372
  * What an Inertia app needs to be built (D-0031 supplement): one project-wide vite.config.ts
1844
4373
  * that finds every src/apps/<app>/views/app.js, a `build` script, the Vite and Vue packages as
1845
4374
  * dev dependencies, and dist/ in .gitignore. Existing files, scripts and versions are kept.
1846
4375
  * With server rendering (D-0039) also vite.ssr.config.ts, and `build` runs both builds.
4376
+ * The Vue pages are type-checked too (AI-M23): `typecheck` runs typecheck-vue.mjs (vue-tsc on
4377
+ * the TypeScript 6 API of @typescript/typescript6, since TypeScript 7 has no JavaScript API)
4378
+ * with tsconfig.vue.json after tsc, and src/infra/inertia/page.d.ts types the shared props.
1847
4379
  */
1848
4380
  async #inertiaBuild(tx, session, host, warnings, ssr) {
1849
4381
  const source = await packageFile(session.root, host.packageName, 'templates/inertia-build');
4382
+ // R4CC-6: an @dokebi/app older than this CLI (0.4.2 and before) has no Vue type check in its
4383
+ // build template; that is a refusal of the plan before anything is written, with the upgrade.
4384
+ const outdated = (what) => new CliError('INVALID_MANIFEST', `${host.packageName} ${host.version} has an Inertia build template without ${what}, which app add --inertia of this CLI writes`, {
4385
+ phase: 'plan',
4386
+ details: {
4387
+ package: host.packageName,
4388
+ version: host.version,
4389
+ missing: what,
4390
+ fix: `npx dokebi upgrade ${host.id}, then run the same app add again`,
4391
+ },
4392
+ });
4393
+ for (const file of ['build.json', 'tsconfig.vue.json', 'typecheck-vue.mjs', 'page.d.ts'])
4394
+ if (!(await exists(join(source, file))))
4395
+ throw outdated(`templates/inertia-build/${file}`);
1850
4396
  const build = parseObject(await readFile(join(source, 'build.json'), 'utf8'), 'build.json');
1851
4397
  const wanted = object(build.devDependencies ?? {}, 'devDependencies');
1852
- const { build: browserScript, buildSsr: ssrScript } = object(build.scripts ?? {}, 'scripts');
4398
+ const { build: browserScript, buildSsr: ssrScript, typecheck: typecheckScript, } = object(build.scripts ?? {}, 'scripts');
1853
4399
  const pkg = structuredClone(session.pkg);
1854
4400
  const scripts = object(pkg.scripts ?? {}, 'scripts');
1855
- if (typeof browserScript !== 'string' || typeof ssrScript !== 'string')
1856
- throw new CliError('INVALID_MANIFEST', 'build.json scripts');
4401
+ if (typeof browserScript !== 'string' ||
4402
+ typeof ssrScript !== 'string' ||
4403
+ typeof typecheckScript !== 'string')
4404
+ throw outdated(`the ${[
4405
+ ['build', browserScript],
4406
+ ['buildSsr', ssrScript],
4407
+ ['typecheck', typecheckScript],
4408
+ ]
4409
+ .filter(([, value]) => typeof value !== 'string')
4410
+ .map(([name]) => name)
4411
+ .join(', ')} script of build.json`);
1857
4412
  const script = ssr ? ssrScript : browserScript;
1858
4413
  // The browser-only script of an earlier `app add --inertia` grows into the SSR one; a script
1859
4414
  // the developer wrote is kept, and the warning says to add the server build by hand.
@@ -1861,6 +4416,14 @@ export class Project {
1861
4416
  scripts.build = script;
1862
4417
  else if (scripts.build !== script && !(!ssr && scripts.build === ssrScript))
1863
4418
  warnings.push({ code: 'BUILD_SCRIPT_KEPT', path: 'package.json' });
4419
+ // The server-only check init wrote grows into one that checks the .vue files too; a script
4420
+ // the developer wrote is kept, and the warning says to add the Vue check by hand.
4421
+ if (scripts.typecheck === undefined || scripts.typecheck === TYPECHECK_SCRIPT)
4422
+ scripts.typecheck = typecheckScript;
4423
+ else if (scripts.typecheck !== typecheckScript) {
4424
+ debug('Kept the typecheck script of the project', { script: scripts.typecheck });
4425
+ warnings.push({ code: 'TYPECHECK_SCRIPT_KEPT', path: 'package.json' });
4426
+ }
1864
4427
  pkg.scripts = scripts;
1865
4428
  const devDependencies = object(pkg.devDependencies ?? {}, 'devDependencies');
1866
4429
  const dependencies = dependencyMap(pkg);
@@ -1881,11 +4444,31 @@ export class Project {
1881
4444
  else if (json(pkg) !== json(session.pkg))
1882
4445
  await tx.write('package.json', json(pkg));
1883
4446
  const configs = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'];
1884
- let configured = false;
4447
+ let configured;
1885
4448
  for (const file of configs)
1886
- configured ||= await exists(await securePath(session.root, file));
4449
+ if (!configured && (await exists(await securePath(session.root, file))))
4450
+ configured = file;
1887
4451
  if (!configured)
1888
4452
  await tx.write('vite.config.ts', await readFile(join(source, 'vite.config.ts'), 'utf8'));
4453
+ // D-0062 addendum (2026-10-20): views/app.js imports '@dokebi/seal/http', which the
4454
+ // template-inertia Vite plugin maps to the module the app serves. A config of the project's
4455
+ // own without it would bundle a second seal client (or fail to resolve the package).
4456
+ else if (!(await readFile(await securePath(session.root, configured), 'utf8')).includes('dokebiInertia')) {
4457
+ debug('Kept a Vite config without the Inertia plugin', { file: configured });
4458
+ warnings.push({ code: 'VITE_PLUGIN_MISSING', path: configured });
4459
+ }
4460
+ // The Vue type check: its tsconfig, its runner and the shared page props, each written once.
4461
+ for (const [file, target] of [
4462
+ ['tsconfig.vue.json', 'tsconfig.vue.json'],
4463
+ ['typecheck-vue.mjs', 'typecheck-vue.mjs'],
4464
+ ['page.d.ts', 'src/infra/inertia/page.d.ts'],
4465
+ ])
4466
+ if (!(await exists(await securePath(session.root, target)))) {
4467
+ const directory = dirname(target);
4468
+ if (directory !== '.')
4469
+ await tx.ensureDirectory(directory);
4470
+ await tx.write(target, await readFile(join(source, file), 'utf8'));
4471
+ }
1889
4472
  if (ssr) {
1890
4473
  let ssrConfigured = false;
1891
4474
  for (const file of configs.map((name) => name.replace('vite.config', 'vite.ssr.config')))
@@ -1919,19 +4502,50 @@ export class Project {
1919
4502
  return catalog.packages.some((item) => installed.has(item.package) &&
1920
4503
  item.provides.some((capability) => capability.startsWith(prefix)));
1921
4504
  }
1922
- /** Shared by `app add` and the first app created when the apps host is added. */
1923
- async #addApp(tx, packagesRoot, host, state, configSource, name, options) {
4505
+ /**
4506
+ * The one path of every new app: `app add` and the first app created when the apps host is
4507
+ * added. `plugins` are the plugins installed once the command is done (`add` passes the new
4508
+ * list, which state does not hold yet). `layout` names the EJS view engine whose options the
4509
+ * shared layout changed, for the caller to write into the shared sample too.
4510
+ */
4511
+ async #addApp(tx, packagesRoot, host, plugins, state, configSource, name, options) {
1924
4512
  if (!APP_NAME.test(name))
1925
4513
  throw new CliError('INVALID_APP', 'App names match ' + APP_NAME.source);
1926
4514
  const template = options.template ?? 'default';
1927
4515
  if (!APP_NAME.test(template))
1928
4516
  throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
1929
4517
  if ((state.apps ?? []).includes(name))
1930
- throw new CliError('APP_EXISTS', name);
4518
+ throw new CliError('APP_EXISTS', `The project already has an app named ${name}`, {
4519
+ details: {
4520
+ app: name,
4521
+ fix: `choose another name, or give ${name} another domain with npx dokebi app link ${name} <domain>`,
4522
+ },
4523
+ phase: 'arguments',
4524
+ });
1931
4525
  const folder = `src/apps/${name}`;
1932
4526
  templateTarget(folder + '/app.ts');
1933
4527
  if (await exists(await securePath(tx.root, folder)))
1934
- throw new CliError('FILE_CONFLICT', folder);
4528
+ throw fileConflict(folder, `already exists, so app add does not write the app ${name} there`, `choose another name, or move ${folder} away and repeat this command`, { app: name });
4529
+ // EJS or Inertia skeleton; `{{app}}` in text files becomes the app name (paths to its views).
4530
+ // Server rendering lays app-inertia-ssr (views/ssr.js, an app.ts naming the bundle) on top.
4531
+ const skeletons = options.inertia
4532
+ ? ['app-inertia', ...(options.ssr ? ['app-inertia-ssr'] : [])]
4533
+ : options.view === 'none'
4534
+ ? []
4535
+ : ['app'];
4536
+ const files = new Map();
4537
+ for (const skeleton of skeletons) {
4538
+ const source = await packageFile(packagesRoot, host.packageName, `templates/${skeleton}`);
4539
+ for (const file of await listFiles(source))
4540
+ files.set(file, join(source, file));
4541
+ }
4542
+ // No views: two files of the CLI's own text instead of the host's EJS skeleton.
4543
+ const texts = options.view === 'none' ? apiAppFiles(name) : new Map();
4544
+ const layout = await this.#sharedLayout(tx, packagesRoot, plugins, configSource, [
4545
+ ...files.keys(),
4546
+ ...texts.keys(),
4547
+ ]);
4548
+ configSource = layout.configSource;
1935
4549
  const domains = [...new Set(options.domains ?? [])];
1936
4550
  const config = parseProjectConfig(configSource).options;
1937
4551
  for (const domain of domains) {
@@ -1939,22 +4553,11 @@ export class Project {
1939
4553
  throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
1940
4554
  const current = domainTarget(config, host.id, domain);
1941
4555
  if (current && current !== name)
1942
- throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
4556
+ throw domainInUse(domain, current);
1943
4557
  }
1944
4558
  // Directories first: commit creates them in order, before files create parents themselves.
1945
4559
  if (template !== 'default')
1946
4560
  await tx.ensureDirectory(`${folder}/views/${template}`);
1947
- // EJS or Inertia skeleton; `{{app}}` in text files becomes the app name (paths to its views).
1948
- // Server rendering lays app-inertia-ssr (views/ssr.js, an app.ts naming the bundle) on top.
1949
- const skeletons = options.inertia
1950
- ? ['app-inertia', ...(options.ssr ? ['app-inertia-ssr'] : [])]
1951
- : ['app'];
1952
- const files = new Map();
1953
- for (const skeleton of skeletons) {
1954
- const source = await packageFile(packagesRoot, host.packageName, `templates/${skeleton}`);
1955
- for (const file of await listFiles(source))
1956
- files.set(file, join(source, file));
1957
- }
1958
4561
  for (const [file, path] of files) {
1959
4562
  const target = `${folder}/${file}`;
1960
4563
  templateTarget(target);
@@ -1963,18 +4566,87 @@ export class Project {
1963
4566
  ? content.toString('utf8').replaceAll('{{app}}', name)
1964
4567
  : content);
1965
4568
  }
1966
- const changes = domainChanges(domains, template === 'default' ? { app: name } : { app: name, template });
4569
+ for (const [file, text] of texts) {
4570
+ templateTarget(`${folder}/${file}`);
4571
+ await tx.write(`${folder}/${file}`, text);
4572
+ }
4573
+ const changes = domainChanges(domains, domainTargetOf(name, template, options.seal));
1967
4574
  state.apps = [...(state.apps ?? []), name];
1968
- return { configSource: applyConfigChanges(configSource, host.id, changes), changes };
4575
+ return {
4576
+ configSource: applyConfigChanges(configSource, host.id, changes),
4577
+ changes,
4578
+ ...(layout.engine ? { layout: { engine: layout.engine, changes: layout.changes } } : {}),
4579
+ };
1969
4580
  }
1970
- async writeConfiguration(tx, state, configSource) {
1971
- const generated = generateRegistration(state.plugins, state.installations, state.apps ?? [], state.modules ?? []);
4581
+ /**
4582
+ * The shared layout of an app whose skeleton has EJS views (D-0110 §3). Its view engine is the
4583
+ * installed plugin that provides the catalog capability `view.ejs`, found by capability as
4584
+ * D-0070 finds hosts, never by package name: that plugin's `templates/layouts/main.ejs` goes to
4585
+ * src/shared/views/layouts/main.ejs (kept when it is there) and its `defaultLayout` becomes
4586
+ * `layouts/main`, once. The engine installs no page scaffolds itself, so a project of Inertia
4587
+ * apps only keeps the engine entry (mail templates, error pages) without unused HTML.
4588
+ */
4589
+ async #sharedLayout(tx, packagesRoot, plugins, configSource, skeletonFiles) {
4590
+ if (!skeletonFiles.some((file) => file.endsWith('.ejs'))) {
4591
+ debug('App skeleton has no EJS views; no shared EJS layout');
4592
+ return { configSource, changes: [] };
4593
+ }
4594
+ const installed = new Map(plugins.map((plugin) => [plugin.packageName, plugin]));
4595
+ const providers = providersOf(await loadCatalog(), EJS_VIEW)
4596
+ .map((item) => installed.get(item.package))
4597
+ .filter((plugin) => plugin !== undefined);
4598
+ const engine = providers[0];
4599
+ if (!engine) {
4600
+ debug('No installed plugin provides the EJS view; the app has no shared layout', {
4601
+ capability: EJS_VIEW,
4602
+ });
4603
+ return { configSource, changes: [] };
4604
+ }
4605
+ if (providers.length > 1)
4606
+ debug('More than one installed plugin provides the EJS view; the first in the catalog', {
4607
+ capability: EJS_VIEW,
4608
+ plugins: providers.map((plugin) => plugin.id),
4609
+ engine: engine.id,
4610
+ });
4611
+ if (optionAt(configSource, engine.id, [EJS_LAYOUT.option]) !== undefined) {
4612
+ debug('The EJS view engine already has a default layout', { engine: engine.id });
4613
+ return { configSource, changes: [] };
4614
+ }
4615
+ if (await exists(await securePath(tx.root, EJS_LAYOUT.target)))
4616
+ debug('Shared layout kept', { path: EJS_LAYOUT.target });
4617
+ else {
4618
+ const source = await packageFile(packagesRoot, engine.packageName, EJS_LAYOUT.source);
4619
+ await tx.write(EJS_LAYOUT.target, await readFile(source));
4620
+ debug('Shared layout written', { path: EJS_LAYOUT.target, engine: engine.id });
4621
+ }
4622
+ const changes = [
4623
+ { op: 'default', path: [EJS_LAYOUT.option], value: EJS_LAYOUT.value },
4624
+ ];
4625
+ return {
4626
+ configSource: applyConfigChanges(configSource, engine.id, changes),
4627
+ engine: engine.id,
4628
+ changes,
4629
+ };
4630
+ }
4631
+ async writeConfiguration(tx, state, configSource,
4632
+ /** The settings file (M50: the one DOKEBI_CONFIG names for the config commands). */
4633
+ configFile) {
4634
+ const generated = generateRegistration(state.plugins, state.installations, state.apps ?? [], state.modules ?? [], state.settings ?? []);
1972
4635
  state.generatedHash = hash(generated);
1973
4636
  await tx.write(GENERATED, generated);
1974
- await tx.write(CONFIG_FILE, configSource);
4637
+ await tx.write(configFile, configSource);
1975
4638
  await tx.write('.dokebi/state.json', json(state));
1976
4639
  }
1977
4640
  }
4641
+ /** The catalog capability of the view engine of EJS app skeletons (D-0110 §3). */
4642
+ const EJS_VIEW = 'view.ejs';
4643
+ /** What a `view.ejs` provider ships for a new EJS app: its shared layout and the option naming it. */
4644
+ const EJS_LAYOUT = {
4645
+ source: 'templates/layouts/main.ejs',
4646
+ target: 'src/shared/views/layouts/main.ejs',
4647
+ option: 'defaultLayout',
4648
+ value: 'layouts/main',
4649
+ };
1978
4650
  const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
1979
4651
  /** `options.<apps host>.domains` (D-0030): domain → { app, template? }. */
1980
4652
  function domainsOf(options, host) {
@@ -2016,6 +4688,14 @@ function parseAndRender(source) {
2016
4688
  return source;
2017
4689
  }
2018
4690
  /** Replaces each domain mapping (remove, then set) so a re-link changes app or template. */
4691
+ /** One `domains.<host>` entry: the app, its template unless default, and `seal: true` if asked. */
4692
+ function domainTargetOf(app, template, seal) {
4693
+ return {
4694
+ app,
4695
+ ...(template === 'default' ? {} : { template }),
4696
+ ...(seal ? { seal: true } : {}),
4697
+ };
4698
+ }
2019
4699
  function domainChanges(domains, target) {
2020
4700
  return domains.flatMap((domain) => [
2021
4701
  { op: 'remove', path: ['domains', domain] },