@kosdev-code/kos-ui-cli 2.1.26 → 2.1.28

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.
package/README.md CHANGED
@@ -8,6 +8,7 @@ The KOS UI CLI (`kosui`) provides tools for:
8
8
 
9
9
  - Generating KOS models with proper architecture patterns
10
10
  - Creating OpenAPI type definitions and service wrappers from device APIs
11
+ - Comparing API versions for breaking change detection
11
12
  - Managing workspace configurations
12
13
  - Scaffolding new KOS projects
13
14
 
@@ -31,6 +32,7 @@ npx @kosdev-code/kos-ui-cli <command>
31
32
  |---------|-------------|---------|
32
33
  | `model` | Generate KOS models | `kosui model UserProfile` |
33
34
  | `api:generate` | Generate API types from OpenAPI | `kosui api:generate --project my-app` |
35
+ | `api:compare` | Compare API versions for compatibility | `kosui api:compare --project my-app --app kos --base v1.8.0 --target v1.9.0` |
34
36
  | `workspace` | Workspace utilities | `kosui workspace:list-models` |
35
37
 
36
38
  ## Commands
@@ -176,6 +178,139 @@ private onDeviceStatusLoaded(ctx: ExecutionContext<typeof PATH_DEVICE_STATUS>):
176
178
  }
177
179
  ```
178
180
 
181
+ ### kosui api:compare
182
+
183
+ Compare OpenAPI versions to detect breaking changes and API compatibility issues.
184
+
185
+ Analyzes two generated API type definitions to identify:
186
+ - Removed endpoints or HTTP methods
187
+ - New or removed parameters
188
+ - Parameter requirement changes
189
+ - Request body modifications
190
+
191
+ #### Prerequisites
192
+
193
+ Generate types for both versions before comparing:
194
+
195
+ ```bash
196
+ # Connect to base version device and generate types
197
+ kosui api:generate --project my-models --apps kos
198
+
199
+ # Connect to target version device and generate types
200
+ kosui api:generate --project my-models --apps kos
201
+ ```
202
+
203
+ #### Options
204
+
205
+ **Required:**
206
+ - `--project <name>` - Project containing generated API types
207
+ - `--app <name>` - App namespace to compare (kos, dispense, freestyle)
208
+ - `--base <version>` - Base version to compare from
209
+ - `--target <version>` - Target version to compare to
210
+
211
+ **Optional:**
212
+ - `--format <format>` - Output format: console (default), json, markdown
213
+ - `--output <file>` - Write output to file instead of stdout
214
+ - `--apps <apps>` - Compare multiple apps (comma-separated)
215
+
216
+ #### Examples
217
+
218
+ **Basic comparison:**
219
+ ```bash
220
+ kosui api:compare \
221
+ --project device-models \
222
+ --app kos \
223
+ --base v1.8.0 \
224
+ --target v1.9.0
225
+ ```
226
+
227
+ **Generate markdown release notes:**
228
+ ```bash
229
+ kosui api:compare \
230
+ --project device-models \
231
+ --app kos \
232
+ --base v1.8.0 \
233
+ --target v1.9.0 \
234
+ --format markdown \
235
+ --output CHANGELOG-API.md
236
+ ```
237
+
238
+ **JSON output for automation:**
239
+ ```bash
240
+ kosui api:compare \
241
+ --project device-models \
242
+ --app kos \
243
+ --base v1.8.0 \
244
+ --target v1.9.0 \
245
+ --format json
246
+ ```
247
+
248
+ **Compare multiple apps:**
249
+ ```bash
250
+ kosui api:compare \
251
+ --project device-models \
252
+ --apps kos,dispense,freestyle \
253
+ --base v1.8.0 \
254
+ --target v1.9.0
255
+ ```
256
+
257
+ #### Output Formats
258
+
259
+ **Console (default):**
260
+ - Colored terminal output with compatibility status
261
+ - Summary statistics (added/removed/modified endpoints)
262
+ - Breaking changes highlighted
263
+ - Recommendations for safe upgrade
264
+
265
+ **JSON:**
266
+ - Machine-readable format for automation
267
+ - Complete comparison data structure
268
+ - Suitable for CI/CD pipelines
269
+
270
+ **Markdown:**
271
+ - Ready for release notes and documentation
272
+ - Organized sections for breaking/non-breaking changes
273
+ - Suitable for commit messages or changelogs
274
+
275
+ #### Exit Codes
276
+
277
+ The command uses exit codes for automation:
278
+
279
+ - **Exit 0** - Versions are backward compatible
280
+ - **Exit 1** - Breaking changes detected
281
+
282
+ Example CI/CD usage:
283
+ ```bash
284
+ kosui api:compare --project models --app kos --base v1 --target v2
285
+ if [ $? -eq 0 ]; then
286
+ echo "Safe to upgrade"
287
+ else
288
+ echo "Breaking changes detected"
289
+ fi
290
+ ```
291
+
292
+ #### Integration with Build Process
293
+
294
+ Add comparison target to `project.json`:
295
+
296
+ ```json
297
+ {
298
+ "targets": {
299
+ "api:compare": {
300
+ "executor": "nx:run-commands",
301
+ "options": {
302
+ "command": "kosui api:compare --project device-models --app kos --base ${BASE} --target ${TARGET}"
303
+ }
304
+ }
305
+ }
306
+ }
307
+ ```
308
+
309
+ Run via Nx:
310
+ ```bash
311
+ BASE=v1.8.0 TARGET=v1.9.0 npx nx run device-models:api:compare
312
+ ```
313
+
179
314
  ### kosui model
180
315
 
181
316
  Generate KOS models with proper architecture patterns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kosdev-code/kos-ui-cli",
3
- "version": "2.1.26",
3
+ "version": "2.1.28",
4
4
  "bin": {
5
5
  "kosui": "./src/lib/cli.mjs"
6
6
  },
@@ -22,7 +22,7 @@
22
22
  "main": "./src/index.js",
23
23
  "kos": {
24
24
  "build": {
25
- "gitHash": "5af05d3d902c4ddbee74f34723b5acd86dfbdeb7"
25
+ "gitHash": "de3ec24ed2fd4a39115ae0c10eeb3876aedb2c22"
26
26
  }
27
27
  },
28
28
  "publishConfig": {
@@ -0,0 +1,139 @@
1
+ import { compareApiVersions } from "./lib/compare-api.mjs";
2
+
3
+ /**
4
+ * Registers the api:compare generator with Plop for comparing OpenAPI versions
5
+ * and detecting breaking changes between API type definitions.
6
+ *
7
+ * This generator analyzes two generated API type definition files to identify
8
+ * compatibility issues such as removed endpoints, changed parameters, and
9
+ * breaking changes. Essential for safe API upgrades and release planning.
10
+ *
11
+ * @param {import('plop').NodePlopAPI} plop - The Plop API instance
12
+ * @returns {Promise<void>} Resolves when generator is registered
13
+ *
14
+ * @example
15
+ * ```javascript
16
+ * // In plopfile.mjs
17
+ * import registerApiCompare from './generators/api/compare.mjs';
18
+ * await registerApiCompare(plop);
19
+ * ```
20
+ */
21
+ export default async function register(plop) {
22
+ plop.setGenerator("api:compare", {
23
+ description: "Compare OpenAPI versions for compatibility analysis",
24
+ prompts: [
25
+ {
26
+ type: "input",
27
+ name: "project",
28
+ message: "Project name containing generated API types",
29
+ validate: (input) => {
30
+ if (!input || input.trim() === "") {
31
+ return "Project name is required";
32
+ }
33
+ return true;
34
+ },
35
+ },
36
+ {
37
+ type: "input",
38
+ name: "app",
39
+ message: "App namespace to compare (kos, dispense, freestyle, etc.)",
40
+ validate: (input) => {
41
+ if (!input || input.trim() === "") {
42
+ return "App namespace is required";
43
+ }
44
+ return true;
45
+ },
46
+ },
47
+ {
48
+ type: "input",
49
+ name: "baseVersion",
50
+ message: "Base version to compare from",
51
+ validate: (input) => {
52
+ if (!input || input.trim() === "") {
53
+ return "Base version is required";
54
+ }
55
+ return true;
56
+ },
57
+ },
58
+ {
59
+ type: "input",
60
+ name: "targetVersion",
61
+ message: "Target version to compare to",
62
+ validate: (input) => {
63
+ if (!input || input.trim() === "") {
64
+ return "Target version is required";
65
+ }
66
+ return true;
67
+ },
68
+ },
69
+ {
70
+ type: "list",
71
+ name: "format",
72
+ message: "Output format",
73
+ choices: ["console", "json", "markdown"],
74
+ default: "console",
75
+ },
76
+ {
77
+ type: "input",
78
+ name: "outputFile",
79
+ message: "Output file path (optional, leave empty for stdout)",
80
+ default: "",
81
+ },
82
+ ],
83
+ actions: function (answers) {
84
+ return [
85
+ async function compareApi() {
86
+ try {
87
+ const options = {
88
+ project: answers.project,
89
+ app: answers.app,
90
+ apps: answers.apps, // Support multi-app comparison
91
+ baseVersion: answers.baseVersion,
92
+ targetVersion: answers.targetVersion,
93
+ format: answers.format,
94
+ outputFile: answers.outputFile || undefined,
95
+ };
96
+
97
+ const result = await compareApiVersions(options);
98
+
99
+ if (answers.outputFile) {
100
+ return `[ok] Comparison written to ${answers.outputFile}`;
101
+ } else {
102
+ return `[ok] Comparison complete`;
103
+ }
104
+ } catch (error) {
105
+ console.error("Error comparing API versions:", error);
106
+ throw new Error(`Failed to compare API versions: ${error.message}`);
107
+ }
108
+ },
109
+ ];
110
+ },
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Metadata for CLI integration describing the api:compare command.
116
+ *
117
+ * Provides command key, description, and argument mappings for the CLI
118
+ * help system and command-line argument parsing.
119
+ *
120
+ * @type {Object}
121
+ * @property {string} key - Command identifier used in CLI
122
+ * @property {string} name - Human-readable command name
123
+ * @property {string} description - Command description for help text
124
+ * @property {Object} namedArguments - Maps CLI argument names to prompt property names
125
+ */
126
+ export const metadata = {
127
+ key: "api:compare",
128
+ name: "Compare API Versions",
129
+ description: "Analyze compatibility between OpenAPI versions and detect breaking changes",
130
+ namedArguments: {
131
+ project: "project",
132
+ app: "app",
133
+ apps: "apps", // For multi-app comparison
134
+ base: "baseVersion",
135
+ target: "targetVersion",
136
+ format: "format",
137
+ output: "outputFile",
138
+ },
139
+ };
@@ -0,0 +1,748 @@
1
+ import { readFileSync, existsSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { getProjectDetails } from '../../../utils/nx-context.mjs';
5
+
6
+ /**
7
+ * Compares two OpenAPI versions and generates a detailed compatibility report.
8
+ *
9
+ * Analyzes generated TypeScript type definitions (openapi.d.ts files) to detect
10
+ * breaking changes, new endpoints, and API modifications. Supports multiple output
11
+ * formats and can compare multiple app namespaces simultaneously.
12
+ *
13
+ * @param {Object} options - Comparison options
14
+ * @param {string} options.project - Project name containing generated API types
15
+ * @param {string} [options.app] - Single app namespace to compare (e.g., 'kos', 'dispense')
16
+ * @param {string[]} [options.apps] - Array of app namespaces for multi-app comparison
17
+ * @param {string} options.baseVersion - Base version identifier (e.g., 'v1.8.0')
18
+ * @param {string} options.targetVersion - Target version identifier (e.g., 'v1.9.0')
19
+ * @param {('console'|'json'|'markdown')} [options.format='console'] - Output format
20
+ * @param {string} [options.outputFile] - Optional file path to write output
21
+ * @returns {Promise<{compatible: boolean, results: Array}>} Comparison results with compatibility status
22
+ * @throws {Error} When project not found or required type files don't exist
23
+ *
24
+ * @example
25
+ * ```javascript
26
+ * // Basic comparison
27
+ * const result = await compareApiVersions({
28
+ * project: 'device-models',
29
+ * app: 'kos',
30
+ * baseVersion: 'v1.8.0',
31
+ * targetVersion: 'v1.9.0',
32
+ * format: 'console'
33
+ * });
34
+ *
35
+ * if (!result.compatible) {
36
+ * console.error('Breaking changes detected!');
37
+ * process.exit(1);
38
+ * }
39
+ * ```
40
+ *
41
+ * @example
42
+ * ```javascript
43
+ * // Multi-app comparison with markdown output
44
+ * await compareApiVersions({
45
+ * project: 'device-models',
46
+ * apps: ['kos', 'dispense', 'freestyle'],
47
+ * baseVersion: 'v1.8.0',
48
+ * targetVersion: 'v1.9.0',
49
+ * format: 'markdown',
50
+ * outputFile: 'CHANGELOG-API.md'
51
+ * });
52
+ * ```
53
+ */
54
+ export async function compareApiVersions(options) {
55
+ const {
56
+ project,
57
+ app,
58
+ apps, // Array for multi-app comparison
59
+ baseVersion,
60
+ targetVersion,
61
+ format = 'console',
62
+ outputFile,
63
+ } = options;
64
+
65
+ // Get project configuration (following generate-api.mjs pattern exactly)
66
+ let projectConfig, src, outputPath;
67
+
68
+ try {
69
+ projectConfig = await getProjectDetails(project);
70
+ src = projectConfig.sourceRoot || projectConfig.root;
71
+ outputPath = projectConfig.targets?.api?.options?.outputPath;
72
+ } catch (error) {
73
+ throw new Error(`Project "${project}" not found in workspace: ${error.message}`);
74
+ }
75
+
76
+ // Use custom output path if specified, otherwise default to src/utils
77
+ const utilsDir = outputPath ? join(src, outputPath) : join(src, "utils");
78
+ const servicesDir = join(utilsDir, "services");
79
+
80
+ // Determine which apps to compare
81
+ const appsToCompare = apps || [app];
82
+ const results = [];
83
+
84
+ // Compare each app
85
+ for (const appName of appsToCompare) {
86
+ const baseSpecPath = join(servicesDir, appName, baseVersion, 'openapi.d.ts');
87
+ const targetSpecPath = join(servicesDir, appName, targetVersion, 'openapi.d.ts');
88
+
89
+ // Validate files exist
90
+ if (!existsSync(baseSpecPath)) {
91
+ throw new Error(
92
+ `Base version not found: ${baseSpecPath}\n` +
93
+ `Generate it by running: kosui api:generate --project ${project}\n` +
94
+ `(Point to ${baseVersion} device first)`
95
+ );
96
+ }
97
+
98
+ if (!existsSync(targetSpecPath)) {
99
+ throw new Error(
100
+ `Target version not found: ${targetSpecPath}\n` +
101
+ `Generate it by running: kosui api:generate --project ${project}\n` +
102
+ `(Point to ${targetVersion} device first)`
103
+ );
104
+ }
105
+
106
+ // Load and parse specs
107
+ const baseContent = readFileSync(baseSpecPath, 'utf-8');
108
+ const targetContent = readFileSync(targetSpecPath, 'utf-8');
109
+
110
+ const basePaths = extractPaths(baseContent);
111
+ const targetPaths = extractPaths(targetContent);
112
+
113
+ // Perform comparison
114
+ const comparison = compareSpecs(basePaths, targetPaths, {
115
+ app: appName,
116
+ baseVersion,
117
+ targetVersion,
118
+ });
119
+
120
+ results.push(comparison);
121
+ }
122
+
123
+ // Format and output results
124
+ const formatted = formatResults(results, format);
125
+
126
+ if (outputFile) {
127
+ writeFileSync(outputFile, formatted, 'utf-8');
128
+ } else {
129
+ console.log(formatted);
130
+ }
131
+
132
+ // Return exit code based on compatibility
133
+ const hasBreakingChanges = results.some(r => !r.summary.compatible);
134
+ process.exitCode = hasBreakingChanges ? 1 : 0;
135
+
136
+ return {
137
+ compatible: !hasBreakingChanges,
138
+ results,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Extracts API paths and their HTTP methods from OpenAPI TypeScript type definitions.
144
+ *
145
+ * Parses the `export interface paths` section of a generated openapi.d.ts file
146
+ * to build a map of endpoint paths to their available HTTP methods and parameters.
147
+ *
148
+ * @param {string} content - Content of openapi.d.ts file
149
+ * @returns {Map<string, Map<string, Object>>} Map of paths to methods with parameter info
150
+ *
151
+ * @example
152
+ * ```javascript
153
+ * const content = readFileSync('openapi.d.ts', 'utf-8');
154
+ * const paths = extractPaths(content);
155
+ * // paths.get('/api/kos/device/status') → Map { 'get' => { params: {...} } }
156
+ * ```
157
+ */
158
+ function extractPaths(content) {
159
+ const paths = new Map();
160
+ const pathsMatch = content.match(/export interface paths \{([\s\S]*?)\n\}/);
161
+
162
+ if (!pathsMatch) {
163
+ return paths;
164
+ }
165
+
166
+ const pathsContent = pathsMatch[1];
167
+ const lines = pathsContent.split('\n');
168
+ let currentPath = null;
169
+ let braceCount = 0;
170
+ let pathContent = '';
171
+
172
+ for (const line of lines) {
173
+ // Match path property (quoted string at start of line)
174
+ const pathMatch = line.match(/^\s*"([^"]+)":\s*\{/);
175
+
176
+ if (pathMatch && braceCount === 0) {
177
+ currentPath = pathMatch[1];
178
+ braceCount = 1;
179
+ pathContent = line;
180
+ } else if (currentPath) {
181
+ pathContent += '\n' + line;
182
+ braceCount += (line.match(/\{/g) || []).length;
183
+ braceCount -= (line.match(/\}/g) || []).length;
184
+
185
+ if (braceCount === 0) {
186
+ const methods = extractMethods(pathContent);
187
+ paths.set(currentPath, methods);
188
+ currentPath = null;
189
+ pathContent = '';
190
+ }
191
+ }
192
+ }
193
+
194
+ return paths;
195
+ }
196
+
197
+ /**
198
+ * Extracts HTTP methods and their parameters from a path definition string.
199
+ *
200
+ * Parses the TypeScript definition for a single path to identify available
201
+ * HTTP methods (GET, POST, PUT, DELETE, PATCH) and their query, path, and
202
+ * body parameters.
203
+ *
204
+ * @param {string} pathDef - TypeScript definition string for a single path
205
+ * @returns {Map<string, Object>} Map of method names to parameter definitions
206
+ *
207
+ * @example
208
+ * ```javascript
209
+ * const pathDef = `"/api/kos/device/status": {
210
+ * get: { parameters: { query: { detailed?: boolean } } }
211
+ * }`;
212
+ * const methods = extractMethods(pathDef);
213
+ * // methods.get('get') → { params: { query: [...], path: [...], body: '...' } }
214
+ * ```
215
+ */
216
+ function extractMethods(pathDef) {
217
+ const methods = new Map();
218
+ const methodNames = ['get', 'post', 'put', 'delete', 'patch'];
219
+
220
+ for (const methodName of methodNames) {
221
+ // Match method property (unquoted identifier with any indentation)
222
+ const methodRegex = new RegExp(`^\\s*${methodName}:\\s*\\{`, 'gm');
223
+ const match = methodRegex.exec(pathDef);
224
+
225
+ if (match) {
226
+ const startIdx = match.index;
227
+ let braceCount = 1;
228
+ let endIdx = startIdx + match[0].length;
229
+
230
+ for (let i = endIdx; i < pathDef.length; i++) {
231
+ if (pathDef[i] === '{') braceCount++;
232
+ if (pathDef[i] === '}') braceCount--;
233
+ if (braceCount === 0) {
234
+ endIdx = i + 1;
235
+ break;
236
+ }
237
+ }
238
+
239
+ const methodDef = pathDef.substring(startIdx, endIdx);
240
+
241
+ const params = {
242
+ query: extractParameters(methodDef, 'query'),
243
+ path: extractParameters(methodDef, 'path'),
244
+ body: extractRequestBody(methodDef)
245
+ };
246
+
247
+ methods.set(methodName, { params });
248
+ }
249
+ }
250
+
251
+ return methods;
252
+ }
253
+
254
+ /**
255
+ * Extracts parameters of a specific type from an HTTP method definition.
256
+ *
257
+ * Parses query or path parameters from the TypeScript definition, identifying
258
+ * parameter names and whether they are required or optional.
259
+ *
260
+ * @param {string} methodDef - TypeScript definition for an HTTP method
261
+ * @param {('query'|'path')} paramType - Type of parameters to extract
262
+ * @returns {Array<{name: string, required: boolean}>} Array of parameter definitions
263
+ *
264
+ * @example
265
+ * ```javascript
266
+ * const methodDef = `get: { parameters: { query: { id: string, optional?: string } } }`;
267
+ * const params = extractParameters(methodDef, 'query');
268
+ * // Returns: [{ name: 'id', required: true }, { name: 'optional', required: false }]
269
+ * ```
270
+ */
271
+ function extractParameters(methodDef, paramType) {
272
+ const params = [];
273
+ const paramMatch = methodDef.match(new RegExp(`${paramType}[?]?:\\s*\\{([\\s\\S]*?)\\n\\s*\\}`, 'm'));
274
+
275
+ if (paramMatch) {
276
+ const paramContent = paramMatch[1];
277
+ const paramRegex = /(\w+)(\?)?:/g;
278
+ let match;
279
+
280
+ while ((match = paramRegex.exec(paramContent)) !== null) {
281
+ if (match[1] !== 'never' && match[1] !== 'header' && match[1] !== 'cookie') {
282
+ params.push({
283
+ name: match[1],
284
+ required: !match[2]
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ return params;
291
+ }
292
+
293
+ /**
294
+ * Checks if an HTTP method definition includes a request body.
295
+ *
296
+ * Determines whether the endpoint expects a request body by checking for
297
+ * the presence of requestBody schema in the TypeScript definition.
298
+ *
299
+ * @param {string} methodDef - TypeScript definition for an HTTP method
300
+ * @returns {('present'|'none')} Whether a request body is expected
301
+ *
302
+ * @example
303
+ * ```javascript
304
+ * const methodDef = `post: { requestBody: { schema: {...} } }`;
305
+ * extractRequestBody(methodDef); // Returns: 'present'
306
+ * ```
307
+ */
308
+ function extractRequestBody(methodDef) {
309
+ const bodyMatch = methodDef.match(/requestBody[:\?]\s*\{[\s\S]*?schema:/);
310
+ return bodyMatch ? 'present' : 'none';
311
+ }
312
+
313
+ /**
314
+ * Compares parameters between two API versions to detect changes.
315
+ *
316
+ * Identifies added, removed, and modified parameters, classifying changes
317
+ * as either breaking (removed params, new required params) or safe
318
+ * (new optional params, optional becoming required).
319
+ *
320
+ * @param {Array<{name: string, required: boolean}>} baseParams - Parameters from base version
321
+ * @param {Array<{name: string, required: boolean}>} targetParams - Parameters from target version
322
+ * @returns {Array<{type: string, param: string, severity: string}>} Array of parameter changes
323
+ *
324
+ * @example
325
+ * ```javascript
326
+ * const base = [{ name: 'id', required: true }];
327
+ * const target = [{ name: 'id', required: false }, { name: 'filter', required: true }];
328
+ * const changes = compareParameters(base, target);
329
+ * // Returns changes including:
330
+ * // - { type: 'changed', param: 'id', change: 'now optional', severity: 'safe' }
331
+ * // - { type: 'added', param: 'filter', required: true, severity: 'breaking' }
332
+ * ```
333
+ */
334
+ function compareParameters(baseParams, targetParams) {
335
+ const changes = [];
336
+ const baseNames = new Set(baseParams.map(p => p.name));
337
+ const targetNames = new Set(targetParams.map(p => p.name));
338
+
339
+ // Removed parameters (breaking)
340
+ for (const param of baseParams) {
341
+ if (!targetNames.has(param.name)) {
342
+ changes.push({ type: 'removed', param: param.name, severity: 'breaking' });
343
+ }
344
+ }
345
+
346
+ // Added parameters
347
+ for (const param of targetParams) {
348
+ if (!baseNames.has(param.name)) {
349
+ const severity = param.required ? 'breaking' : 'safe';
350
+ changes.push({ type: 'added', param: param.name, required: param.required, severity });
351
+ }
352
+ }
353
+
354
+ // Changed required status
355
+ for (const baseParam of baseParams) {
356
+ const targetParam = targetParams.find(p => p.name === baseParam.name);
357
+ if (targetParam && baseParam.required !== targetParam.required) {
358
+ const severity = targetParam.required ? 'breaking' : 'safe';
359
+ changes.push({
360
+ type: 'changed',
361
+ param: baseParam.name,
362
+ change: targetParam.required ? 'now required' : 'now optional',
363
+ severity
364
+ });
365
+ }
366
+ }
367
+
368
+ return changes;
369
+ }
370
+
371
+ /**
372
+ * Performs comprehensive comparison of two OpenAPI specifications.
373
+ *
374
+ * Analyzes paths, methods, and parameters to generate a detailed compatibility
375
+ * report including breaking changes, new endpoints, and modifications. The
376
+ * comparison identifies API evolution and determines backward compatibility.
377
+ *
378
+ * @param {Map<string, Map<string, Object>>} basePaths - Parsed paths from base version
379
+ * @param {Map<string, Map<string, Object>>} targetPaths - Parsed paths from target version
380
+ * @param {Object} metadata - Version metadata (app, baseVersion, targetVersion)
381
+ * @returns {Object} Comprehensive comparison results with summary and detailed changes
382
+ *
383
+ * @example
384
+ * ```javascript
385
+ * const basePaths = extractPaths(baseContent);
386
+ * const targetPaths = extractPaths(targetContent);
387
+ * const results = compareSpecs(basePaths, targetPaths, {
388
+ * app: 'kos',
389
+ * baseVersion: 'v1.8.0',
390
+ * targetVersion: 'v1.9.0'
391
+ * });
392
+ *
393
+ * console.log(`Compatible: ${results.summary.compatible}`);
394
+ * console.log(`Breaking changes: ${results.summary.breakingChanges.length}`);
395
+ * ```
396
+ */
397
+ function compareSpecs(basePaths, targetPaths, metadata) {
398
+ const results = {
399
+ metadata,
400
+ summary: {
401
+ totalPathsBase: basePaths.size,
402
+ totalPathsTarget: targetPaths.size,
403
+ addedPaths: [],
404
+ removedPaths: [],
405
+ modifiedPaths: [],
406
+ compatible: true,
407
+ breakingChanges: []
408
+ }
409
+ };
410
+
411
+ // Find added paths
412
+ for (const [path, methods] of targetPaths) {
413
+ if (!basePaths.has(path)) {
414
+ results.summary.addedPaths.push({
415
+ path,
416
+ methods: Array.from(methods.keys()).map(m => m.toUpperCase())
417
+ });
418
+ }
419
+ }
420
+
421
+ // Find removed paths (breaking)
422
+ for (const [path, methods] of basePaths) {
423
+ if (!targetPaths.has(path)) {
424
+ results.summary.removedPaths.push({
425
+ path,
426
+ methods: Array.from(methods.keys()).map(m => m.toUpperCase())
427
+ });
428
+ results.summary.compatible = false;
429
+ results.summary.breakingChanges.push({
430
+ path,
431
+ change: `${Array.from(methods.keys()).map(m => m.toUpperCase()).join(', ')} removed`
432
+ });
433
+ }
434
+ }
435
+
436
+ // Find modified paths
437
+ for (const [path, baseMethods] of basePaths) {
438
+ if (!targetPaths.has(path)) continue;
439
+
440
+ const targetMethods = targetPaths.get(path);
441
+ const changes = [];
442
+
443
+ // Check for removed methods (breaking)
444
+ for (const [method, baseMethodDef] of baseMethods) {
445
+ if (!targetMethods.has(method)) {
446
+ changes.push(`${method.toUpperCase()} method removed`);
447
+ results.summary.compatible = false;
448
+ results.summary.breakingChanges.push({
449
+ path,
450
+ change: `${method.toUpperCase()} method removed`
451
+ });
452
+ }
453
+ }
454
+
455
+ // Check for added methods (safe)
456
+ for (const [method] of targetMethods) {
457
+ if (!baseMethods.has(method)) {
458
+ changes.push(`Added ${method.toUpperCase()} method`);
459
+ }
460
+ }
461
+
462
+ // Check for parameter changes
463
+ for (const [method, baseMethodDef] of baseMethods) {
464
+ const targetMethodDef = targetMethods.get(method);
465
+ if (!targetMethodDef) continue;
466
+
467
+ // Query parameters
468
+ const queryChanges = compareParameters(baseMethodDef.params.query, targetMethodDef.params.query);
469
+ for (const change of queryChanges) {
470
+ const changeDesc = `${method.toUpperCase()} query parameter "${change.param}" ${change.type}${change.change ? ` (${change.change})` : ''}`;
471
+
472
+ if (change.severity === 'breaking') {
473
+ results.summary.compatible = false;
474
+ results.summary.breakingChanges.push({ path, change: changeDesc });
475
+ } else {
476
+ changes.push(changeDesc);
477
+ }
478
+ }
479
+
480
+ // Path parameters
481
+ const pathChanges = compareParameters(baseMethodDef.params.path, targetMethodDef.params.path);
482
+ for (const change of pathChanges) {
483
+ const changeDesc = `${method.toUpperCase()} path parameter "${change.param}" ${change.type}${change.change ? ` (${change.change})` : ''}`;
484
+
485
+ if (change.severity === 'breaking') {
486
+ results.summary.compatible = false;
487
+ results.summary.breakingChanges.push({ path, change: changeDesc });
488
+ } else {
489
+ changes.push(changeDesc);
490
+ }
491
+ }
492
+
493
+ // Request body changes
494
+ if (baseMethodDef.params.body !== targetMethodDef.params.body) {
495
+ const changeDesc = targetMethodDef.params.body === 'present' && baseMethodDef.params.body === 'none'
496
+ ? `${method.toUpperCase()} request body now required`
497
+ : `${method.toUpperCase()} request body changed`;
498
+
499
+ const severity = targetMethodDef.params.body === 'present' && baseMethodDef.params.body === 'none' ? 'breaking' : 'safe';
500
+
501
+ if (severity === 'breaking') {
502
+ results.summary.compatible = false;
503
+ results.summary.breakingChanges.push({ path, change: changeDesc });
504
+ } else {
505
+ changes.push(changeDesc);
506
+ }
507
+ }
508
+ }
509
+
510
+ if (changes.length > 0) {
511
+ results.summary.modifiedPaths.push({ path, changes });
512
+ }
513
+ }
514
+
515
+ return results;
516
+ }
517
+
518
+ /**
519
+ * Formats comparison results according to the specified output format.
520
+ *
521
+ * Delegates to format-specific functions to generate console, JSON, or
522
+ * markdown output suitable for different use cases (terminal display,
523
+ * automation, documentation).
524
+ *
525
+ * @param {Array<Object>} results - Array of comparison results (one per app)
526
+ * @param {('console'|'json'|'markdown')} format - Desired output format
527
+ * @returns {string} Formatted comparison output
528
+ *
529
+ * @example
530
+ * ```javascript
531
+ * const results = [{ metadata: {...}, summary: {...} }];
532
+ * const output = formatResults(results, 'markdown');
533
+ * writeFileSync('CHANGELOG.md', output);
534
+ * ```
535
+ */
536
+ function formatResults(results, format) {
537
+ switch (format) {
538
+ case 'json':
539
+ return formatJson(results);
540
+ case 'markdown':
541
+ return formatMarkdown(results);
542
+ case 'console':
543
+ default:
544
+ return formatConsole(results);
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Formats comparison results as JSON for machine-readable output.
550
+ *
551
+ * Generates a structured JSON representation suitable for automation,
552
+ * CI/CD pipelines, and programmatic analysis.
553
+ *
554
+ * @param {Array<Object>} results - Array of comparison results
555
+ * @returns {string} JSON-formatted comparison data
556
+ *
557
+ * @example
558
+ * ```javascript
559
+ * const json = formatJson(results);
560
+ * const parsed = JSON.parse(json);
561
+ * console.log(`Breaking changes: ${parsed[0].summary.breakingChanges.length}`);
562
+ * ```
563
+ */
564
+ function formatJson(results) {
565
+ return JSON.stringify(results, null, 2);
566
+ }
567
+
568
+ /**
569
+ * Formats comparison results as Markdown for documentation and release notes.
570
+ *
571
+ * Generates well-structured markdown with sections for summary, breaking changes,
572
+ * new endpoints, removed endpoints, and modifications. Suitable for changelogs,
573
+ * release notes, and technical documentation.
574
+ *
575
+ * @param {Array<Object>} results - Array of comparison results
576
+ * @returns {string} Markdown-formatted comparison report
577
+ *
578
+ * @example
579
+ * ```javascript
580
+ * const markdown = formatMarkdown(results);
581
+ * writeFileSync('CHANGELOG-API.md', markdown);
582
+ * ```
583
+ */
584
+ function formatMarkdown(results) {
585
+ let output = [];
586
+
587
+ for (const result of results) {
588
+ const { metadata, summary } = result;
589
+
590
+ output.push(`# API Changes: ${metadata.app} (${metadata.baseVersion} → ${metadata.targetVersion})\n`);
591
+
592
+ // Summary
593
+ output.push(`## Summary\n`);
594
+ output.push(`- Total endpoints: ${summary.totalPathsBase} → ${summary.totalPathsTarget}`);
595
+ output.push(`- Added: ${summary.addedPaths.length}`);
596
+ output.push(`- Removed: ${summary.removedPaths.length}`);
597
+ output.push(`- Modified: ${summary.modifiedPaths.length}`);
598
+ output.push(`- Breaking changes: ${summary.breakingChanges.length}`);
599
+ output.push(`- **Status**: ${summary.compatible ? '✅ Backward compatible' : '❌ Not backward compatible'}\n`);
600
+
601
+ // Breaking changes
602
+ if (summary.breakingChanges.length > 0) {
603
+ output.push(`## Breaking Changes\n`);
604
+ for (const change of summary.breakingChanges) {
605
+ output.push(`- \`${change.path}\` - ${change.change}`);
606
+ }
607
+ output.push('');
608
+ }
609
+
610
+ // New endpoints
611
+ if (summary.addedPaths.length > 0) {
612
+ output.push(`## New Endpoints\n`);
613
+ for (const { path, methods } of summary.addedPaths) {
614
+ output.push(`- \`${path}\` - ${methods.join(', ')}`);
615
+ }
616
+ output.push('');
617
+ }
618
+
619
+ // Removed endpoints
620
+ if (summary.removedPaths.length > 0) {
621
+ output.push(`## Removed Endpoints\n`);
622
+ for (const { path, methods } of summary.removedPaths) {
623
+ output.push(`- \`${path}\` - ${methods.join(', ')}`);
624
+ }
625
+ output.push('');
626
+ }
627
+
628
+ // Modified endpoints
629
+ if (summary.modifiedPaths.length > 0) {
630
+ output.push(`## Modified Endpoints (Non-Breaking)\n`);
631
+ for (const { path, changes } of summary.modifiedPaths) {
632
+ output.push(`- \`${path}\` - ${changes.join(', ')}`);
633
+ }
634
+ output.push('');
635
+ }
636
+ }
637
+
638
+ return output.join('\n');
639
+ }
640
+
641
+ /**
642
+ * Formats comparison results with colored terminal output for human readability.
643
+ *
644
+ * Generates visually appealing console output with color-coded sections,
645
+ * compatibility status, and actionable recommendations. Uses chalk for
646
+ * terminal color support.
647
+ *
648
+ * @param {Array<Object>} results - Array of comparison results
649
+ * @returns {string} Colored terminal output with ASCII decorations
650
+ *
651
+ * @example
652
+ * ```javascript
653
+ * const output = formatConsole(results);
654
+ * console.log(output); // Displays colored comparison in terminal
655
+ * ```
656
+ */
657
+ function formatConsole(results) {
658
+ let output = [];
659
+
660
+ for (const result of results) {
661
+ const { metadata, summary } = result;
662
+
663
+ // Header
664
+ output.push(chalk.gray('═'.repeat(63)));
665
+ output.push(chalk.bold.cyan('API Compatibility Analysis\n'));
666
+ output.push(chalk.white(`App: ${metadata.app}`));
667
+ output.push(chalk.white(`Base Version: ${metadata.baseVersion}`));
668
+ output.push(chalk.white(`Target Version: ${metadata.targetVersion}`));
669
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
670
+
671
+ // Compatibility status
672
+ if (summary.compatible) {
673
+ output.push(chalk.green.bold('BACKWARD COMPATIBLE\n'));
674
+ } else {
675
+ output.push(chalk.red.bold('BREAKING CHANGES DETECTED\n'));
676
+ }
677
+
678
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
679
+
680
+ // Summary
681
+ output.push(chalk.bold('Summary\n'));
682
+ output.push(chalk.white(`Total Paths in Base: ${summary.totalPathsBase}`));
683
+ output.push(chalk.white(`Total Paths in Target: ${summary.totalPathsTarget}`));
684
+ output.push(chalk.white(`Added Paths: ${summary.addedPaths.length}`));
685
+ output.push(chalk.white(`Removed Paths: ${summary.removedPaths.length}`));
686
+ output.push(chalk.white(`Modified Paths: ${summary.modifiedPaths.length}`));
687
+ output.push(chalk.white(`Breaking Changes: ${summary.breakingChanges.length}`));
688
+ output.push('');
689
+
690
+ // Breaking changes
691
+ if (summary.breakingChanges.length > 0) {
692
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
693
+ output.push(chalk.red.bold('BREAKING CHANGES\n'));
694
+ for (const change of summary.breakingChanges) {
695
+ output.push(chalk.yellow(`${change.path}`) + chalk.white(` - ${change.change}`));
696
+ }
697
+ output.push('');
698
+ }
699
+
700
+ // New endpoints
701
+ if (summary.addedPaths.length > 0) {
702
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
703
+ output.push(chalk.green.bold('NEW ENDPOINTS\n'));
704
+ for (const { path, methods } of summary.addedPaths) {
705
+ output.push(chalk.cyan(path) + chalk.white(` - ${methods.join(', ')}`));
706
+ }
707
+ output.push('');
708
+ }
709
+
710
+ // Removed endpoints
711
+ if (summary.removedPaths.length > 0) {
712
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
713
+ output.push(chalk.red.bold('REMOVED ENDPOINTS\n'));
714
+ for (const { path, methods } of summary.removedPaths) {
715
+ output.push(chalk.red(path) + chalk.white(` - ${methods.join(', ')}`));
716
+ }
717
+ output.push('');
718
+ }
719
+
720
+ // Modified endpoints
721
+ if (summary.modifiedPaths.length > 0) {
722
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
723
+ output.push(chalk.blue.bold('MODIFIED ENDPOINTS (Non-Breaking)\n'));
724
+ for (const { path, changes } of summary.modifiedPaths) {
725
+ output.push(chalk.cyan(path) + chalk.white(` - ${changes.join(', ')}`));
726
+ }
727
+ output.push('');
728
+ }
729
+
730
+ output.push(chalk.gray('═'.repeat(63)) + '\n');
731
+
732
+ // Recommendations
733
+ output.push(chalk.bold('Recommendations\n'));
734
+ if (!summary.compatible) {
735
+ output.push(chalk.yellow('Migration Required - Breaking changes detected'));
736
+ output.push(chalk.white('Review all breaking changes before upgrading'));
737
+ } else if (summary.addedPaths.length > 0) {
738
+ output.push(chalk.green('Safe to upgrade - No breaking changes'));
739
+ output.push(chalk.white('New endpoints available to use'));
740
+ } else {
741
+ output.push(chalk.green('Versions are identical or fully compatible'));
742
+ }
743
+
744
+ output.push('\n' + chalk.gray('═'.repeat(63)));
745
+ }
746
+
747
+ return output.join('\n');
748
+ }
@@ -1,5 +1,5 @@
1
1
  // generators/workspace/index.mjs
2
- import { createWorkspace } from "create-nx-workspace";
2
+ // Note: create-nx-workspace is imported lazily to avoid dependency errors
3
3
  import { required } from "../../utils/validators.mjs";
4
4
  import registerListModels from "./list-models.mjs";
5
5
  import registerListProjects from "./list-projects.mjs";
@@ -15,6 +15,8 @@ export const metadata = {
15
15
  };
16
16
  export default async function (plop) {
17
17
  plop.setActionType("createWorkspace", async function (answers) {
18
+ // Lazy import to avoid dependency errors when not using workspace generator
19
+ const { createWorkspace } = await import("create-nx-workspace");
18
20
  await createWorkspace("@kosdev-code/kos-nx-plugin", {
19
21
  nxCloud: "skip",
20
22
  name: answers.workspaceName,
@@ -13,6 +13,7 @@ import registerComponent from "./generators/component/index.mjs";
13
13
  import registerPluginComponent from "./generators/plugin/index.mjs";
14
14
 
15
15
  import registerApiGenerate from "./generators/api/generate.mjs";
16
+ import registerApiCompare from "./generators/api/compare.mjs";
16
17
  import registerCacheGenerators from "./generators/cache/index.mjs";
17
18
  import registerDev from "./generators/dev/index.mjs";
18
19
  import registerEnv from "./generators/env/index.mjs";
@@ -62,6 +63,7 @@ export default async function (plop) {
62
63
  await registerI18n(plop);
63
64
  await registerI18nNamespace(plop);
64
65
  await registerApiGenerate(plop);
66
+ await registerApiCompare(plop);
65
67
  await registerDev(plop);
66
68
  await registerEnv(plop);
67
69
  await registerKab(plop);