@dokebi/cli 0.2.4 → 0.4.0

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 (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2199 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +7 -0
  31. package/dist/config-edit.js +17 -0
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +141 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +856 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +793 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
@@ -11,6 +11,8 @@ import { packageFile, workspaceRootOf } from './workspace.js';
11
11
  const HINT = /^[^\p{Cc}]{1,200}$/u;
12
12
  const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
13
13
  const KEY = /^[A-Za-z0-9_-]{1,64}$/;
14
+ /** An environment variable name a plan may ask a generated secret for. */
15
+ export const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
14
16
  export function optionsOf(options, id) {
15
17
  const value = Object.hasOwn(options, id) ? options[id] : undefined;
16
18
  return value === undefined || value === null ? {} : object(value, 'plugin options');
@@ -39,6 +41,19 @@ export function optionPath(value) {
39
41
  }
40
42
  return [...value];
41
43
  }
44
+ function envSecrets(value) {
45
+ if (value === undefined)
46
+ return [];
47
+ if (!Array.isArray(value) || value.length > 8)
48
+ throw new Error('envSecrets');
49
+ const result = [];
50
+ for (const item of value) {
51
+ if (typeof item !== 'string' || !ENV_NAME.test(item) || result.includes(item))
52
+ throw new Error('envSecrets');
53
+ result.push(item);
54
+ }
55
+ return result;
56
+ }
42
57
  function configChanges(value) {
43
58
  if (value === undefined)
44
59
  return [];
@@ -98,20 +113,22 @@ export async function planLifecycle(input, phase, extra = {}) {
98
113
  const staticDefaults = base.configDefaults ? [base.configDefaults] : [];
99
114
  if (raw === PHASE_MISSING) {
100
115
  const { configDefaults: _ignored, ...rest } = base;
101
- return { ...rest, defaults: staticDefaults, requires: [], configChanges: [] };
116
+ return { ...rest, defaults: staticDefaults, requires: [], configChanges: [], envSecrets: [] };
102
117
  }
103
118
  let plan;
104
119
  let requires;
105
120
  let changes;
121
+ let secrets;
106
122
  try {
107
123
  plan = object(raw, 'installation plan');
108
124
  const allowed = phase === 'planInstall'
109
- ? ['directories', 'files', 'entry', 'configDefaults', 'requires']
125
+ ? ['directories', 'files', 'entry', 'configDefaults', 'requires', 'envSecrets']
110
126
  : ['directories', 'files', 'entry', 'configChanges', 'requires'];
111
127
  if (Object.keys(plan).some((key) => !allowed.includes(key)))
112
128
  throw new Error('keys');
113
129
  requires = requirements(plan.requires, plugin.id);
114
130
  changes = configChanges(plan.configChanges);
131
+ secrets = envSecrets(plan.envSecrets);
115
132
  }
116
133
  catch {
117
134
  throw invalidPlan(plugin.id);
@@ -134,7 +151,7 @@ export async function planLifecycle(input, phase, extra = {}) {
134
151
  files: dynamic.files.slice(base.files.length),
135
152
  directories: plan.directories ?? [],
136
153
  ...(plan.entry !== undefined ? { entry: dynamic.entry } : {}),
137
- }, plugin.id);
154
+ }, plugin.id, plugin.hosts);
138
155
  }
139
156
  catch {
140
157
  throw invalidPlan(plugin.id);
@@ -145,6 +162,7 @@ export async function planLifecycle(input, phase, extra = {}) {
145
162
  defaults: configDefaults ? [...staticDefaults, configDefaults] : staticDefaults,
146
163
  requires,
147
164
  configChanges: changes,
165
+ envSecrets: secrets,
148
166
  };
149
167
  }
150
168
  export async function checkPlugin(input) {
@@ -0,0 +1,47 @@
1
+ import { type CatalogPackage, type SearchResult } from './catalog.js';
2
+ export interface ListedPackage {
3
+ readonly id: string | null;
4
+ readonly package: string;
5
+ readonly kind: CatalogPackage['kind'];
6
+ readonly version: string;
7
+ readonly summary: string;
8
+ readonly maturity: CatalogPackage['maturity'];
9
+ readonly provides: readonly string[];
10
+ readonly install: string;
11
+ readonly core: string | null;
12
+ readonly compatible: boolean;
13
+ readonly installed: string | null;
14
+ /** With --online: the registry's latest and the highest version for this core. */
15
+ readonly registry?: {
16
+ latest: string | null;
17
+ compatible: string | null;
18
+ } | 'not-published';
19
+ }
20
+ export declare function pluginsList(root: string, options: {
21
+ compatible: boolean;
22
+ online: boolean;
23
+ }): Promise<{
24
+ source: 'snapshot' | 'registry';
25
+ coreVersion: string;
26
+ coreFrom: 'project' | 'catalog';
27
+ packages: ListedPackage[];
28
+ warnings: {
29
+ code: string;
30
+ message: string;
31
+ }[];
32
+ }>;
33
+ export declare function pluginsSearch(query: string): Promise<SearchResult>;
34
+ export declare function pluginsInfo(root: string, key: string): Promise<CatalogPackage & {
35
+ capabilityDetails: Record<string, string>;
36
+ installed: string | null;
37
+ compatible: boolean;
38
+ coreVersion: string;
39
+ }>;
40
+ /** The installed version's AI guide: path relative to the project and its content. */
41
+ export declare function pluginsDoc(root: string, key: string): Promise<{
42
+ id: string | null;
43
+ package: string;
44
+ version: string;
45
+ path: string;
46
+ content: string;
47
+ }>;
@@ -0,0 +1,189 @@
1
+ // `dokebi plugins list|search|info|doc` (D-0065): what Dokebi has, from the catalog snapshot
2
+ // shipped with this CLI, optionally refreshed from the registry; and what it does not have.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import satisfies from 'semver/functions/satisfies.js';
6
+ import { findPackage, loadCatalog, searchCatalog, } from './catalog.js';
7
+ import { debug, describeError, isMissing } from './diagnostics.js';
8
+ import { CliError } from './errors.js';
9
+ import { parseObject } from './filesystem.js';
10
+ import { fetchPackument, highestCompatible } from './registry.js';
11
+ import { installedPath, packageFile } from './workspace.js';
12
+ async function projectFacts(root) {
13
+ const installed = new Map();
14
+ let state;
15
+ try {
16
+ state = JSON.parse(await readFile(join(root, '.dokebi/state.json'), 'utf8'));
17
+ }
18
+ catch (error) {
19
+ if (isMissing(error))
20
+ return { core: null, installed };
21
+ throw new CliError('INVALID_STATE', 'Cannot read .dokebi/state.json', { cause: error });
22
+ }
23
+ for (const plugin of state.plugins ?? [])
24
+ installed.set(plugin.packageName, plugin.version);
25
+ let core = null;
26
+ for (const name of ['@dokebi/core', '@dokebi/config', '@dokebi/cli']) {
27
+ try {
28
+ const pkg = parseObject(await readFile(await packageFile(root, name, 'package.json'), 'utf8'), name);
29
+ if (typeof pkg.version === 'string') {
30
+ installed.set(name, pkg.version);
31
+ if (name === '@dokebi/core')
32
+ core = pkg.version;
33
+ }
34
+ }
35
+ catch (error) {
36
+ if (!isMissing(error) && !(error instanceof CliError))
37
+ throw error;
38
+ debug('Package not installed in this project', { package: name });
39
+ }
40
+ }
41
+ return { core, installed };
42
+ }
43
+ function listed(item, facts, core) {
44
+ return {
45
+ id: item.id,
46
+ package: item.package,
47
+ kind: item.kind,
48
+ version: item.version,
49
+ summary: item.summary,
50
+ maturity: item.maturity,
51
+ provides: item.provides,
52
+ install: item.install,
53
+ core: item.core,
54
+ compatible: item.core === null || satisfies(core, item.core),
55
+ installed: facts.installed.get(item.package) ?? null,
56
+ };
57
+ }
58
+ export async function pluginsList(root, options) {
59
+ const catalog = await loadCatalog();
60
+ const facts = await projectFacts(root);
61
+ const core = facts.core ?? catalog.core.version;
62
+ let packages = catalog.packages.map((item) => listed(item, facts, core));
63
+ const warnings = [];
64
+ let source = 'snapshot';
65
+ if (options.online) {
66
+ const offline = [];
67
+ const queue = [...packages];
68
+ const refreshed = new Map();
69
+ const worker = async () => {
70
+ for (let item = queue.shift(); item; item = queue.shift()) {
71
+ const found = await fetchPackument(item.package, root);
72
+ if (found.status === 'offline')
73
+ offline.push(item.package);
74
+ else
75
+ refreshed.set(item.package, found.status === 'missing'
76
+ ? 'not-published'
77
+ : { latest: found.latest, compatible: highestCompatible(found, core) });
78
+ }
79
+ };
80
+ await Promise.all(Array.from({ length: 6 }, worker));
81
+ packages = packages.map((item) => {
82
+ const registry = refreshed.get(item.package);
83
+ if (registry === undefined)
84
+ return item;
85
+ return {
86
+ ...item,
87
+ registry,
88
+ compatible: item.compatible && registry !== 'not-published' && registry.compatible !== null,
89
+ };
90
+ });
91
+ if (offline.length)
92
+ warnings.push({
93
+ code: 'CATALOG_OFFLINE',
94
+ message: `The registry did not answer for ${offline.length} packages; their entries come from the snapshot of CLI ${catalog.cli}`,
95
+ });
96
+ if (refreshed.size)
97
+ source = 'registry';
98
+ }
99
+ if (options.compatible)
100
+ packages = packages.filter((item) => item.compatible);
101
+ return {
102
+ source,
103
+ coreVersion: core,
104
+ coreFrom: facts.core ? 'project' : 'catalog',
105
+ packages,
106
+ warnings,
107
+ };
108
+ }
109
+ export async function pluginsSearch(query) {
110
+ return searchCatalog(await loadCatalog(), query);
111
+ }
112
+ function unknownPackage(catalog, key) {
113
+ const search = searchCatalog(catalog, key);
114
+ return new CliError('UNKNOWN_PLUGIN', `No Dokebi package "${key}"`, {
115
+ phase: 'arguments',
116
+ details: {
117
+ query: key,
118
+ matches: search.matches.map((item) => item.id ?? item.package),
119
+ absent: search.absent.map((entry) => ({
120
+ capability: entry.capability,
121
+ summary: entry.summary,
122
+ instead: entry.instead,
123
+ })),
124
+ hint: 'npx dokebi plugins search <words> --json',
125
+ },
126
+ });
127
+ }
128
+ export async function pluginsInfo(root, key) {
129
+ const catalog = await loadCatalog();
130
+ const item = findPackage(catalog, key);
131
+ if (!item)
132
+ throw unknownPackage(catalog, key);
133
+ const facts = await projectFacts(root);
134
+ const core = facts.core ?? catalog.core.version;
135
+ return {
136
+ ...item,
137
+ capabilityDetails: Object.fromEntries(item.provides.map((name) => [name, catalog.capabilities[name]?.summary ?? ''])),
138
+ installed: facts.installed.get(item.package) ?? null,
139
+ compatible: item.core === null || satisfies(core, item.core),
140
+ coreVersion: core,
141
+ };
142
+ }
143
+ /** The installed version's AI guide: path relative to the project and its content. */
144
+ export async function pluginsDoc(root, key) {
145
+ const catalog = await loadCatalog();
146
+ const item = findPackage(catalog, key);
147
+ const packageName = item?.package ?? (key.startsWith('@') ? key : undefined);
148
+ if (!packageName)
149
+ throw unknownPackage(catalog, key);
150
+ let pkg;
151
+ try {
152
+ pkg = parseObject(await readFile(await packageFile(root, packageName, 'package.json'), 'utf8'), packageName);
153
+ }
154
+ catch (error) {
155
+ if (!isMissing(error))
156
+ throw error;
157
+ debug('plugins doc: package not installed', {
158
+ package: packageName,
159
+ error: describeError(error),
160
+ });
161
+ throw new CliError('NOT_INSTALLED', `${packageName} is not installed in this project`, {
162
+ phase: 'arguments',
163
+ details: {
164
+ package: packageName,
165
+ hint: `npx dokebi add ${packageName} --dry-run --json`,
166
+ summary: item?.summary ?? null,
167
+ },
168
+ });
169
+ }
170
+ const manifest = pkg.dokebi;
171
+ const documentation = typeof manifest?.documentation === 'string' ? manifest.documentation : 'AI.md';
172
+ const path = `node_modules/${packageName}/${documentation}`;
173
+ let content;
174
+ try {
175
+ content = await readFile(await installedPath(root, path), 'utf8');
176
+ }
177
+ catch (error) {
178
+ if (!isMissing(error))
179
+ throw error;
180
+ throw new CliError('DOC_MISSING', `${path} is missing`, { cause: error, details: { path } });
181
+ }
182
+ return {
183
+ id: item?.id ?? null,
184
+ package: packageName,
185
+ version: String(pkg.version),
186
+ path,
187
+ content,
188
+ };
189
+ }
@@ -1 +1,36 @@
1
- export declare const PROJECT_GUIDE = "# Dokebi \uD504\uB85C\uC81D\uD2B8 \uC791\uC5C5 \uC548\uB0B4\n\n\uC124\uCE58\uB41C \uCF54\uC5B4 \uACC4\uC57D\uC740 [\uCF54\uC5B4 AI \uC0AC\uC6A9 \uC548\uB0B4](node_modules/@dokebi/core/AI.md)\uB97C \uC77D\uB294\uB2E4.\n\uD328\uD0A4\uC9C0 \uBC84\uC804\uC740 package.json\uACFC \uC2E4\uC81C node_modules\uB97C \uD568\uAED8 \uD655\uC778\uD55C\uB2E4.\n\n- \uD504\uB85C\uC81D\uD2B8 \uB8E8\uD2B8\uC5D0\uC11C \uC124\uCE58\uB41C CLI\uB85C dokebi inspect --json, dokebi doctor --json\uC744 \uC2E4\uD589\uD55C\uB2E4.\n- inspect\uC5D0 \uB098\uC628 \uD50C\uB7EC\uADF8\uC778 documentation\uACFC optionsSchema\uB97C \uD655\uC778\uD55C\uB2E4.\n- src/main.ts\uB294 \uC0AC\uC6A9\uC790 \uC9C4\uC785\uC810, dokebi.yaml\uC740 \uC0AC\uC6A9\uC790 \uC635\uC158\uC774\uB2E4.\n- \uD3F4\uB354 \uAD6C\uC870(D-0031\u00B7D-0032): src/apps/<\uC571>(\uC571 \uD558\uB098\uC758 app.ts\u00B7routes.ts\u00B7controllers\u00B7models\u00B7views\u00B7sockets), src/shared/modules/<\uBAA8\uB4C8>(\uAE30\uB2A5 \uD558\uB098\uC758 module.ts\u00B7routes.ts\u00B7controllers\u00B7models\u00B7views, dokebi module add\uB85C \uB9CC\uB4E4\uACE0 \uC571\uC758 modules: { '/\uC811\uB450\uC0AC': \uBAA8\uB4C8 }\uB85C \uBD99\uC778\uB2E4), src/shared/models(\uACF5\uD1B5 \uC21C\uC218 \uCF54\uB4DC), src/shared/views(\uACF5\uD1B5 \uB808\uC774\uC544\uC6C3\u00B7\uBD80\uBD84 \uD15C\uD50C\uB9BF), src/infra/<\uC774\uB984>(\uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0 \uC124\uC815: db/connections.ts, events/types.ts, inertia/shared.ts, web/requires.ts, worker/tasks.ts), src/plugins/<\uC774\uB984>(\uC9C1\uC811 \uB9CC\uB4E0 \uD50C\uB7EC\uADF8\uC778). \uC5C5\uBB34 \uCF54\uB4DC\uB294 apps\u00B7shared/modules\uC5D0\uB9CC, \uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0 \uCF54\uB4DC\uB294 infra\uC5D0\uB9CC \uB454\uB2E4. \uCEE8\uD2B8\uB864\uB7EC\uB294 \uC571\uC774\uB098 \uBAA8\uB4C8\uC5D0 \uC18D\uD55C\uB2E4.\n- \uC2E4\uC81C dokebi.yaml\uC740 Git\uC5D0 \uB123\uC9C0 \uC54A\uB294\uB2E4. dokebi.example.yaml\uC740 \uBE44\uBC00\uAC12 \uC5C6\uB294 \uCD5C\uCD08 \uC124\uC815 \uACAC\uBCF8\uC774\uB2E4.\n- JSON \uD504\uB85C\uC81D\uD2B8\uB294 core 0.2.x \uC124\uCE58 \uD6C4 dokebi migrate --dry-run\uC73C\uB85C \uC774\uC804 \uACC4\uD68D\uBD80\uD130 \uD655\uC778\uD55C\uB2E4.\n- config\uB294 \uCF54\uC5B4 \uD544\uC218 \uAD6C\uC131\uC73C\uB85C \uC790\uB3D9 \uC124\uCE58\uB418\uBA70 remove\uD560 \uC218 \uC5C6\uB2E4.\n- src/dokebi.generated.ts\uC640 .dokebi/state.json\uC740 \uC9C1\uC811 \uD3B8\uC9D1\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- add\uB294 \uD328\uD0A4\uC9C0 \uC9C0\uC815, upgrade\u00B7remove\uB294 \uD50C\uB7EC\uADF8\uC778 ID\uB97C \uBC1B\uB294\uB2E4. \uBA3C\uC800 --dry-run\uC73C\uB85C \uAC80\uD1A0\uD55C\uB2E4. \uC124\uCE58 \uC804 \uC124\uC815\uC740 add --set path=value\uB85C \uB118\uAE38 \uC218 \uC788\uB2E4.\n- \uD50C\uB7EC\uADF8\uC778\uC774 \uB9CC\uB4E0 \uC9C4\uC785 \uD30C\uC77C(\uC608: src/db/connections.ts)\uC740 \uC0AC\uC6A9\uC790 \uCF54\uB4DC\uB2E4. \uC9C1\uC811 \uACE0\uCE58\uACE0, \uC9C0\uC6B0\uC9C0 \uC54A\uB294\uB2E4.\n- doctor\uB294 \uC790\uB3D9 \uBCF5\uAD6C\uD558\uC9C0 \uC54A\uB294\uB2E4. \uC7A0\uAE08\uC774\uB098 \uBC31\uC5C5\uC744 \uC784\uC758 \uC0AD\uC81C\uD558\uAC70\uB098 \uC2E4\uD328\uB97C \uBB34\uC870\uAC74 \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- src\uC758 .ts\uB294 Node\uAC00 \uD0C0\uC785\uB9CC \uC9C0\uC6B0\uACE0 \uC2E4\uD589\uD55C\uB2E4. \uC0DD\uC131\uC790 \uB9E4\uAC1C\uBCC0\uC218 \uC18D\uC131\u00B7enum\u00B7namespace\uCC98\uB7FC \uC9C0\uC6B8 \uC218 \uC5C6\uB294 \uBB38\uBC95\uC740 \uC4F0\uC9C0 \uC54A\uB294\uB2E4(tsconfig.json\uC758 erasableSyntaxOnly\uAC00 \uD3B8\uC9D1\uAE30\uC640 npx tsc -p tsconfig.json\uC5D0\uC11C \uC54C\uB824 \uC900\uB2E4).\n- \uBCC0\uACBD \uD6C4 npm start\uC640 \uD504\uB85C\uC81D\uD2B8\uBCC4 \uD14C\uC2A4\uD2B8\uB97C \uC2E4\uD589\uD55C\uB2E4.\n";
1
+ import type { PluginManifest } from './manifest.js';
2
+ import type { RequiredPlugin } from './required-plugins.js';
3
+ /** Markers of the CLI-maintained list of installed packages in AGENTS.md (D-0065). */
4
+ export declare const INSTALLED_START = "<!-- dokebi:installed:start -->";
5
+ export declare const INSTALLED_END = "<!-- dokebi:installed:end -->";
6
+ export declare const AGENTS_FILE = "AGENTS.md";
7
+ export declare const CLAUDE_FILE = "CLAUDE.md";
8
+ /** CLAUDE.md of a new project: Claude Code reads AGENTS.md through this import line. */
9
+ export declare const CLAUDE_GUIDE = "@AGENTS.md\n";
10
+ /** One row of the installed list: a package, its version and its AI guide path. */
11
+ export interface InstalledEntry {
12
+ readonly id: string;
13
+ readonly packageName: string;
14
+ readonly version: string;
15
+ readonly documentation: string | null;
16
+ }
17
+ /** The block between the markers (markers included), rewritten by add, upgrade and remove. */
18
+ export declare function installedBlock(entries: readonly InstalledEntry[]): string;
19
+ /** The rows of the AGENTS.md installed list: core, CLI, required plugins, then plugins. */
20
+ export declare function guideEntries(core: string, required: readonly RequiredPlugin[], plugins: readonly PluginManifest[], cli: string): InstalledEntry[];
21
+ /**
22
+ * Whether the installed list in an AGENTS.md text was edited by hand (doctor
23
+ * GUIDE_BLOCK_MODIFIED, D-0072): a line that is not a marker, comment or table line, or rows
24
+ * whose `id | package | version` differ from `entries`. The core and CLI versions are not
25
+ * compared (npm can update them before the next add rewrites the block), nor the header,
26
+ * comment and AI.md path wording (a newer CLI may word them differently). undefined when the
27
+ * markers are missing (GUIDE_BLOCK_MISSING).
28
+ */
29
+ export declare function installedBlockEdited(text: string, entries: readonly InstalledEntry[]): boolean | undefined;
30
+ /**
31
+ * Replaces the block in an AGENTS.md text. Returns undefined when the markers are missing or out
32
+ * of order (the developer removed them; doctor reports GUIDE_BLOCK_MISSING and the file stays).
33
+ */
34
+ export declare function replaceInstalledBlock(text: string, block: string): string | undefined;
35
+ /** 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;
@@ -1,19 +1,142 @@
1
- export const PROJECT_GUIDE = `# Dokebi 프로젝트 작업 안내
2
-
3
- 설치된 코어 계약은 [코어 AI 사용 안내](node_modules/@dokebi/core/AI.md)를 읽는다.
4
- 패키지 버전은 package.json과 실제 node_modules를 함께 확인한다.
5
-
6
- - 프로젝트 루트에서 설치된 CLI로 dokebi inspect --json, dokebi doctor --json을 실행한다.
7
- - inspect에 나온 플러그인 documentation과 optionsSchema를 확인한다.
8
- - src/main.ts는 사용자 진입점, dokebi.yaml은 사용자 옵션이다.
9
- - 폴더 구조(D-0031·D-0032): src/apps/<앱>(앱 하나의 app.ts·routes.ts·controllers·models·views·sockets), src/shared/modules/<모듈>(기능 하나의 module.ts·routes.ts·controllers·models·views, dokebi module add로 만들고 앱의 modules: { '/접두사': 모듈 }로 붙인다), src/shared/models(공통 순수 코드), src/shared/views(공통 레이아웃·부분 템플릿), src/infra/<이름>(플러그인 연결 설정: db/connections.ts, events/types.ts, inertia/shared.ts, web/requires.ts, worker/tasks.ts), src/plugins/<이름>(직접 만든 플러그인). 업무 코드는 apps·shared/modules에만, 플러그인 연결 코드는 infra에만 둔다. 컨트롤러는 앱이나 모듈에 속한다.
10
- - 실제 dokebi.yaml은 Git에 넣지 않는다. dokebi.example.yaml은 비밀값 없는 최초 설정 견본이다.
11
- - JSON 프로젝트는 core 0.2.x 설치 후 dokebi migrate --dry-run으로 이전 계획부터 확인한다.
12
- - config는 코어 필수 구성으로 자동 설치되며 remove할 수 없다.
13
- - src/dokebi.generated.ts와 .dokebi/state.json은 직접 편집하지 않는다.
14
- - add는 패키지 지정, upgrade·remove는 플러그인 ID를 받는다. 먼저 --dry-run으로 검토한다. 설치 전 설정은 add --set path=value로 넘길 수 있다.
15
- - 플러그인이 만든 진입 파일(예: src/db/connections.ts)은 사용자 코드다. 직접 고치고, 지우지 않는다.
16
- - doctor는 자동 복구하지 않는다. 잠금이나 백업을 임의 삭제하거나 실패를 무조건 재시도하지 않는다.
17
- - src의 .ts는 Node가 타입만 지우고 실행한다. 생성자 매개변수 속성·enum·namespace처럼 지울 수 없는 문법은 쓰지 않는다(tsconfig.json의 erasableSyntaxOnly가 편집기와 npx tsc -p tsconfig.json에서 알려 준다).
18
- - 변경 후 npm start와 프로젝트별 테스트를 실행한다.
1
+ /** Markers of the CLI-maintained list of installed packages in AGENTS.md (D-0065). */
2
+ export const INSTALLED_START = '<!-- dokebi:installed:start -->';
3
+ export const INSTALLED_END = '<!-- dokebi:installed:end -->';
4
+ export const AGENTS_FILE = 'AGENTS.md';
5
+ export const CLAUDE_FILE = 'CLAUDE.md';
6
+ /** CLAUDE.md of a new project: Claude Code reads AGENTS.md through this import line. */
7
+ export const CLAUDE_GUIDE = '@AGENTS.md\n';
8
+ /** The block between the markers (markers included), rewritten by add, upgrade and remove. */
9
+ export function installedBlock(entries) {
10
+ const rows = entries.map((entry) => `| ${entry.id} | ${entry.packageName} | ${entry.version} | ${entry.documentation ? `node_modules/${entry.packageName}/${entry.documentation}` : '-'} |`);
11
+ return [
12
+ INSTALLED_START,
13
+ '<!-- dokebi add, upgrade and remove rewrite this block in the same transaction. Do not edit between these two markers by hand. -->',
14
+ '| Name | Package | Version | AI guide (read first) |',
15
+ '|---|---|---|---|',
16
+ ...rows,
17
+ INSTALLED_END,
18
+ ].join('\n');
19
+ }
20
+ /** The rows of the AGENTS.md installed list: core, CLI, required plugins, then plugins. */
21
+ export function guideEntries(core, required, plugins, cli) {
22
+ return [
23
+ { id: 'core', packageName: '@dokebi/core', version: core, documentation: 'AI.md' },
24
+ { id: 'cli', packageName: '@dokebi/cli', version: cli, documentation: 'AI.md' },
25
+ ...required.map((plugin) => ({
26
+ id: plugin.id,
27
+ packageName: plugin.packageName,
28
+ version: plugin.version,
29
+ documentation: 'AI.md',
30
+ })),
31
+ ...plugins.map((plugin) => ({
32
+ id: plugin.id,
33
+ packageName: plugin.packageName,
34
+ version: plugin.version,
35
+ documentation: plugin.documentation ?? null,
36
+ })),
37
+ ];
38
+ }
39
+ /**
40
+ * Whether the installed list in an AGENTS.md text was edited by hand (doctor
41
+ * GUIDE_BLOCK_MODIFIED, D-0072): a line that is not a marker, comment or table line, or rows
42
+ * whose `id | package | version` differ from `entries`. The core and CLI versions are not
43
+ * compared (npm can update them before the next add rewrites the block), nor the header,
44
+ * comment and AI.md path wording (a newer CLI may word them differently). undefined when the
45
+ * markers are missing (GUIDE_BLOCK_MISSING).
46
+ */
47
+ export function installedBlockEdited(text, entries) {
48
+ const start = text.indexOf(INSTALLED_START);
49
+ const end = text.indexOf(INSTALLED_END);
50
+ if (start === -1 || end === -1 || end < start)
51
+ return undefined;
52
+ const lines = text
53
+ .slice(start + INSTALLED_START.length, end)
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .filter(Boolean);
57
+ if (lines.some((line) => !line.startsWith('|') && !/^<!--.*-->$/.test(line)))
58
+ return true;
59
+ const key = (id, name, version) => `${id}|${name}|${id === 'core' || id === 'cli' ? '*' : version}`;
60
+ const table = lines.filter((line) => line.startsWith('|'));
61
+ const rows = table
62
+ .slice(1)
63
+ .filter((line) => !/^\|[\s|:-]*$/.test(line))
64
+ .map((line) => {
65
+ const cells = line.split('|').map((cell) => cell.trim());
66
+ return key(cells[1] ?? '', cells[2] ?? '', cells[3] ?? '');
67
+ });
68
+ const wanted = entries.map((entry) => key(entry.id, entry.packageName, entry.version));
69
+ return rows.length !== wanted.length || rows.some((row, index) => row !== wanted[index]);
70
+ }
71
+ /**
72
+ * Replaces the block in an AGENTS.md text. Returns undefined when the markers are missing or out
73
+ * of order (the developer removed them; doctor reports GUIDE_BLOCK_MISSING and the file stays).
74
+ */
75
+ export function replaceInstalledBlock(text, block) {
76
+ const start = text.indexOf(INSTALLED_START);
77
+ const end = text.indexOf(INSTALLED_END);
78
+ if (start === -1 || end === -1 || end < start)
79
+ return undefined;
80
+ return text.slice(0, start) + block + text.slice(end + INSTALLED_END.length);
81
+ }
82
+ /** AGENTS.md of a new project (D-0065 skeleton): ask first, use the catalog, verify last. */
83
+ export function projectGuide(entries) {
84
+ return `# Working on this Dokebi project (for AI agents)
85
+
86
+ This project is built with the Dokebi framework. The CLI is the \`@dokebi/cli\` installed in this project; run it from the project root as \`npx dokebi …\`.
87
+ The npm package named \`dokebi\` is not this CLI: never run \`npx -y dokebi\` (if \`node_modules/@dokebi/cli\` is missing, run \`npm install\`; outside a project use \`npx -y @dokebi/cli …\`).
88
+ Run the command-line tools installed in this project by their bin names from the project root: \`npx dokebi …\` and, with @dokebi/migrate, \`npx dokebi-migrate …\`. Never run a file inside \`node_modules/*/dist/\` and do not use \`npm exec --package …\` for them.
89
+
90
+ ## 0. First
91
+
92
+ 1. \`npx dokebi guide --json\`: the steps from a one-line request to a verified app (this document in short) and every command.
93
+ 2. \`npx dokebi inspect --json\`: installed plugins, versions, option schemas and diagnostics.
94
+ 3. Read the AI guide of every package in the "Installed packages" table below (the AI.md of the installed version). Read a plugin's AI.md before you write code with it (\`npx dokebi plugins doc <id> --json\`). The installed AI.md is right where older documents on the internet or your memory disagree.
95
+
96
+ ## 1. Do not decide alone: ask first
97
+
98
+ - Find the recipe for the request: \`npx dokebi recipe list --json\` (a blog is \`blog\`, a JSON API is \`api\`).
99
+ - When answers are missing, \`npx dokebi recipe <name> --json\` installs nothing and returns \`status: "needs-answers"\`, \`waitForUser: true\` and \`questions[]\` (options, pros and cons, what each installs, what it needs, the default and its reason), with the error \`ANSWERS_REQUIRED\` (exit 2).
100
+ - Choices such as the view (ejs, inertia-vue, inertia-vue-ssr), the database (postgres, mysql, mariadb, mongodb, none), the development database (docker, url) and an admin area are **the user's: show them as questions and get the answers**. Tell the user that an option with \`available: false\` (absent) does not exist; do not offer it.
101
+ - **Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use \`--defaults\` if the user said to decide.** After showing the questions, do not go on installing ("I will continue with the defaults while you choose").
102
+ - Use \`--defaults\` only when the user left the choice to you ("you decide"). Then report the chosen values and their \`defaultReason\` to the user when you are done (\`verify\` returns them as \`tellUser\`, see 3).
103
+ - When the user answers, pass the answers as flags and add \`--answers-from user\`. Look at the plan first with \`--dry-run --json\` (\`plan.steps\`, \`preflight\`), then run it (applying without \`--answers-from user\` or \`--defaults\` fails with \`ANSWERS_SOURCE_REQUIRED\`). Tell the user the \`report\` of the result (where the answers came from, the defaults, and what to know, such as no CSRF protection). On a preflight error (\`DOCKER_MISSING\`, \`PORT_IN_USE\`) ask the user; never switch to another database or port behind their back.
104
+
105
+ ## 2. Plugins
106
+
107
+ - What exists: \`npx dokebi plugins list --json\`; find: \`npx dokebi plugins search <word|capability> --json\`; details: \`npx dokebi plugins info <id> --json\`.
108
+ - The \`absent\` entries of a search (for example auth, csrf, upload, mail, markdown, sqlite, React) are **features Dokebi does not have**. Never invent a plugin, flag or API for them: build them in app code as their \`instead\` says, or tell the user.
109
+ - Install with \`npx dokebi add <package> --dry-run --json\` first, then the same command without \`--dry-run\`. Without a version, add picks the newest version that fits this core (\`resolved\`).
110
+ - Never install or run a package outside \`@dokebi/\` (someone else's package, \`THIRD_PARTY_PACKAGE\`) without the permission of the user.
111
+ - Never pass a secret (password, token, connection URL) with \`--set\` or \`config set\` (the shell history keeps it). Write it into dokebi.yaml yourself, use \`npx dokebi config secret <id>.<path>\` for a generated one, or use the value \`npx dokebi dev-infra\` generated.
112
+ - Secrets a plugin reads from an environment variable (for example \`DOKEBI_SESSION_SECRETS\` of @dokebi/session) live in \`.env\`: \`npx dokebi add\` generates the ones a plugin declares (\`generatedSecrets\` names them), and \`npx dokebi dev\`, \`start\` and \`verify\` pass \`.env\` to the app (a variable set in the environment wins). Do not wrap the CLI in \`node --env-file\`, never commit \`.env\` and never print its values.
113
+
114
+ ## 3. Before you say it is done
115
+
116
+ - \`npx dokebi check --json\`: the code convention check (empty \`catch\` and \`.catch(() => {})\`, a promise without await, syntax Node cannot strip (enum, parameter properties, namespaces with values, decorators), private paths such as \`@dokebi/*/dist\` and \`@dokebi/*\` packages that are not installed, edited generated files, secrets that would be committed). Every problem has \`file\`, \`line\` and \`fix\`: fix it as \`fix\` says. Only an empty catch or a background promise that is really intended gets a \`// dokebi-check: allow <rule> <reason>\` comment.
117
+ - Say "done" only after \`npx dokebi verify --json\` returns \`ok: true\` (doctor, check, typecheck, build, start, HTTP checks, stop).
118
+ - **Your final message to the user must include verify's \`tellUser\` lines**, word for word: the choices of the recipe and who made them (a default with its reason) and what to know, such as no CSRF protection for HTML forms. They come from \`.dokebi/recipe.json\`, which \`recipe\` writes; do not edit it.
119
+ - When \`ok: false\`, fix the failed items of \`checks[]\` (\`code\`, \`details\`) and run verify again. Never hide a failure or turn a check off.
120
+
121
+ ## 4. Structure rules
122
+
123
+ - \`src/main.ts\` is the user entry point, \`dokebi.yaml\` holds the real settings of this machine (kept out of Git), and \`dokebi.example.yaml\` is a sample without secrets.
124
+ - Folders (D-0031, D-0032): \`src/apps/<app>\` (one app: app.ts, routes.ts, controllers, models, views), \`src/shared/modules/<module>\` (one feature; create it with \`npx dokebi module add\` and attach it in the app with \`modules: { '/prefix': module }\`), \`src/shared/models\` and \`src/shared/views\` (shared code), \`src/infra/<name>\` (plugin connection code, for example \`src/infra/db/connections.ts\`), \`src/plugins/<name>\` (your own plugins). Business code lives only in apps and shared/modules.
125
+ - **Files the CLI rewrites** are only \`src/dokebi.generated.ts\`, \`.dokebi/state.json\` and the part of this file between the \`dokebi:installed\` markers. Never edit them by hand; change them with commands (\`dokebi check\` reports \`generated-file\`).
126
+ - **Every other file the CLI or a plugin created is user code**: the shared layout and views (\`src/shared/views/layouts/main.ejs\` and others), entry files (for example \`src/infra/db/connections.ts\`, \`src/infra/web/extension.ts\`), and the app and module files \`app add\` and \`module add\` create (app.ts, routes.ts, controllers, models, views and the examples \`items.ts\`, \`summary.ts\`, \`site.ts\`). **Edit them where they are** (do not move or copy them). doctor does not warn about them, and upgrade does not overwrite an edited one (the new template location is in \`preservedDetails\`).
127
+ - Node runs the .ts files in \`src\` by stripping their types. Do not use syntax that cannot be stripped, such as constructor parameter properties, enum or namespace (\`npm run typecheck\` reports it). Do not run \`npx tsc\` (the npm package named tsc is not TypeScript).
128
+ - doctor repairs nothing. Never delete the lock (\`.dokebi/lock\`) or a backup, and never blindly re-run a failed command.
129
+ - config (\`@dokebi/config\`) is a required part of the core and cannot be removed.
130
+
131
+ ## 5. Errors and forms (D-0074)
132
+
133
+ - **Refuse input with \`throw validationError({ field: 'message' })\`** (\`import { validationError } from '@dokebi/web'\`), not with your own \`reply.code(422).send(...)\`. Clients then get exactly what a refused route schema gives them: 422 \`VALIDATION_FAILED\` with \`fields: [{ field, message }]\` and \`errors: { field: message }\`. Other refusals throw a \`DokebiError\` with a \`status\` (403, 404, 409…).
134
+ - **JSON or HTML is decided for you**: Inertia requests, \`fetch\`/XHR, Bearer API clients and JSON bodies get JSON errors; only plain browser pages (a navigation, an HTML form post) get an HTML page. Read errors from the JSON (\`body.errors.name\`); do not parse HTML.
135
+ - **Inertia forms** (\`useForm().post/put/patch/delete\`, \`router.post/…\`): pass \`only\` with the props the submit changes, for example \`form.post(url, { only: ['items'] })\`. With @dokebi/session a validation error goes back (303) with the messages in \`form.errors.<field>\` (\`errors\` always comes along); without \`only\` the GET after the redirect sends every prop of the page again. \`npx dokebi check\` warns about a submit without \`only\` (\`inertia-form-only\`).
136
+ - **Error pages**: an EJS app shows \`views/<template>/errors/<status>.ejs\` (then \`errors/error.ejs\`) for browser pages, with \`status\`, \`error\`, \`message\`, \`hint\` and \`fields\`; an Inertia app may set \`inertia: { errorPage: 'Error' }\` to render its \`Error\` page component.
137
+
138
+ ## Installed packages
139
+
140
+ ${installedBlock(entries)}
19
141
  `;
142
+ }
@@ -10,7 +10,17 @@ export interface Diagnostic {
10
10
  /** A command that resolves the diagnostic. */
11
11
  hint?: string;
12
12
  }
13
+ /** The short form of an inspection (first in the result; the default human output). */
14
+ export interface InspectionSummary {
15
+ healthy: boolean;
16
+ errors: number;
17
+ warnings: number;
18
+ plugins: number;
19
+ /** Diagnostic code → how often it occurs. */
20
+ codes: Record<string, number>;
21
+ }
13
22
  export interface Inspection {
23
+ summary: InspectionSummary;
14
24
  root: string;
15
25
  healthy: boolean;
16
26
  coreVersion: string | null;
@@ -10,6 +10,7 @@ import { orderPlugins, readManifest } from './manifest.js';
10
10
  import { dependencyMap, validateProjectPackage } from './npm-client.js';
11
11
  import { checkPlugin } from './plugin-lifecycle.js';
12
12
  import { PluginOptionsValidator } from './plugin-options.js';
13
+ import { AGENTS_FILE, guideEntries, INSTALLED_END, INSTALLED_START, installedBlockEdited, } from './project-guide.js';
13
14
  import { readState } from './project-state.js';
14
15
  import { readRequiredPlugins } from './required-plugins.js';
15
16
  import { installedPath } from './workspace.js';
@@ -19,6 +20,7 @@ export class ProjectInspector {
19
20
  async inspect(directory, settings = {}) {
20
21
  const root = await realpath(directory);
21
22
  const result = {
23
+ summary: { healthy: false, errors: 0, warnings: 0, plugins: 0, codes: {} },
22
24
  root,
23
25
  healthy: false,
24
26
  coreVersion: null,
@@ -157,7 +159,7 @@ export class ProjectInspector {
157
159
  if (present === false)
158
160
  issue('ENTRY_MISSING', entry, plugin.id);
159
161
  // Installed before the D-0031 layout: it keeps working where it is.
160
- if (legacyLayout(entry, plugin.id))
162
+ if (legacyLayout(entry, plugin.hosts))
161
163
  issue('LEGACY_LAYOUT', entry, plugin.id, 'warning');
162
164
  }
163
165
  if (settings.runChecks && installed && config && pkg) {
@@ -193,6 +195,7 @@ export class ProjectInspector {
193
195
  code: item.code,
194
196
  ...(item.optionPath ? { optionPath: item.optionPath } : {}),
195
197
  },
198
+ ...(item.hint ? { hint: item.hint } : {}),
196
199
  });
197
200
  }
198
201
  }
@@ -209,10 +212,22 @@ export class ProjectInspector {
209
212
  }
210
213
  if (core)
211
214
  await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core, result.requiredPlugins));
215
+ // D-0065 §11: only CLI-owned files (`owner: 'cli'`, regenerated by the CLI or a plugin) are
216
+ // diagnosed when edited. Scaffolds written for the developer (`owner: 'user'`: layouts,
217
+ // views, examples, entry files) are expected to change; upgrade keeps an edited copy.
218
+ const entries = new Set(Object.values(state.installations).flatMap((item) => (item.entry ? [item.entry] : [])));
212
219
  for (const [path, owned] of Object.entries(state.files)) {
220
+ if (owned.owner !== 'cli' || entries.has(path))
221
+ continue;
213
222
  const actual = await check(path, async () => fingerprint(await securePath(root, path)));
214
223
  if (actual !== owned.hash)
215
- issue('MODIFIED_TEMPLATE', path, null, 'warning');
224
+ result.diagnostics.push({
225
+ code: 'MODIFIED_TEMPLATE',
226
+ severity: 'warning',
227
+ path,
228
+ plugin: owned.owners[0] ?? null,
229
+ hint: `${path} is regenerated by ${owned.owners.join(', ')} (owner: cli); move your changes to a user-owned file, or restore it`,
230
+ });
216
231
  }
217
232
  }
218
233
  for (const [path, expected] of originals) {
@@ -222,6 +237,30 @@ export class ProjectInspector {
222
237
  return true;
223
238
  });
224
239
  }
240
+ // D-0065: AGENTS.md carries the CLI-maintained installed list between two markers.
241
+ await check(AGENTS_FILE, async () => {
242
+ const path = await securePath(root, AGENTS_FILE);
243
+ const text = (await exists(path)) ? await readFile(path, 'utf8') : null;
244
+ if (text === null || !text.includes(INSTALLED_START) || !text.includes(INSTALLED_END))
245
+ result.diagnostics.push({
246
+ code: 'GUIDE_BLOCK_MISSING',
247
+ severity: 'warning',
248
+ path: AGENTS_FILE,
249
+ plugin: null,
250
+ hint: `put the lines ${INSTALLED_START} and ${INSTALLED_END} in AGENTS.md; the next add, upgrade or remove fills the list between them`,
251
+ });
252
+ // D-0072: a hand edit between the markers is lost on the next add, upgrade or remove.
253
+ else if (state &&
254
+ installedBlockEdited(text, guideEntries('*', result.requiredPlugins, state.plugins, '*')))
255
+ result.diagnostics.push({
256
+ code: 'GUIDE_BLOCK_MODIFIED',
257
+ severity: 'warning',
258
+ path: AGENTS_FILE,
259
+ plugin: null,
260
+ hint: `move notes outside ${INSTALLED_START} … ${INSTALLED_END} and restore the rows (one per installed package: npx dokebi inspect --json); add, upgrade and remove rewrite them`,
261
+ });
262
+ return true;
263
+ });
225
264
  await check('.dokebi/lock', async () => {
226
265
  if ((await exists(await securePath(root, '.dokebi/lock'))) &&
227
266
  !result.diagnostics.some((item) => item.code === 'PROJECT_LOCKED'))
@@ -229,6 +268,16 @@ export class ProjectInspector {
229
268
  return true;
230
269
  });
231
270
  result.healthy = !result.diagnostics.some((item) => item.severity === 'error');
271
+ const codes = {};
272
+ for (const item of result.diagnostics)
273
+ codes[item.code] = (codes[item.code] ?? 0) + 1;
274
+ result.summary = {
275
+ healthy: result.healthy,
276
+ errors: result.diagnostics.filter((item) => item.severity === 'error').length,
277
+ warnings: result.diagnostics.filter((item) => item.severity === 'warning').length,
278
+ plugins: result.plugins.length,
279
+ codes,
280
+ };
232
281
  return result;
233
282
  }
234
283
  }