@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.
|
|
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": "
|
|
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
|
-
|
|
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,
|
package/src/lib/plopfile.mjs
CHANGED
|
@@ -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);
|