@kosdev-code/kos-ui-cli 0.1.0-dev.5155 → 0.1.0-dev.5162

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