@dokebi/cli 0.4.2 → 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 +518 -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 +20 -2
  110. package/dist/install-module.js +45 -8
  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 +17 -2
  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 +40 -4
  124. package/dist/npm-client.js +152 -15
  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 +195 -16
  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 +3006 -333
  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 +43 -4
  170. package/dist/server.js +601 -52
  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 +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  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
@@ -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
+ }
@@ -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;