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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,452 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
2
+ import openapiTS, { astToString } from "openapi-typescript";
3
+ import { join } from "path";
4
+ import { getProjectDetails } from "../../../utils/nx-context.mjs";
5
+
6
+ /**
7
+ * Generate OpenAPI service types for a project
8
+ * @param {Object} options - Generation options
9
+ * @param {string} options.project - Project name
10
+ * @param {string} [options.host] - API host URL
11
+ * @param {boolean} [options.studio] - Whether to use Studio API endpoint
12
+ * @param {string[]} [options.apps] - Apps to include (if not specified, include all)
13
+ * @param {string} [options.outputPath] - Custom output path relative to sourceRoot
14
+ * @param {Object.<string, string>} [options.serviceExportAliases] - Custom export aliases
15
+ * @param {string} [options.defaultVersion] - Default version for exports
16
+ * @param {Object.<string, string>} [options.appVersions] - Custom versions per app
17
+ * @param {boolean} [options.excludeCore] - Exclude core SDK services
18
+ */
19
+ export async function generateApiTypes(options) {
20
+ const {
21
+ project: projectName,
22
+ host: customHost,
23
+ studio = false,
24
+ apps: appsToInclude = [],
25
+ outputPath: customOutputPath,
26
+ serviceExportAliases: customAliases,
27
+ defaultVersion: customDefaultVersion,
28
+ appVersions: customAppVersions,
29
+ allAppsVersion,
30
+ excludeCore = false,
31
+ } = options;
32
+
33
+ let projectConfig, host, src, outputPath, serviceExportAliases, defaultVersion, appVersions;
34
+
35
+ try {
36
+ // Read project configuration using existing CLI utility
37
+ projectConfig = await getProjectDetails(projectName);
38
+
39
+ // Merge CLI options with project.json config (CLI takes precedence)
40
+ host = customHost || projectConfig.targets?.api?.options?.host || "http://127.0.0.1:8081";
41
+ outputPath = customOutputPath || projectConfig.targets?.api?.options?.outputPath;
42
+ serviceExportAliases = {
43
+ ...(projectConfig.targets?.api?.options?.serviceExportAliases || {}),
44
+ ...(customAliases || {})
45
+ };
46
+ defaultVersion = customDefaultVersion || projectConfig.targets?.api?.options?.defaultVersion;
47
+ appVersions = {
48
+ ...(projectConfig.targets?.api?.options?.appVersions || {}),
49
+ ...(customAppVersions || {})
50
+ };
51
+ src = projectConfig.sourceRoot || projectConfig.root;
52
+ } catch (error) {
53
+ // Fallback if project reading fails
54
+ console.warn("Warning: Could not read project configuration, using defaults");
55
+ console.warn(`Error: ${error.message}`);
56
+ host = customHost || "http://127.0.0.1:8081";
57
+ src = `libs/${projectName}/src`;
58
+ serviceExportAliases = customAliases || {};
59
+ defaultVersion = customDefaultVersion;
60
+ appVersions = customAppVersions || {};
61
+ outputPath = customOutputPath;
62
+ }
63
+
64
+ // Use custom output path if specified, otherwise default to src/utils
65
+ const utilsDir = outputPath ? join(src, outputPath) : join(src, "utils");
66
+ const servicesDir = join(utilsDir, "services");
67
+ mkdirSync(servicesDir, { recursive: true });
68
+
69
+ console.log(`Generating API types for ${projectName} from ${host}`);
70
+ const path = studio ? "/api/openapi/api" : "/api/kos/openapi/api";
71
+
72
+ // Fetch the full OpenAPI spec
73
+ const openapiUrl = new URL(`${host}${path}`, import.meta.url);
74
+ const response = await fetch(openapiUrl);
75
+ const fullSpec = await response.json();
76
+
77
+ // Helper function to determine app name from path
78
+ function getAppFromPath(path) {
79
+ const segments = path.split("/").filter(Boolean);
80
+
81
+ if (segments.length < 2) {
82
+ return "other";
83
+ }
84
+
85
+ // /api/kos/* → "kos"
86
+ if (path.startsWith("/api/kos")) {
87
+ return "kos";
88
+ }
89
+
90
+ // /api/app/{appName}/* → appName
91
+ if (path.startsWith("/api/app/")) {
92
+ return segments[2] || "app";
93
+ }
94
+
95
+ // /api/ext/{extName}/* → extName
96
+ if (path.startsWith("/api/ext/")) {
97
+ return segments[2] || "ext";
98
+ }
99
+
100
+ // /api/{other}/* → other
101
+ if (path.startsWith("/api/")) {
102
+ return segments[1] || "other";
103
+ }
104
+
105
+ return "other";
106
+ }
107
+
108
+ // Group paths by app
109
+ const appGroups = {};
110
+ for (const [path, pathItem] of Object.entries(fullSpec.paths || {})) {
111
+ const appName = getAppFromPath(path);
112
+
113
+ if (!appGroups[appName]) {
114
+ appGroups[appName] = {
115
+ paths: {},
116
+ components: new Set(),
117
+ };
118
+ }
119
+
120
+ appGroups[appName].paths[path] = pathItem;
121
+
122
+ // Collect referenced components from this path
123
+ const pathJson = JSON.stringify(pathItem);
124
+ const refMatches = pathJson.matchAll(/#\/components\/schemas\/([^"]+)/g);
125
+ for (const match of refMatches) {
126
+ appGroups[appName].components.add(match[1]);
127
+ }
128
+ }
129
+
130
+ // Helper function to recursively collect component dependencies
131
+ function collectComponentDependencies(componentId, allComponents, collected = new Set()) {
132
+ if (collected.has(componentId)) {
133
+ return collected;
134
+ }
135
+
136
+ collected.add(componentId);
137
+
138
+ const component = allComponents[componentId];
139
+ if (!component) {
140
+ return collected;
141
+ }
142
+
143
+ // Find all schema references in this component
144
+ const componentJson = JSON.stringify(component);
145
+ const refMatches = componentJson.matchAll(/#\/components\/schemas\/([^"]+)/g);
146
+
147
+ for (const match of refMatches) {
148
+ const refId = match[1];
149
+ if (!collected.has(refId)) {
150
+ collectComponentDependencies(refId, allComponents, collected);
151
+ }
152
+ }
153
+
154
+ return collected;
155
+ }
156
+
157
+ // Core SDK services that should be excluded in third-party apps
158
+ const CORE_SDK_SERVICES = ['kos', 'vfs', 'dispense', 'freestyle', 'handle', 'kosdev.ddk'];
159
+
160
+ // Filter apps based on command line arguments and core service exclusion
161
+ let appsToProcess = Object.entries(appGroups);
162
+
163
+ // Exclude core services if requested
164
+ if (excludeCore) {
165
+ appsToProcess = appsToProcess.filter(([appName]) => !CORE_SDK_SERVICES.includes(appName));
166
+ console.log(`Excluding core SDK services: ${CORE_SDK_SERVICES.join(', ')}`);
167
+ }
168
+
169
+ // If specific apps are requested, further filter to only those
170
+ if (appsToInclude.length > 0) {
171
+ appsToProcess = appsToProcess.filter(([appName]) => appsToInclude.includes(appName));
172
+ console.log(`Filtering to include only: ${appsToInclude.join(', ')}`);
173
+ }
174
+
175
+ // Generate a separate OpenAPI spec file for each app
176
+ const generatedFiles = [];
177
+
178
+ for (const [appName, appData] of appsToProcess) {
179
+ // Collect all component dependencies
180
+ const allComponentIds = new Set();
181
+ for (const componentId of appData.components) {
182
+ collectComponentDependencies(componentId, fullSpec.components?.schemas || {}, allComponentIds);
183
+ }
184
+
185
+ // Build filtered components
186
+ const filteredComponents = {};
187
+ for (const componentId of allComponentIds) {
188
+ if (fullSpec.components?.schemas?.[componentId]) {
189
+ filteredComponents[componentId] = fullSpec.components.schemas[componentId];
190
+ }
191
+ }
192
+
193
+ // Create app-specific spec
194
+ const appSpec = {
195
+ openapi: fullSpec.openapi,
196
+ info: {
197
+ ...fullSpec.info,
198
+ title: `${fullSpec.info.title} - ${appName}`,
199
+ },
200
+ paths: appData.paths,
201
+ components: {
202
+ schemas: filteredComponents,
203
+ },
204
+ };
205
+
206
+ // Determine version: priority order is allAppsVersion (CLI override), appVersions (per-app config), OpenAPI spec, fallback
207
+ const version = allAppsVersion || appVersions[appName] || fullSpec.info.version || "v1";
208
+
209
+ // All apps now use versioned folder structure: utils/services/{appName}/{version}/
210
+ const appDir = join(servicesDir, appName, version);
211
+ const filename = "openapi.d.ts";
212
+ const serviceFilename = "service.ts";
213
+
214
+ mkdirSync(appDir, { recursive: true });
215
+ const outFile = join(appDir, filename);
216
+ const relativeAppDir = appDir.replace(utilsDir + "/", "");
217
+
218
+ console.log(
219
+ ` - Generating ${relativeAppDir}/ (${
220
+ Object.keys(appData.paths).length
221
+ } paths, ${allComponentIds.size} components)`
222
+ );
223
+
224
+ // Generate TypeScript types from the filtered spec
225
+ const ast = await openapiTS(appSpec);
226
+ const contents = astToString(ast);
227
+
228
+ writeFileSync(outFile, contents);
229
+
230
+ // Generate corresponding service file for this app
231
+ const serviceFile = join(appDir, serviceFilename);
232
+ const typeModule = "openapi";
233
+
234
+ // Special handling for kos-ui-sdk to avoid circular dependency
235
+ const isKosUiSdk = projectName === "@kosdev-code/kos-ui-sdk";
236
+ const importStatement = isKosUiSdk
237
+ ? `import type { KosExecutionContext } from "../../../../../core/core/decorators/kos-execution-context";
238
+ import type {
239
+ HttpMethod,
240
+ IKosServiceRequestParams,
241
+ } from "../../../../../core/core/decorators/kos-service-request";
242
+ import { kosServiceRequest as baseKosServiceRequest } from "../../../../../core/core/decorators/kos-service-request";
243
+ import type { ClientResponse } from "../../../../../core/util/kos-service-request";
244
+ import { createClient } from "../../../../../core/util/kos-service-request";
245
+
246
+ import type { PathsByMethod } from "../../../../../core/util/kos-service-request";`
247
+ : `import {
248
+ kosServiceRequest as baseKosServiceRequest,
249
+ createClient,
250
+ type ClientResponse,
251
+ type HttpMethod,
252
+ type IKosServiceRequestParams,
253
+ type KosExecutionContext,
254
+ type PathsByMethod,
255
+ } from "@kosdev-code/kos-ui-sdk"`;
256
+
257
+ const serviceContent = `${importStatement}
258
+ import type { paths } from "./${typeModule}";
259
+
260
+ /**
261
+ * Type aliases for ${appName} API
262
+ */
263
+ export type Api = paths;
264
+ export type ApiPath = keyof paths;
265
+ export type ValidPaths = PathsByMethod<paths>;
266
+
267
+ /**
268
+ * Get client response type for ${appName} API
269
+ */
270
+ export type ApiResponse<
271
+ Path extends ApiPath,
272
+ Method extends "get" | "post" | "put" | "delete" = "get"
273
+ > = ClientResponse<paths, Path, Method>;
274
+
275
+ /**
276
+ * Get execution context type for ${appName} API
277
+ */
278
+ export type ExecutionContext<
279
+ Path extends ApiPath = ApiPath,
280
+ Method extends HttpMethod = "get"
281
+ > = KosExecutionContext<paths, Path, Method>;
282
+
283
+ /**
284
+ * Typed decorator factory for @kosServiceRequest with ${appName} API types
285
+ *
286
+ * Provides full IntelliSense and type safety for path, query params, and body
287
+ * based on the ${appName} OpenAPI schema.
288
+ *
289
+ * @example
290
+ * \`\`\`typescript
291
+ * import { kosServiceRequest } from '../../utils/services/${appDir.replace(
292
+ servicesDir + "/",
293
+ ""
294
+ )}/service';
295
+ * import { DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
296
+ *
297
+ * @kosServiceRequest({
298
+ * path: '/api/...',
299
+ * method: 'get',
300
+ * lifecycle: DependencyLifecycle.LOAD
301
+ * })
302
+ * private onDataLoaded(): void {
303
+ * // Fully typed based on ${appName} API
304
+ * }
305
+ * \`\`\`
306
+ */
307
+ export function kosServiceRequest<
308
+ Path extends ApiPath,
309
+ Method extends HttpMethod = "get",
310
+ Response = any,
311
+ TransformedResponse = Response
312
+ >(
313
+ params: IKosServiceRequestParams<
314
+ paths,
315
+ Path,
316
+ Method,
317
+ Response,
318
+ TransformedResponse
319
+ >
320
+ ) {
321
+ return baseKosServiceRequest<
322
+ paths,
323
+ Path,
324
+ Method,
325
+ Response,
326
+ TransformedResponse
327
+ >(params);
328
+ }
329
+
330
+ /**
331
+ * Create an API client for ${appName}
332
+ */
333
+ export const api = createClient<paths>();
334
+
335
+ export default api;
336
+ `;
337
+
338
+ writeFileSync(serviceFile, serviceContent);
339
+
340
+ // All apps now have version info
341
+ generatedFiles.push({
342
+ appName,
343
+ appDir: appDir.replace(servicesDir + "/", ""),
344
+ version: version,
345
+ pathCount: Object.keys(appData.paths).length,
346
+ });
347
+ }
348
+
349
+ // Read existing index files to preserve manual entries
350
+ function mergeIndexFile(filePath, newEntries, comment) {
351
+ let existingContent = "";
352
+ try {
353
+ existingContent = readFileSync(filePath, "utf-8");
354
+ } catch (error) {
355
+ // File doesn't exist yet, that's ok
356
+ }
357
+
358
+ // Parse existing exports
359
+ const existingExports = new Map();
360
+ const manualExports = [];
361
+ const lines = existingContent.split("\n");
362
+
363
+ for (const line of lines) {
364
+ const match = line.match(/^export \* as (\w+) from ['"](.+)['"]/);
365
+ if (match) {
366
+ const [, exportName, path] = match;
367
+ existingExports.set(exportName, { exportName, path, line });
368
+ } else if (line.trim() && !line.startsWith("//")) {
369
+ // Preserve non-export lines (imports, other statements)
370
+ manualExports.push(line);
371
+ }
372
+ }
373
+
374
+ // Merge with new entries
375
+ for (const { exportName, path } of newEntries) {
376
+ existingExports.set(exportName, {
377
+ exportName,
378
+ path,
379
+ line: `export * as ${exportName} from '${path}';`,
380
+ });
381
+ }
382
+
383
+ // Build final content
384
+ const header = comment ? `// ${comment}\n` : "";
385
+ const exportLines = Array.from(existingExports.values())
386
+ .sort((a, b) => a.exportName.localeCompare(b.exportName))
387
+ .map((e) => e.line);
388
+
389
+ return (
390
+ header +
391
+ exportLines.join("\n") +
392
+ "\n" +
393
+ (manualExports.length > 0 ? "\n" + manualExports.join("\n") + "\n" : "")
394
+ );
395
+ }
396
+
397
+ // Generate index file that exports all apps (merge with existing)
398
+ const typeIndexEntries = generatedFiles.map(({ appName, appDir }) => ({
399
+ exportName: appName.replace(/[.-]/g, "_"),
400
+ path: `./services/${appDir}/openapi`,
401
+ }));
402
+
403
+ const indexFile = join(utilsDir, "openapi-index.ts");
404
+ const indexContent = mergeIndexFile(
405
+ indexFile,
406
+ typeIndexEntries,
407
+ "Auto-generated type exports - new entries added automatically"
408
+ );
409
+ writeFileSync(indexFile, indexContent);
410
+
411
+ // Generate service index that re-exports all app services (merge with existing)
412
+ const serviceIndexEntries = generatedFiles.map(({ appName, appDir, version }) => {
413
+ const baseAlias = serviceExportAliases[appName] || appName.replace(/[.-]/g, "_").toUpperCase();
414
+
415
+ // Check if this version is the default version
416
+ const isDefaultVersion = defaultVersion && version === defaultVersion;
417
+
418
+ // For default version, use base alias. For others, append version suffix
419
+ const exportName = isDefaultVersion
420
+ ? baseAlias
421
+ : `${baseAlias}_${version.replace(/[.-]/g, "_").toUpperCase()}`;
422
+
423
+ return {
424
+ exportName,
425
+ path: `./services/${appDir}/service`,
426
+ };
427
+ });
428
+
429
+ const serviceIndexFile = join(utilsDir, "services-index.ts");
430
+ const serviceIndexContent = mergeIndexFile(
431
+ serviceIndexFile,
432
+ serviceIndexEntries,
433
+ "Auto-generated service exports - new entries added automatically"
434
+ );
435
+ writeFileSync(serviceIndexFile, serviceIndexContent);
436
+
437
+ console.log(
438
+ `\nGenerated ${generatedFiles.length} app-specific type and service file pairs:`
439
+ );
440
+ generatedFiles.forEach(({ appName, appDir, version, pathCount }) => {
441
+ const versionStr = version ? ` [${version}]` : "";
442
+ console.log(` ✓ ${appDir}/${versionStr} (${appName}: ${pathCount} paths)`);
443
+ });
444
+ console.log(` ✓ openapi-index.ts (aggregated type exports)`);
445
+ console.log(` ✓ services-index.ts (aggregated service exports)`);
446
+
447
+ return {
448
+ success: true,
449
+ filesGenerated: generatedFiles.length * 2 + 2, // type + service files + 2 index files
450
+ apps: generatedFiles.map(f => f.appName),
451
+ };
452
+ }
@@ -12,6 +12,7 @@ import registerKosModel from "./generators/model/model.mjs";
12
12
  import registerComponent from "./generators/component/index.mjs";
13
13
  import registerPluginComponent from "./generators/plugin/index.mjs";
14
14
 
15
+ import registerApiGenerate from "./generators/api/generate.mjs";
15
16
  import registerCacheGenerators from "./generators/cache/index.mjs";
16
17
  import registerDev from "./generators/dev/index.mjs";
17
18
  import registerEnv from "./generators/env/index.mjs";
@@ -60,6 +61,7 @@ export default async function (plop) {
60
61
  await registerSplashProject(plop);
61
62
  await registerI18n(plop);
62
63
  await registerI18nNamespace(plop);
64
+ await registerApiGenerate(plop);
63
65
  await registerDev(plop);
64
66
  await registerEnv(plop);
65
67
  await registerKab(plop);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * CLI help display utilities
3
+ *
4
+ * High-level help display functions for the KOS CLI. Coordinates the display
5
+ * of generator-specific help and general CLI help using utilities from
6
+ * cli-help-utils.mjs.
7
+ *
8
+ * @module cli-help-display
9
+ */
10
+ import { displayExamples, displayNamedArguments } from "./cli-help-utils.mjs";
11
+
12
+ /**
13
+ * Display comprehensive help information for a specific generator.
14
+ *
15
+ * Shows the generator's name, description, named arguments mapping, and
16
+ * usage examples. This provides users with all the information needed to
17
+ * use a specific CLI command effectively.
18
+ *
19
+ * @param {string} command - The command name (e.g., 'model', 'api:generate')
20
+ * @param {Object} meta - Generator metadata from the generator's export
21
+ * @param {string} meta.name - Display name for the generator
22
+ * @param {string} meta.description - Brief description of what the generator does
23
+ * @param {Object} meta.namedArguments - Mapping of CLI args to prompt names
24
+ * @param {Object} [generator=null] - Optional plop generator object (fallback for metadata)
25
+ *
26
+ * @example
27
+ * displayGeneratorHelp('api:generate', {
28
+ * name: 'Generate OpenAPI service types',
29
+ * description: 'Generate typed service helpers from OpenAPI specifications',
30
+ * namedArguments: { project: 'project', host: 'host' }
31
+ * })
32
+ */
33
+ export function displayGeneratorHelp(command, meta, generator = null) {
34
+ console.log(`--- ${meta?.name || command} Help ---`);
35
+ console.log(
36
+ meta?.description || generator?.description || "No description available"
37
+ );
38
+
39
+ displayNamedArguments(meta?.namedArguments);
40
+ displayExamples(command, meta?.namedArguments);
41
+ }
42
+
43
+ /**
44
+ * Display general CLI help showing all available generators and global options.
45
+ *
46
+ * Outputs a comprehensive overview of the KOS CLI including:
47
+ * - List of all available generators with descriptions
48
+ * - General usage pattern
49
+ * - Global options that apply to all commands
50
+ * - Help invocation instructions
51
+ *
52
+ * This is displayed when users run `kosui --help` or `kosui` with no command.
53
+ *
54
+ * @param {Object} plop - The plop instance containing registered generators
55
+ *
56
+ * @example
57
+ * displayGeneralHelp(plopInstance)
58
+ * // Outputs:
59
+ * // --- KOS CLI Help ---
60
+ * //
61
+ * // Available Generators:
62
+ * // - model: Generate KOS models
63
+ * // - api:generate: Generate OpenAPI service types
64
+ * // ...
65
+ */
66
+ export function displayGeneralHelp(plop) {
67
+ console.warn("--- KOS CLI Help ---");
68
+ console.log("\nAvailable Generators:");
69
+ plop.getGeneratorList().forEach((g) => {
70
+ console.log(`- ${g.name}: ${g.description}`);
71
+ });
72
+
73
+ console.log("\nUsage:");
74
+ console.log(" kosui <generator> [options]");
75
+ console.log(" kosui <generator> --help # Show generator-specific help");
76
+ console.log("\nGlobal Options:");
77
+ console.log(" --no-cache Disable cache");
78
+ console.log(" --refresh Clear cache and refresh");
79
+ console.log(" --quiet Suppress banner and debug output");
80
+ console.log(
81
+ " --interactive, -i Force interactive mode (ignore provided arguments)"
82
+ );
83
+ console.log(" --help Show this help");
84
+ }