@dokebi/cli 0.4.2 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +638 -37
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +15 -0
  46. package/dist/check/shared-state-rules.js +117 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +381 -36
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
@@ -1,10 +1,134 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { ConfigValidator, loadSchema } from '@dokebi/config';
5
+ import { debug, describeError } from './diagnostics.js';
2
6
  import { CliError } from './errors.js';
7
+ import { optionsKey } from './manifest.js';
8
+ import { optionsFix } from './option-fixes.js';
9
+ import { readSettingsSchema } from './settings.js';
3
10
  import { packageFile } from './workspace.js';
11
+ /** The smallest schema the validator compiles: whether it works at all. */
12
+ const TRIVIAL_SCHEMA = Object.freeze({
13
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
14
+ type: 'object',
15
+ });
16
+ /** The version of the @dokebi/config this CLI validates with ('unknown' when it cannot be read). */
17
+ function configVersion() {
18
+ try {
19
+ // ESM resolution: @dokebi/config exports only an `import` condition.
20
+ let directory = dirname(fileURLToPath(import.meta.resolve('@dokebi/config')));
21
+ for (let depth = 0; depth < 4; depth++) {
22
+ const file = join(directory, 'package.json');
23
+ if (existsSync(file)) {
24
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
25
+ if (pkg.name === '@dokebi/config')
26
+ return String(pkg.version);
27
+ }
28
+ directory = dirname(directory);
29
+ }
30
+ }
31
+ catch (error) {
32
+ debug('The @dokebi/config version could not be read', { error: describeError(error) });
33
+ }
34
+ return 'unknown';
35
+ }
36
+ /**
37
+ * The first line of an error and of each cause under it, at most 500 characters: why a schema
38
+ * failed, never a value. @dokebi/config's ConfigError says only "Invalid or unsupported options
39
+ * schema"; ajv's words (`strict mode: unknown keyword: "x-dokebi-…"`) are its cause (R5C-1).
40
+ */
41
+ function reasonOf(error) {
42
+ const lines = [];
43
+ let current = error ?? 'unknown';
44
+ for (let depth = 0; depth < 4 && current !== undefined && current !== null; depth++) {
45
+ const text = current instanceof Error ? `${current.name}: ${current.message}` : String(current);
46
+ lines.push(text.split('\n')[0] ?? '');
47
+ current = current instanceof Error ? current.cause : undefined;
48
+ }
49
+ return lines.join(' <- caused by ').slice(0, 500);
50
+ }
4
51
  export class PluginOptionsValidator {
5
52
  #validator;
53
+ /** Whether the validator itself works (a trivial schema compiles); asked once, on a failure. */
54
+ #works;
6
55
  constructor(options = {}) {
7
- this.#validator = new ConfigValidator(options);
56
+ this.#validator =
57
+ options.validator ??
58
+ new ConfigValidator({ ...(options.allErrors ? { allErrors: true } : {}) });
59
+ }
60
+ /**
61
+ * AI-M45: a schema that cannot be read or compiled, with the reason. When even a trivial schema
62
+ * does not compile, the validator itself is broken (a damaged node_modules: ajv's files gone)
63
+ * and every plugin would be reported: the fix then says to restore the installation.
64
+ */
65
+ async #failure(cause, subject, reinstall) {
66
+ // A schema the validator takes (it refuses one without the 2020-12 `$schema`): with
67
+ // `{ type: 'object' }` alone the probe always failed, so every schema that did not compile
68
+ // was reported as a damaged installation with the fix `npm ci`.
69
+ this.#works ??= this.#validator.check(TRIVIAL_SCHEMA, {}).then(() => true, () => false);
70
+ const validatorBroken = !(await this.#works);
71
+ // A schema of a newer release than this CLI's @dokebi/config (2026-10-28: an older CLI met
72
+ // x-dokebi-secret and x-dokebi-alias-of and was read as a broken installation).
73
+ const keyword = /unknown keyword:? "?(x-dokebi-[\w-]+)/.exec(reasonOf(cause))?.[1];
74
+ if (!validatorBroken && keyword !== undefined) {
75
+ const version = configVersion();
76
+ return new CliError('INVALID_OPTIONS_SCHEMA', `The options schema of ${String(subject.plugin ?? subject.section)} uses ${keyword}, which the @dokebi/config ${version} of this CLI does not know`, {
77
+ cause,
78
+ details: {
79
+ ...subject,
80
+ reason: reasonOf(cause),
81
+ keyword,
82
+ config: version,
83
+ validator: false,
84
+ fix: `the schema comes from a newer release than this CLI: run the @dokebi/cli of the same release as the installed plugins (npm install the matching @dokebi/cli in the project, then npx dokebi), and keep @dokebi/core, @dokebi/config and @dokebi/cli of one release; the installation is not damaged`,
85
+ },
86
+ phase: 'plan',
87
+ });
88
+ }
89
+ return new CliError('INVALID_OPTIONS_SCHEMA', validatorBroken
90
+ ? 'The options schema validator of @dokebi/config cannot load (the installation is damaged)'
91
+ : `The options schema of ${String(subject.plugin ?? subject.section)} cannot be read or compiled`, {
92
+ cause,
93
+ details: {
94
+ ...subject,
95
+ reason: reasonOf(cause),
96
+ validator: validatorBroken,
97
+ fix: validatorBroken
98
+ ? 'restore node_modules with npm ci (or npm install): the JSON Schema validator (ajv, a dependency of @dokebi/config) is missing files, so no options can be checked; every section is reported until then'
99
+ : reinstall,
100
+ },
101
+ phase: 'plan',
102
+ });
103
+ }
104
+ /**
105
+ * D-0155: the options of an application settings section against the schema file beside its
106
+ * declaration (src/shared/settings/<section>.schema.json); no schema yet checks nothing.
107
+ */
108
+ async checkSettings(root, section, options) {
109
+ const schema = await readSettingsSchema(root, section);
110
+ if (!schema)
111
+ return { schema: null, errors: [] };
112
+ try {
113
+ const checked = await this.#validator.check(schema, options);
114
+ return { schema, errors: checked.errors };
115
+ }
116
+ catch (cause) {
117
+ throw await this.#failure(cause, { section }, `run npx dokebi settings sync to write src/shared/settings/${section}.schema.json again`);
118
+ }
119
+ }
120
+ /**
121
+ * A settings section against a schema in hand (`settings sync` checks the options against the
122
+ * schema it is about to write, before the file is on disk).
123
+ */
124
+ async checkSettingsWith(section, schema, options) {
125
+ try {
126
+ const checked = await this.#validator.check(schema, options);
127
+ return { schema, errors: checked.errors };
128
+ }
129
+ catch (cause) {
130
+ throw await this.#failure(cause, { section }, `correct the declaration of ${section} so core renders a valid schema, then npx dokebi settings sync`);
131
+ }
8
132
  }
9
133
  async check(root, plugin, options) {
10
134
  if (!plugin.optionsSchema)
@@ -16,7 +140,7 @@ export class PluginOptionsValidator {
16
140
  return { schema, errors: checked.errors };
17
141
  }
18
142
  catch (cause) {
19
- throw new CliError('INVALID_OPTIONS_SCHEMA', plugin.id, { cause });
143
+ throw await this.#failure(cause, { plugin: plugin.id, version: plugin.version, schema: plugin.optionsSchema }, `reinstall ${plugin.packageName} (npm install), or use another version of it; details.reason says why`);
20
144
  }
21
145
  }
22
146
  }
@@ -24,12 +148,20 @@ export async function checkPluginOptions(root, plugin, options) {
24
148
  return new PluginOptionsValidator().check(root, plugin, options);
25
149
  }
26
150
  export async function validatePluginOptions(root, plugins, options) {
27
- const validator = new PluginOptionsValidator();
151
+ // Every issue (D-0153): the fix names all unknown keys in one config unset.
152
+ const validator = new PluginOptionsValidator({ allErrors: true });
28
153
  for (const plugin of plugins) {
29
- const result = await validator.check(root, plugin, Object.hasOwn(options, plugin.id) ? (options[plugin.id] ?? {}) : {});
154
+ // D-0151: the logger host's options are options.logging.
155
+ const key = optionsKey(plugin);
156
+ const result = await validator.check(root, plugin, Object.hasOwn(options, key) ? (options[key] ?? {}) : {});
30
157
  if (result.errors.length)
31
- throw new CliError('INVALID_PLUGIN_OPTIONS', plugin.id, {
32
- details: { plugin: plugin.id, errors: result.errors },
158
+ throw new CliError('INVALID_PLUGIN_OPTIONS', `The options of ${plugin.id} do not match its schema: options.${key}${(result.errors[0]?.instancePath ?? '').replaceAll('/', '.')} (${result.errors[0]?.keyword ?? 'invalid'})`, {
159
+ details: {
160
+ plugin: plugin.id,
161
+ errors: result.errors,
162
+ fix: optionsFix(plugin.id, result.errors, `correct options.${key} in dokebi.yaml (npx dokebi plugins info ${plugin.id} --json lists the option keys)`, key, result.schema),
163
+ },
164
+ phase: 'plan',
33
165
  });
34
166
  }
35
167
  }
@@ -109,9 +109,14 @@ export async function pluginsList(root, options) {
109
109
  export async function pluginsSearch(query) {
110
110
  return searchCatalog(await loadCatalog(), query);
111
111
  }
112
- function unknownPackage(catalog, key) {
112
+ function unknownPackage(catalog, key,
113
+ /** An installed package the key names by its short name (AI-M04 F7), with its guide's path. */
114
+ installed) {
113
115
  const search = searchCatalog(catalog, key);
114
- return new CliError('UNKNOWN_PLUGIN', `No Dokebi package "${key}"`, {
116
+ const fix = installed
117
+ ? `npx dokebi plugins doc ${installed.package} --json (or read ${installed.path})`
118
+ : undefined;
119
+ return new CliError('UNKNOWN_PLUGIN', `No Dokebi package "${key}"${installed ? `; did you mean ${installed.package}? ${fix}` : ''}`, {
115
120
  phase: 'arguments',
116
121
  details: {
117
122
  query: key,
@@ -121,10 +126,44 @@ function unknownPackage(catalog, key) {
121
126
  summary: entry.summary,
122
127
  instead: entry.instead,
123
128
  })),
124
- hint: 'npx dokebi plugins search <words> --json',
129
+ ...(installed && fix ? { package: installed.package, path: installed.path, fix } : {}),
130
+ hint: fix ?? 'npx dokebi plugins search <words> --json',
125
131
  },
126
132
  });
127
133
  }
134
+ /** The guide path of an installed package's package.json (`dokebi.documentation`, else AI.md). */
135
+ function documentationPath(packageName, pkg) {
136
+ const manifest = pkg.dokebi;
137
+ const documentation = typeof manifest?.documentation === 'string' ? manifest.documentation : 'AI.md';
138
+ return `node_modules/${packageName}/${documentation}`;
139
+ }
140
+ /**
141
+ * An id that is not in the catalog but names an installed @dokebi package by its short name
142
+ * (`dokebi-core`, `dokebi-cli`: core, CLI and drivers have no plugin id), AI-M04 F7.
143
+ */
144
+ async function installedByShortName(root, key) {
145
+ const short = key
146
+ .trim()
147
+ .toLowerCase()
148
+ .replace(/^@dokebi\//, '')
149
+ .replace(/^dokebi-/, '');
150
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(short))
151
+ return undefined;
152
+ const packageName = `@dokebi/${short}`;
153
+ try {
154
+ const pkg = parseObject(await readFile(await packageFile(root, packageName, 'package.json'), 'utf8'), packageName);
155
+ return { package: packageName, path: documentationPath(packageName, pkg) };
156
+ }
157
+ catch (error) {
158
+ if (!isMissing(error) && !(error instanceof CliError))
159
+ throw error;
160
+ debug('plugins doc: no installed package by that short name', {
161
+ package: packageName,
162
+ error: describeError(error),
163
+ });
164
+ return undefined;
165
+ }
166
+ }
128
167
  export async function pluginsInfo(root, key) {
129
168
  const catalog = await loadCatalog();
130
169
  const item = findPackage(catalog, key);
@@ -146,7 +185,7 @@ export async function pluginsDoc(root, key) {
146
185
  const item = findPackage(catalog, key);
147
186
  const packageName = item?.package ?? (key.startsWith('@') ? key : undefined);
148
187
  if (!packageName)
149
- throw unknownPackage(catalog, key);
188
+ throw unknownPackage(catalog, key, await installedByShortName(root, key));
150
189
  let pkg;
151
190
  try {
152
191
  pkg = parseObject(await readFile(await packageFile(root, packageName, 'package.json'), 'utf8'), packageName);
@@ -167,9 +206,7 @@ export async function pluginsDoc(root, key) {
167
206
  },
168
207
  });
169
208
  }
170
- const manifest = pkg.dokebi;
171
- const documentation = typeof manifest?.documentation === 'string' ? manifest.documentation : 'AI.md';
172
- const path = `node_modules/${packageName}/${documentation}`;
209
+ const path = documentationPath(packageName, pkg);
173
210
  let content;
174
211
  try {
175
212
  content = await readFile(await installedPath(root, path), 'utf8');
@@ -0,0 +1,39 @@
1
+ /** Spawn with `detached: PROCESS_GROUPS`: on POSIX the child then leads a new process group. */
2
+ export declare const PROCESS_GROUPS: boolean;
3
+ /**
4
+ * Signals the process group of `pid` (POSIX; the child leads it) or the child alone (Windows):
5
+ * true when the signal was sent, false when nothing of the group is left (ESRCH, the expected
6
+ * case after a clean run) or it could not be signalled (logged at debug: the caller decides).
7
+ */
8
+ export declare function killGroup(pid: number | undefined, signal: NodeJS.Signals): boolean;
9
+ /**
10
+ * Whether the process group `pid` led still has a member (POSIX). A group outlives its leader
11
+ * while a member runs, and its id is not reused as long as it exists, so a member found here
12
+ * right after the leader exited is one the leader started.
13
+ */
14
+ export declare function groupAlive(pid: number): boolean;
15
+ /**
16
+ * Ends what is left of the process group of `pid` after its leader exited: processes the leader
17
+ * started and did not stop (a helper, a server holding a port). SIGTERM first, SIGKILL after
18
+ * `graceMs`. `none` when nothing was left (the usual case), else how they were ended.
19
+ */
20
+ export declare function endLeftovers(pid: number, graceMs: number): Promise<'none' | 'terminated' | 'killed'>;
21
+ /** The signals that end a command (Ctrl+C, an agent's command time limit, a closed terminal). */
22
+ export declare const TERMINATING: readonly ['SIGINT', 'SIGTERM', 'SIGHUP'];
23
+ /**
24
+ * Holds off a terminating signal while a step must clean up first: the first SIGINT, SIGTERM or
25
+ * SIGHUP is recorded (`signal()`) instead of ending the process at once, so the step can see it,
26
+ * undo what it started and throw. `release()` removes the handlers and, when a signal came and
27
+ * nobody else listens for it, ends this process as that signal would have.
28
+ */
29
+ export declare function holdTermination(): {
30
+ signal(): NodeJS.Signals | undefined;
31
+ release(): void;
32
+ };
33
+ /**
34
+ * A detached group no longer gets the terminal's Ctrl+C (nor a SIGTERM or SIGHUP sent to this
35
+ * process). While the child runs, this passes such a signal on to its group and then lets this
36
+ * process end as it would have without the listener. Returns the function that stops forwarding;
37
+ * call it once the child closed.
38
+ */
39
+ export declare function forwardTermination(pid: number | undefined): () => void;
@@ -0,0 +1,132 @@
1
+ // Child commands that may start their own children (npm run, Vite, tsc, test runners) run in a
2
+ // process group of their own, so a time limit can end all of them. Killing only the direct child
3
+ // leaves the grandchildren running, and as they still hold the stdout/stderr pipes the child's
4
+ // 'close' event (and the step waiting for it) would not come until they end by themselves.
5
+ import { debug, describeError } from './diagnostics.js';
6
+ /** Spawn with `detached: PROCESS_GROUPS`: on POSIX the child then leads a new process group. */
7
+ export const PROCESS_GROUPS = process.platform !== 'win32';
8
+ /**
9
+ * Signals the process group of `pid` (POSIX; the child leads it) or the child alone (Windows):
10
+ * true when the signal was sent, false when nothing of the group is left (ESRCH, the expected
11
+ * case after a clean run) or it could not be signalled (logged at debug: the caller decides).
12
+ */
13
+ export function killGroup(pid, signal) {
14
+ if (pid === undefined)
15
+ return false;
16
+ try {
17
+ if (PROCESS_GROUPS)
18
+ process.kill(-pid, signal);
19
+ else
20
+ process.kill(pid, signal);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ if (error.code !== 'ESRCH')
25
+ debug('Could not signal the process group', {
26
+ pid,
27
+ signal,
28
+ error: describeError(error),
29
+ });
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Whether the process group `pid` led still has a member (POSIX). A group outlives its leader
35
+ * while a member runs, and its id is not reused as long as it exists, so a member found here
36
+ * right after the leader exited is one the leader started.
37
+ */
38
+ export function groupAlive(pid) {
39
+ if (!PROCESS_GROUPS)
40
+ return false;
41
+ try {
42
+ process.kill(-pid, 0);
43
+ return true;
44
+ }
45
+ catch (error) {
46
+ // ESRCH: nobody is left. EPERM: a member this user may not signal (not one the app started).
47
+ if (error.code !== 'ESRCH')
48
+ debug('Could not probe the process group', { pid, error: describeError(error) });
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * Ends what is left of the process group of `pid` after its leader exited: processes the leader
54
+ * started and did not stop (a helper, a server holding a port). SIGTERM first, SIGKILL after
55
+ * `graceMs`. `none` when nothing was left (the usual case), else how they were ended.
56
+ */
57
+ export async function endLeftovers(pid, graceMs) {
58
+ if (!groupAlive(pid))
59
+ return 'none';
60
+ debug('Processes are left in the group of an exited process; ending them', { pid, graceMs });
61
+ killGroup(pid, 'SIGTERM');
62
+ const until = performance.now() + graceMs;
63
+ while (performance.now() < until) {
64
+ await new Promise((resolve) => setTimeout(resolve, 50));
65
+ if (!groupAlive(pid))
66
+ return 'terminated';
67
+ }
68
+ killGroup(pid, 'SIGKILL');
69
+ debug('Processes left in the group did not end on SIGTERM; killed', { pid, graceMs });
70
+ return 'killed';
71
+ }
72
+ /** The signals that end a command (Ctrl+C, an agent's command time limit, a closed terminal). */
73
+ export const TERMINATING = ['SIGINT', 'SIGTERM', 'SIGHUP'];
74
+ /**
75
+ * Holds off a terminating signal while a step must clean up first: the first SIGINT, SIGTERM or
76
+ * SIGHUP is recorded (`signal()`) instead of ending the process at once, so the step can see it,
77
+ * undo what it started and throw. `release()` removes the handlers and, when a signal came and
78
+ * nobody else listens for it, ends this process as that signal would have.
79
+ */
80
+ export function holdTermination() {
81
+ let received;
82
+ const handlers = new Map();
83
+ for (const signal of TERMINATING) {
84
+ const handler = () => {
85
+ if (received)
86
+ return;
87
+ received = signal;
88
+ debug('A termination signal arrived; cleaning up before ending', { signal });
89
+ };
90
+ handlers.set(signal, handler);
91
+ process.on(signal, handler);
92
+ }
93
+ return {
94
+ signal: () => received,
95
+ release: () => {
96
+ for (const [signal, handler] of handlers)
97
+ process.off(signal, handler);
98
+ handlers.clear();
99
+ if (received && process.listenerCount(received) === 0)
100
+ process.kill(process.pid, received);
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * A detached group no longer gets the terminal's Ctrl+C (nor a SIGTERM or SIGHUP sent to this
106
+ * process). While the child runs, this passes such a signal on to its group and then lets this
107
+ * process end as it would have without the listener. Returns the function that stops forwarding;
108
+ * call it once the child closed.
109
+ */
110
+ export function forwardTermination(pid) {
111
+ const handlers = new Map();
112
+ const stop = () => {
113
+ for (const [signal, handler] of handlers)
114
+ process.off(signal, handler);
115
+ handlers.clear();
116
+ };
117
+ if (!PROCESS_GROUPS || pid === undefined)
118
+ return stop;
119
+ for (const signal of TERMINATING) {
120
+ const handler = () => {
121
+ debug('Passing a termination signal on to the child process group', { pid, signal });
122
+ killGroup(pid, signal);
123
+ stop();
124
+ // Nobody else listens: the signal's default action (ending this process) applies again.
125
+ if (process.listenerCount(signal) === 0)
126
+ process.kill(process.pid, signal);
127
+ };
128
+ handlers.set(signal, handler);
129
+ process.on(signal, handler);
130
+ }
131
+ return stop;
132
+ }
@@ -0,0 +1,3 @@
1
+ import type { Diagnostic } from './project-inspector.js';
2
+ /** The production diagnostics of the project at `root` (D-0184 §4). */
3
+ export declare function productionDiagnostics(root: string, env?: NodeJS.ProcessEnv): Promise<Diagnostic[]>;
@@ -0,0 +1,176 @@
1
+ // `dokebi doctor --production` (D-0184 §4): the checks of a server, on the settings the app
2
+ // reads there (DOKEBI_CONFIG, merged with the secrets file DOKEBI_SECRETS). The per-process
3
+ // state rules mirror what the plugins refuse at start in production (D-0161), so a deploy learns
4
+ // them before the restart instead of from a failed start.
5
+ import { spawn } from 'node:child_process';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { isAbsolute, join } from 'node:path';
8
+ import { applySecrets, ConfigError, parseConfig, SECRETS_VARIABLE } from '@dokebi/config';
9
+ import { kept } from './check/shared-state-rules.js';
10
+ import { placeholderPaths } from './config-init.js';
11
+ import { configSecrets } from './config-secrets.js';
12
+ import { readSettings } from './config-show.js';
13
+ import { debug, describeError } from './diagnostics.js';
14
+ import { CliError } from './errors.js';
15
+ import { parseObject } from './filesystem.js';
16
+ import { optionsKey } from './manifest.js';
17
+ import { readState } from './project-state.js';
18
+ function valueAt(options, path) {
19
+ let value = options;
20
+ for (const key of path) {
21
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, key))
22
+ value = value[key];
23
+ else
24
+ return undefined;
25
+ }
26
+ return value;
27
+ }
28
+ /** `dokebi-migrate status --json` with the settings of the server; undefined when it cannot run. */
29
+ function migrationStatus(root, env) {
30
+ return new Promise((resolve) => {
31
+ let stdout = '';
32
+ const child = spawn(join(root, 'node_modules', '.bin', 'dokebi-migrate'), ['status', '--json'], {
33
+ cwd: root,
34
+ env,
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ timeout: 60_000,
37
+ });
38
+ child.stdout.setEncoding('utf8');
39
+ child.stdout.on('data', (chunk) => {
40
+ stdout += chunk;
41
+ });
42
+ child.once('error', (error) => {
43
+ debug('dokebi-migrate status could not run', { error: describeError(error) });
44
+ resolve(undefined);
45
+ });
46
+ child.once('close', () => {
47
+ try {
48
+ resolve(JSON.parse(stdout));
49
+ }
50
+ catch (error) {
51
+ debug('dokebi-migrate status gave no JSON', { error: describeError(error) });
52
+ resolve(undefined);
53
+ }
54
+ });
55
+ });
56
+ }
57
+ /** The production diagnostics of the project at `root` (D-0184 §4). */
58
+ export async function productionDiagnostics(root, env = process.env) {
59
+ const found = [];
60
+ const add = (code, severity, path, hint, plugin = null, details) => found.push({
61
+ code,
62
+ severity,
63
+ path,
64
+ plugin,
65
+ hint,
66
+ ...(details === undefined ? {} : { details }),
67
+ });
68
+ if (env.NODE_ENV !== 'production')
69
+ add('NODE_ENV_NOT_PRODUCTION', 'error', 'NODE_ENV', 'NODE_ENV is not production here, so this check (and an app started like this) runs with development behaviour: run it as the service runs, NODE_ENV=production npx dokebi doctor --production (the systemd unit and compose file set it)');
70
+ const { target, source } = await readSettings(root);
71
+ const secretsFile = env[SECRETS_VARIABLE] || undefined;
72
+ let options;
73
+ try {
74
+ options = (await applySecrets(parseConfig(source), secretsFile)).options;
75
+ }
76
+ catch (error) {
77
+ if (!(error instanceof ConfigError))
78
+ throw error;
79
+ // The app would refuse to start with the same code; the rest is checked on the file alone.
80
+ add(error.code, 'error', target.file, `${error.message}. ${error.fix}`, 'dokebi-config', {
81
+ issues: error.issues,
82
+ });
83
+ // A settings file that does not parse has nothing more to check; a secrets problem leaves
84
+ // the settings file itself readable.
85
+ if (!error.code.startsWith('CONFIG_SECRETS') && !/secrets file/.test(error.message))
86
+ return found;
87
+ options = parseConfig(source).options;
88
+ }
89
+ const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
90
+ const installed = new Set(state.plugins.map((plugin) => optionsKey(plugin)));
91
+ // The same state `dokebi check` names for several workers (D-0161): in production one process
92
+ // is refused too (a restart loses it).
93
+ const redis = Object.keys(valueAt(options['dokebi-redis'], ['connections']) ??
94
+ {})[0];
95
+ for (const item of kept(installed, options))
96
+ add('STATE_NOT_SHARED', 'error', item.path.join('.'), `${item.what} would live in one process and be lost on every restart; the plugin refuses to start so in production (D-0161): ${item.fix(redis)}`, item.path[0] ?? null);
97
+ for (const plugin of ['dokebi-lock', 'dokebi-cache'])
98
+ if (installed.has(plugin)) {
99
+ const backends = valueAt(options[plugin], ['backends']);
100
+ for (const [name, backend] of Object.entries(backends && typeof backends === 'object' ? backends : {}))
101
+ if ((valueAt(backend, ['driver']) ?? 'local') === 'local')
102
+ add('STATE_NOT_SHARED', 'warning', `${plugin}.backends.${name}`, `the ${name} backend is local to one process: right for one process, wrong for a cluster or two instances that must share it; use driver: redis for shared ${plugin === 'dokebi-lock' ? 'locks' : 'cache entries'}`, plugin);
103
+ }
104
+ if (installed.has('dokebi-mail')) {
105
+ const driver = valueAt(options['dokebi-mail'], ['transport', 'driver']) ?? 'log';
106
+ if (driver === 'log' || driver === 'capture')
107
+ add('TRANSPORT_NOT_FOR_PRODUCTION', 'error', 'dokebi-mail.transport.driver', `the ${String(driver)} transport keeps mail in memory and the plugin refuses it in production: set options.dokebi-mail.transport to smtp or ses`, 'dokebi-mail');
108
+ }
109
+ if (installed.has('dokebi-web')) {
110
+ const web = options['dokebi-web'];
111
+ const port = valueAt(web, ['port']);
112
+ if (typeof port !== 'number' || port === 0)
113
+ add('WEB_PORT_UNSET', 'error', 'dokebi-web.port', 'the port is 0 (any free port), so the reverse proxy cannot reach the app: set the instance port (deploy init writes it)', 'dokebi-web');
114
+ const trust = valueAt(web, ['trustProxy']);
115
+ if (!Array.isArray(trust) || trust.length === 0)
116
+ add('TRUST_PROXY_EMPTY', 'warning', 'dokebi-web.trustProxy', 'behind a reverse proxy the app must trust its address, or it ignores X-Forwarded-Proto and -Host (http URLs, cookies without Secure, wrong client addresses): list the proxy addresses, 127.0.0.1 and ::1 on the same server', 'dokebi-web');
117
+ const host = valueAt(web, ['host']) ?? '127.0.0.1';
118
+ if (host === '0.0.0.0' || host === '::')
119
+ add('WEB_LISTENS_EVERYWHERE', 'warning', 'dokebi-web.host', 'the app listens on every interface, so it can be reached without the reverse proxy (no TLS): listen on 127.0.0.1 unless a firewall closes the port', 'dokebi-web');
120
+ }
121
+ if (installed.has('dokebi-storage')) {
122
+ const storage = options['dokebi-storage'];
123
+ const directory = valueAt(storage, ['directory']) ?? '.dokebi/storage';
124
+ if ((valueAt(storage, ['driver']) ?? 'local') === 'local' && !isAbsolute(String(directory)))
125
+ add('STORAGE_RELATIVE', 'warning', 'dokebi-storage.directory', `the stored files go to ${String(directory)} inside the working folder, which a new release replaces: set an absolute folder outside the release (deploy init uses /var/lib/<app>/<instance>/storage)`, 'dokebi-storage');
126
+ }
127
+ const placeholders = placeholderPaths(options);
128
+ if (placeholders.length)
129
+ add('PLACEHOLDER_LEFT', 'error', target.file, `these options still hold a <placeholder> of the sample: ${placeholders.join(', ')}; fill them in the settings (or the secrets file)`, null, { paths: placeholders });
130
+ if (!found.some((item) => item.code.startsWith('CONFIG_SECRETS'))) {
131
+ try {
132
+ const secrets = await configSecrets(root, env);
133
+ for (const warning of secrets.warnings ?? [])
134
+ add(warning.code, 'warning', warning.path, warning.hint);
135
+ const unlisted = secrets.missing.filter((path) => !secrets.listed.includes(path));
136
+ if (unlisted.length)
137
+ add('SECRETS_MISSING', 'error', target.file, `secrets the installed plugins need are not set: ${unlisted.join(', ')}; put them in the secrets file (npx dokebi config secrets --json)`, null, { paths: unlisted });
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof CliError))
141
+ throw error;
142
+ add(error.code, 'error', target.file, error.message);
143
+ }
144
+ }
145
+ if (installed.has('dokebi-migrate')) {
146
+ const status = await migrationStatus(root, {
147
+ ...env,
148
+ ...(env.DOKEBI_CONFIG ? {} : { DOKEBI_CONFIG: target.path }),
149
+ });
150
+ if (!status?.results)
151
+ add('MIGRATIONS_UNCHECKED', 'error', 'dokebi-migrate', `dokebi-migrate status could not read the migrations${status?.error?.code ? ` (${status.error.code}: ${status.error.message ?? ''})` : ''}: check its options and that the database is reachable with these settings (npx dokebi-migrate status)`, 'dokebi-migrate');
152
+ else
153
+ for (const [connection, list] of Object.entries(status.results)) {
154
+ const pending = list.filter((item) => item.state === 'pending');
155
+ const before = pending
156
+ .filter((item) => item.phase !== 'post-deploy')
157
+ .map((item) => item.name);
158
+ const after = pending
159
+ .filter((item) => item.phase === 'post-deploy')
160
+ .map((item) => item.name);
161
+ const missing = list.filter((item) => item.state === 'missing').map((item) => item.name);
162
+ if (before.length)
163
+ add('MIGRATIONS_PENDING', 'error', `dokebi-migrate.connections.${connection}`, `${before.length} migration(s) to apply before the new code starts: ${before.join(', ')}; run npx dokebi-migrate up --phase pre-deploy --json (DEPLOY.md, release step 2)`, 'dokebi-migrate');
164
+ if (after.length)
165
+ add('MIGRATIONS_POST_DEPLOY_PENDING', 'warning', `dokebi-migrate.connections.${connection}`, `post-deploy migration(s) wait for the switch: ${after.join(', ')}; run npx dokebi-migrate up --json after the new code runs`, 'dokebi-migrate');
166
+ if (missing.length)
167
+ add('MIGRATION_FILE_MISSING', 'warning', `dokebi-migrate.connections.${connection}`, `applied migrations whose files are gone (an older release folder?): ${missing.join(', ')}`, 'dokebi-migrate');
168
+ }
169
+ }
170
+ debug('Production checks done', {
171
+ errors: found.filter((item) => item.severity === 'error').length,
172
+ warnings: found.filter((item) => item.severity === 'warning').length,
173
+ secretsFile: secretsFile !== undefined,
174
+ });
175
+ return found;
176
+ }
@@ -1,3 +1,4 @@
1
+ import type { Catalog } from './catalog.js';
1
2
  import type { PluginManifest } from './manifest.js';
2
3
  import type { RequiredPlugin } from './required-plugins.js';
3
4
  /** Markers of the CLI-maintained list of installed packages in AGENTS.md (D-0065). */
@@ -7,6 +8,12 @@ export declare const AGENTS_FILE = "AGENTS.md";
7
8
  export declare const CLAUDE_FILE = "CLAUDE.md";
8
9
  /** CLAUDE.md of a new project: Claude Code reads AGENTS.md through this import line. */
9
10
  export declare const CLAUDE_GUIDE = "@AGENTS.md\n";
11
+ /**
12
+ * Markers of the project's own block in AGENTS.md (D-0077 G): the people of the project (or their
13
+ * tools) write rules there; the CLI never changes what is between them and doctor never judges it.
14
+ */
15
+ export declare const PROJECT_START = "<!-- dokebi:project:start -->";
16
+ export declare const PROJECT_END = "<!-- dokebi:project:end -->";
10
17
  /** One row of the installed list: a package, its version and its AI guide path. */
11
18
  export interface InstalledEntry {
12
19
  readonly id: string;
@@ -32,5 +39,16 @@ export declare function installedBlockEdited(text: string, entries: readonly Ins
32
39
  * of order (the developer removed them; doctor reports GUIDE_BLOCK_MISSING and the file stays).
33
40
  */
34
41
  export declare function replaceInstalledBlock(text: string, block: string): string | undefined;
42
+ /** The project block as init writes it (and as it is added to an older AGENTS.md). */
43
+ export declare function projectBlock(): string;
44
+ /**
45
+ * The state of the project block in an AGENTS.md text: `ok` (one start, then one end, outside
46
+ * the installed list), `missing` (no marker at all: the next add, upgrade or remove adds an
47
+ * empty block) or `invalid` (a marker alone, repeated, out of order, or inside the installed
48
+ * list: doctor warns GUIDE_PROJECT_BLOCK_INVALID and the CLI leaves the file alone).
49
+ */
50
+ export declare function projectBlockState(text: string): 'ok' | 'missing' | 'invalid';
51
+ /** The text with an empty project block (and its heading) added at the end. */
52
+ export declare function withProjectBlock(text: string): string;
35
53
  /** AGENTS.md of a new project (D-0065 skeleton): ask first, use the catalog, verify last. */
36
- export declare function projectGuide(entries: readonly InstalledEntry[]): string;
54
+ export declare function projectGuide(entries: readonly InstalledEntry[], catalog?: Catalog): string;