@kuckit/cli 1.0.0 → 1.0.4

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,1019 @@
1
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { accessSync, constants, existsSync } from "node:fs";
5
+
6
+ //#region src/commands/generate-module.ts
7
+ function toKebabCase(str) {
8
+ return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
9
+ }
10
+ function toPascalCase(str) {
11
+ return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
12
+ }
13
+ function toCamelCase(str) {
14
+ const pascal = toPascalCase(str);
15
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
16
+ }
17
+ async function generateModule(name, options) {
18
+ const kebabName = toKebabCase(name);
19
+ const pascalName = toPascalCase(name);
20
+ const camelName = toCamelCase(name);
21
+ const moduleDirName = `${kebabName}-module`;
22
+ const packageName = options.org ? `@${options.org}/${moduleDirName}` : `@kuckit/${moduleDirName}`;
23
+ const moduleId = options.org ? `${options.org}.${kebabName}` : `kuckit.${kebabName}`;
24
+ const targetDir = join(process.cwd(), options.dir, moduleDirName);
25
+ console.log(`Creating module: ${packageName}`);
26
+ console.log(` Directory: ${targetDir}`);
27
+ await mkdir(join(targetDir, "src", "domain"), { recursive: true });
28
+ await mkdir(join(targetDir, "src", "ports"), { recursive: true });
29
+ await mkdir(join(targetDir, "src", "adapters"), { recursive: true });
30
+ await mkdir(join(targetDir, "src", "usecases"), { recursive: true });
31
+ await mkdir(join(targetDir, "src", "api"), { recursive: true });
32
+ await mkdir(join(targetDir, "src", "ui"), { recursive: true });
33
+ const packageJson = {
34
+ name: packageName,
35
+ version: "0.1.0",
36
+ private: true,
37
+ description: `${pascalName} module for Kuckit`,
38
+ type: "module",
39
+ main: "src/index.ts",
40
+ types: "src/index.ts",
41
+ exports: {
42
+ ".": {
43
+ types: "./src/index.ts",
44
+ default: "./src/index.ts"
45
+ },
46
+ "./client": {
47
+ types: "./src/client-module.ts",
48
+ default: "./src/client-module.ts"
49
+ },
50
+ "./ui": {
51
+ types: "./src/ui/index.ts",
52
+ default: "./src/ui/index.ts"
53
+ }
54
+ },
55
+ peerDependencies: { typescript: "^5" },
56
+ dependencies: {
57
+ "@kuckit/sdk": "^1.0.0",
58
+ "@kuckit/sdk-react": "^1.0.0",
59
+ "@kuckit/api": "^1.0.0",
60
+ "@orpc/server": "^1.10.0",
61
+ "@orpc/zod": "^1.10.0",
62
+ "drizzle-orm": "^0.44.0",
63
+ zod: "^3.23.0",
64
+ react: "^19.0.0",
65
+ "@tanstack/react-query": "^5.0.0"
66
+ }
67
+ };
68
+ await writeFile(join(targetDir, "package.json"), JSON.stringify(packageJson, null, " ") + "\n");
69
+ await writeFile(join(targetDir, "tsconfig.json"), JSON.stringify({
70
+ extends: "../../tsconfig.base.json",
71
+ compilerOptions: {
72
+ outDir: "dist",
73
+ rootDir: "src"
74
+ },
75
+ include: ["src"]
76
+ }, null, " ") + "\n");
77
+ const entityFile = `import { z } from 'zod'
78
+
79
+ /**
80
+ * ${pascalName} entity schema
81
+ */
82
+ export const ${pascalName}Schema = z.object({
83
+ id: z.string(),
84
+ name: z.string().min(1, 'Name is required'),
85
+ description: z.string().optional(),
86
+ createdAt: z.date(),
87
+ updatedAt: z.date(),
88
+ userId: z.string(),
89
+ })
90
+
91
+ export type ${pascalName} = z.infer<typeof ${pascalName}Schema>
92
+
93
+ /**
94
+ * Create ${camelName} input schema
95
+ */
96
+ export const Create${pascalName}InputSchema = z.object({
97
+ name: z.string().min(1, 'Name is required'),
98
+ description: z.string().optional(),
99
+ })
100
+
101
+ export type Create${pascalName}Input = z.infer<typeof Create${pascalName}InputSchema>
102
+
103
+ /**
104
+ * Update ${camelName} input schema
105
+ */
106
+ export const Update${pascalName}InputSchema = z.object({
107
+ id: z.string(),
108
+ name: z.string().min(1, 'Name is required').optional(),
109
+ description: z.string().optional(),
110
+ })
111
+
112
+ export type Update${pascalName}Input = z.infer<typeof Update${pascalName}InputSchema>
113
+ `;
114
+ await writeFile(join(targetDir, "src", "domain", `${kebabName}.entity.ts`), entityFile);
115
+ const repositoryPort = `import type { ${pascalName}, Create${pascalName}Input, Update${pascalName}Input } from '../domain/${kebabName}.entity'
116
+
117
+ /**
118
+ * ${pascalName} repository port interface
119
+ * Defines the contract for ${camelName} persistence operations
120
+ */
121
+ export interface ${pascalName}Repository {
122
+ findById(id: string): Promise<${pascalName} | null>
123
+ findByUserId(userId: string): Promise<${pascalName}[]>
124
+ create(input: Create${pascalName}Input & { id: string; userId: string }): Promise<${pascalName}>
125
+ update(input: Update${pascalName}Input): Promise<${pascalName} | null>
126
+ delete(id: string): Promise<boolean>
127
+ }
128
+ `;
129
+ await writeFile(join(targetDir, "src", "ports", `${kebabName}.repository.ts`), repositoryPort);
130
+ const drizzleAdapter = `import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
131
+ import { eq } from 'drizzle-orm'
132
+ import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
133
+ import type { ${pascalName}, Create${pascalName}Input, Update${pascalName}Input } from '../domain/${kebabName}.entity'
134
+
135
+ /**
136
+ * ${pascalName}s table schema for Drizzle ORM
137
+ */
138
+ export const ${camelName}sTable = pgTable('${kebabName}s', {
139
+ id: text('id').primaryKey(),
140
+ name: text('name').notNull(),
141
+ description: text('description'),
142
+ createdAt: timestamp('created_at').notNull().defaultNow(),
143
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
144
+ userId: text('user_id').notNull(),
145
+ })
146
+
147
+ /**
148
+ * Create a Drizzle-based ${camelName} repository
149
+ */
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ export function make${pascalName}Repository(db: any): ${pascalName}Repository {
152
+ return {
153
+ async findById(id: string): Promise<${pascalName} | null> {
154
+ const results = await db.select().from(${camelName}sTable).where(eq(${camelName}sTable.id, id))
155
+ return results[0] ?? null
156
+ },
157
+
158
+ async findByUserId(userId: string): Promise<${pascalName}[]> {
159
+ return db.select().from(${camelName}sTable).where(eq(${camelName}sTable.userId, userId))
160
+ },
161
+
162
+ async create(input: Create${pascalName}Input & { id: string; userId: string }): Promise<${pascalName}> {
163
+ const now = new Date()
164
+ const ${camelName} = {
165
+ id: input.id,
166
+ name: input.name,
167
+ description: input.description ?? null,
168
+ createdAt: now,
169
+ updatedAt: now,
170
+ userId: input.userId,
171
+ }
172
+ await db.insert(${camelName}sTable).values(${camelName})
173
+ return ${camelName} as ${pascalName}
174
+ },
175
+
176
+ async update(input: Update${pascalName}Input): Promise<${pascalName} | null> {
177
+ const existing = await this.findById(input.id)
178
+ if (!existing) return null
179
+
180
+ const updated = {
181
+ ...existing,
182
+ ...(input.name !== undefined && { name: input.name }),
183
+ ...(input.description !== undefined && { description: input.description }),
184
+ updatedAt: new Date(),
185
+ }
186
+ await db.update(${camelName}sTable).set(updated).where(eq(${camelName}sTable.id, input.id))
187
+ return updated
188
+ },
189
+
190
+ async delete(id: string): Promise<boolean> {
191
+ const result = await db.delete(${camelName}sTable).where(eq(${camelName}sTable.id, id))
192
+ return result.rowCount > 0
193
+ },
194
+ }
195
+ }
196
+ `;
197
+ await writeFile(join(targetDir, "src", "adapters", `${kebabName}.drizzle.ts`), drizzleAdapter);
198
+ const listUseCase = `import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
199
+ import type { ${pascalName} } from '../domain/${kebabName}.entity'
200
+
201
+ interface List${pascalName}sDeps {
202
+ ${camelName}Repository: ${pascalName}Repository
203
+ }
204
+
205
+ /**
206
+ * List ${camelName}s use case
207
+ */
208
+ export function makeList${pascalName}s(deps: List${pascalName}sDeps) {
209
+ return async (userId: string): Promise<${pascalName}[]> => {
210
+ return deps.${camelName}Repository.findByUserId(userId)
211
+ }
212
+ }
213
+ `;
214
+ await writeFile(join(targetDir, "src", "usecases", `list-${kebabName}s.ts`), listUseCase);
215
+ const getUseCase = `import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
216
+ import type { ${pascalName} } from '../domain/${kebabName}.entity'
217
+
218
+ interface Get${pascalName}Deps {
219
+ ${camelName}Repository: ${pascalName}Repository
220
+ }
221
+
222
+ /**
223
+ * Get ${camelName} use case
224
+ */
225
+ export function makeGet${pascalName}(deps: Get${pascalName}Deps) {
226
+ return async (id: string): Promise<${pascalName} | null> => {
227
+ return deps.${camelName}Repository.findById(id)
228
+ }
229
+ }
230
+ `;
231
+ await writeFile(join(targetDir, "src", "usecases", `get-${kebabName}.ts`), getUseCase);
232
+ const createUseCase = `import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
233
+ import type { ${pascalName}, Create${pascalName}Input } from '../domain/${kebabName}.entity'
234
+
235
+ export interface Create${pascalName}UseCaseInput extends Create${pascalName}Input {
236
+ userId: string
237
+ }
238
+
239
+ interface Create${pascalName}Deps {
240
+ ${camelName}Repository: ${pascalName}Repository
241
+ }
242
+
243
+ /**
244
+ * Create ${camelName} use case
245
+ */
246
+ export function makeCreate${pascalName}(deps: Create${pascalName}Deps) {
247
+ return async (input: Create${pascalName}UseCaseInput): Promise<${pascalName}> => {
248
+ const id = crypto.randomUUID()
249
+ return deps.${camelName}Repository.create({
250
+ id,
251
+ name: input.name,
252
+ description: input.description,
253
+ userId: input.userId,
254
+ })
255
+ }
256
+ }
257
+ `;
258
+ await writeFile(join(targetDir, "src", "usecases", `create-${kebabName}.ts`), createUseCase);
259
+ const deleteUseCase = `import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
260
+
261
+ interface Delete${pascalName}Deps {
262
+ ${camelName}Repository: ${pascalName}Repository
263
+ }
264
+
265
+ /**
266
+ * Delete ${camelName} use case
267
+ */
268
+ export function makeDelete${pascalName}(deps: Delete${pascalName}Deps) {
269
+ return async (id: string): Promise<boolean> => {
270
+ return deps.${camelName}Repository.delete(id)
271
+ }
272
+ }
273
+ `;
274
+ await writeFile(join(targetDir, "src", "usecases", `delete-${kebabName}.ts`), deleteUseCase);
275
+ const routerFile = `import { z } from 'zod'
276
+ import { protectedProcedure } from '@kuckit/api'
277
+ import { Create${pascalName}InputSchema } from '../domain/${kebabName}.entity'
278
+ import type { ${pascalName}Repository } from '../ports/${kebabName}.repository'
279
+
280
+ /**
281
+ * ${pascalName}s oRPC router
282
+ * Provides CRUD operations for ${camelName}s
283
+ */
284
+ export const ${camelName}sRouter = {
285
+ list: protectedProcedure.input(z.object({})).handler(async ({ context }) => {
286
+ const userId = context.session?.user?.id
287
+ if (!userId) throw new Error('User not authenticated')
288
+
289
+ const ${camelName}s = await context.di.resolve<${pascalName}Repository>('${camelName}Repository').findByUserId(userId)
290
+ return ${camelName}s
291
+ }),
292
+
293
+ get: protectedProcedure
294
+ .input(z.object({ id: z.string() }))
295
+ .handler(async ({ input, context }) => {
296
+ const ${camelName} = await context.di.resolve<${pascalName}Repository>('${camelName}Repository').findById(input.id)
297
+ return ${camelName}
298
+ }),
299
+
300
+ create: protectedProcedure.input(Create${pascalName}InputSchema).handler(async ({ input, context }) => {
301
+ const userId = context.session?.user?.id
302
+ if (!userId) throw new Error('User not authenticated')
303
+
304
+ const id = crypto.randomUUID()
305
+ const ${camelName} = await context.di.resolve<${pascalName}Repository>('${camelName}Repository').create({
306
+ id,
307
+ name: input.name,
308
+ description: input.description,
309
+ userId,
310
+ })
311
+ return ${camelName}
312
+ }),
313
+
314
+ delete: protectedProcedure
315
+ .input(z.object({ id: z.string() }))
316
+ .handler(async ({ input, context }) => {
317
+ const success = await context.di.resolve<${pascalName}Repository>('${camelName}Repository').delete(input.id)
318
+ return { success }
319
+ }),
320
+ }
321
+ `;
322
+ await writeFile(join(targetDir, "src", "api", `${kebabName}s.router.ts`), routerFile);
323
+ const pageComponent = `import { useState } from 'react'
324
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
325
+ import { useRpc } from '@kuckit/sdk-react'
326
+
327
+ interface ${pascalName} {
328
+ id: string
329
+ name: string
330
+ description?: string
331
+ createdAt: Date
332
+ updatedAt: Date
333
+ }
334
+
335
+ interface ${pascalName}sRpc {
336
+ ${camelName}s: {
337
+ list: (input: Record<string, never>) => Promise<${pascalName}[]>
338
+ create: (input: { name: string; description?: string }) => Promise<${pascalName}>
339
+ delete: (input: { id: string }) => Promise<void>
340
+ }
341
+ }
342
+
343
+ /**
344
+ * ${pascalName}s page component
345
+ * Demonstrates CRUD operations using the ${camelName}s module with useRpc and TanStack Query
346
+ */
347
+ export function ${pascalName}sPage() {
348
+ const rpc = useRpc<${pascalName}sRpc>()
349
+ const queryClient = useQueryClient()
350
+ const [newName, setNewName] = useState('')
351
+ const [newDescription, setNewDescription] = useState('')
352
+
353
+ const {
354
+ data: ${camelName}s = [],
355
+ isLoading,
356
+ error,
357
+ } = useQuery({
358
+ queryKey: ['${camelName}s'],
359
+ queryFn: () => rpc.${camelName}s.list({}),
360
+ })
361
+
362
+ const createMutation = useMutation({
363
+ mutationFn: (data: { name: string; description?: string }) => rpc.${camelName}s.create(data),
364
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['${camelName}s'] }),
365
+ })
366
+
367
+ const deleteMutation = useMutation({
368
+ mutationFn: (id: string) => rpc.${camelName}s.delete({ id }),
369
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['${camelName}s'] }),
370
+ })
371
+
372
+ const create${pascalName} = async (e: React.FormEvent) => {
373
+ e.preventDefault()
374
+ if (!newName.trim()) return
375
+
376
+ createMutation.mutate(
377
+ { name: newName, description: newDescription || undefined },
378
+ {
379
+ onSuccess: () => {
380
+ setNewName('')
381
+ setNewDescription('')
382
+ },
383
+ }
384
+ )
385
+ }
386
+
387
+ const delete${pascalName} = (id: string) => {
388
+ deleteMutation.mutate(id)
389
+ }
390
+
391
+ if (isLoading) {
392
+ return <div style={{ padding: '2rem' }}>Loading ${camelName}s...</div>
393
+ }
394
+
395
+ return (
396
+ <div style={{ padding: '2rem', fontFamily: 'system-ui', maxWidth: '600px', margin: '0 auto' }}>
397
+ <h1>${pascalName}s</h1>
398
+
399
+ {(error || createMutation.error || deleteMutation.error) && (
400
+ <div style={{ color: 'red', marginBottom: '1rem' }}>
401
+ {error?.message || createMutation.error?.message || deleteMutation.error?.message}
402
+ </div>
403
+ )}
404
+
405
+ <form onSubmit={create${pascalName}} style={{ marginBottom: '2rem' }}>
406
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
407
+ <input
408
+ type="text"
409
+ placeholder="Name"
410
+ value={newName}
411
+ onChange={(e) => setNewName(e.target.value)}
412
+ required
413
+ style={{ padding: '0.5rem', fontSize: '1rem' }}
414
+ />
415
+ <input
416
+ type="text"
417
+ placeholder="Description (optional)"
418
+ value={newDescription}
419
+ onChange={(e) => setNewDescription(e.target.value)}
420
+ style={{ padding: '0.5rem', fontSize: '1rem' }}
421
+ />
422
+ <button
423
+ type="submit"
424
+ disabled={createMutation.isPending}
425
+ style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
426
+ >
427
+ {createMutation.isPending ? 'Adding...' : 'Add ${pascalName}'}
428
+ </button>
429
+ </div>
430
+ </form>
431
+
432
+ <ul style={{ listStyle: 'none', padding: 0 }}>
433
+ {${camelName}s.map((${camelName}) => (
434
+ <li
435
+ key={${camelName}.id}
436
+ style={{
437
+ padding: '1rem',
438
+ border: '1px solid #ccc',
439
+ marginBottom: '0.5rem',
440
+ borderRadius: '4px',
441
+ display: 'flex',
442
+ justifyContent: 'space-between',
443
+ alignItems: 'center',
444
+ }}
445
+ >
446
+ <div>
447
+ <strong>{${camelName}.name}</strong>
448
+ {${camelName}.description && (
449
+ <p style={{ margin: '0.5rem 0 0 0', color: '#666' }}>{${camelName}.description}</p>
450
+ )}
451
+ </div>
452
+ <button
453
+ onClick={() => delete${pascalName}(${camelName}.id)}
454
+ disabled={deleteMutation.isPending}
455
+ style={{ cursor: 'pointer', color: 'red', background: 'none', border: 'none' }}
456
+ >
457
+ Delete
458
+ </button>
459
+ </li>
460
+ ))}
461
+ </ul>
462
+
463
+ {${camelName}s.length === 0 && <p>No ${camelName}s yet. Create one above!</p>}
464
+ </div>
465
+ )
466
+ }
467
+ `;
468
+ await writeFile(join(targetDir, "src", "ui", `${pascalName}sPage.tsx`), pageComponent);
469
+ const uiIndex = `export { ${pascalName}sPage } from './${pascalName}sPage'
470
+ `;
471
+ await writeFile(join(targetDir, "src", "ui", "index.ts"), uiIndex);
472
+ const serverModule = `import { defineKuckitModule, asFunction, type KuckitModuleContext } from '@kuckit/sdk'
473
+ import { ${camelName}sTable, make${pascalName}Repository } from './adapters/${kebabName}.drizzle'
474
+ import { ${camelName}sRouter } from './api/${kebabName}s.router'
475
+
476
+ export type ${pascalName}ModuleConfig = Record<string, never>
477
+
478
+ /**
479
+ * ${pascalName} module - Kuckit module demonstrating Clean Architecture pattern
480
+ *
481
+ * This module shows:
482
+ * - Domain entity with Zod validation
483
+ * - Repository port/adapter pattern
484
+ * - Use cases for business logic
485
+ * - oRPC router for API endpoints
486
+ */
487
+ export const kuckitModule = defineKuckitModule<${pascalName}ModuleConfig>({
488
+ id: '${moduleId}',
489
+ displayName: '${pascalName}',
490
+ description: '${pascalName} module for CRUD operations',
491
+ version: '0.1.0',
492
+
493
+ async register(ctx: KuckitModuleContext<${pascalName}ModuleConfig>) {
494
+ const { container } = ctx
495
+
496
+ // Register schema for migrations
497
+ ctx.registerSchema('${camelName}s', ${camelName}sTable)
498
+
499
+ // Register repository
500
+ container.register({
501
+ ${camelName}Repository: asFunction(({ db }) => make${pascalName}Repository(db)).scoped(),
502
+ })
503
+ },
504
+
505
+ registerApi(ctx) {
506
+ ctx.addApiRegistration({
507
+ type: 'rpc-router',
508
+ name: '${camelName}s',
509
+ router: ${camelName}sRouter,
510
+ })
511
+ },
512
+
513
+ async onBootstrap(ctx: KuckitModuleContext<${pascalName}ModuleConfig>) {
514
+ const { container } = ctx
515
+ const logger = container.resolve('logger')
516
+ logger.info('${pascalName} module initialized')
517
+ },
518
+
519
+ async onShutdown(ctx: KuckitModuleContext<${pascalName}ModuleConfig>) {
520
+ const { container } = ctx
521
+ const logger = container.resolve('logger')
522
+ logger.info('${pascalName} module shutting down')
523
+ },
524
+ })
525
+ `;
526
+ await writeFile(join(targetDir, "src", "module.ts"), serverModule);
527
+ const clientModule = `import { defineKuckitClientModule, type KuckitClientModuleContext } from '@kuckit/sdk-react'
528
+ import { ${pascalName}sPage } from './ui/${pascalName}sPage'
529
+
530
+ /**
531
+ * ${pascalName} client module
532
+ * Registers routes and components for the web app
533
+ */
534
+ export const kuckitClientModule = defineKuckitClientModule({
535
+ id: '${moduleId}',
536
+ displayName: '${pascalName}',
537
+ version: '0.1.0',
538
+
539
+ register(ctx: KuckitClientModuleContext) {
540
+ // Register the ${camelName}s page component
541
+ ctx.registerComponent('${pascalName}sPage', ${pascalName}sPage)
542
+
543
+ // Add route for ${camelName}s page
544
+ ctx.addRoute({
545
+ id: '${kebabName}s',
546
+ path: '/${kebabName}s',
547
+ component: ${pascalName}sPage,
548
+ meta: {
549
+ title: '${pascalName}s',
550
+ requiresAuth: true,
551
+ },
552
+ })
553
+
554
+ // Add navigation item
555
+ ctx.addNavItem({
556
+ id: '${kebabName}s-nav',
557
+ label: '${pascalName}s',
558
+ path: '/${kebabName}s',
559
+ icon: 'list',
560
+ order: 100,
561
+ })
562
+ },
563
+ })
564
+
565
+ export { ${pascalName}sPage } from './ui/${pascalName}sPage'
566
+ `;
567
+ await writeFile(join(targetDir, "src", "client-module.ts"), clientModule);
568
+ const indexFile = `// Server module
569
+ export { kuckitModule } from './module'
570
+
571
+ // Domain
572
+ export * from './domain/${kebabName}.entity'
573
+
574
+ // Ports
575
+ export type { ${pascalName}Repository } from './ports/${kebabName}.repository'
576
+
577
+ // Adapters
578
+ export { ${camelName}sTable, make${pascalName}Repository } from './adapters/${kebabName}.drizzle'
579
+
580
+ // Use cases
581
+ export { makeList${pascalName}s } from './usecases/list-${kebabName}s'
582
+ export { makeGet${pascalName} } from './usecases/get-${kebabName}'
583
+ export { makeCreate${pascalName} } from './usecases/create-${kebabName}'
584
+ export { makeDelete${pascalName} } from './usecases/delete-${kebabName}'
585
+
586
+ // API
587
+ export { ${camelName}sRouter } from './api/${kebabName}s.router'
588
+ `;
589
+ await writeFile(join(targetDir, "src", "index.ts"), indexFile);
590
+ console.log(`
591
+ ✅ Module created successfully!
592
+
593
+ 📁 Structure:
594
+ ${options.dir}/${moduleDirName}/
595
+ ├── src/
596
+ │ ├── domain/
597
+ │ │ └── ${kebabName}.entity.ts # Zod schema for entity
598
+ │ ├── ports/
599
+ │ │ └── ${kebabName}.repository.ts # Repository interface
600
+ │ ├── adapters/
601
+ │ │ └── ${kebabName}.drizzle.ts # Drizzle implementation
602
+ │ ├── usecases/
603
+ │ │ ├── list-${kebabName}s.ts
604
+ │ │ ├── get-${kebabName}.ts
605
+ │ │ ├── create-${kebabName}.ts
606
+ │ │ └── delete-${kebabName}.ts
607
+ │ ├── api/
608
+ │ │ └── ${kebabName}s.router.ts # oRPC router
609
+ │ ├── ui/
610
+ │ │ └── ${pascalName}sPage.tsx # React component
611
+ │ ├── module.ts # Server module
612
+ │ ├── client-module.ts # Client module
613
+ │ └── index.ts
614
+ ├── package.json
615
+ └── tsconfig.json
616
+
617
+ 📝 Next steps:
618
+ 1. Run 'bun install' from the root to link the new package
619
+ 2. Add module to apps/server/src/config/modules.ts:
620
+ import { kuckitModule as ${camelName}Module } from '${packageName}'
621
+ export const modules = [..., ${camelName}Module]
622
+ 3. Add client module to apps/web/src/modules.client.ts:
623
+ import { kuckitClientModule as ${camelName}Client } from '${packageName}/client'
624
+ export const clientModules = [..., { module: ${camelName}Client }]
625
+ 4. Add schema path to packages/db/drizzle.config.ts:
626
+ schema: [..., '../${moduleDirName}/src/adapters/${kebabName}.drizzle.ts']
627
+ 5. Run 'bun run db:push' to create the table
628
+ `);
629
+ }
630
+
631
+ //#endregion
632
+ //#region src/commands/add-module.ts
633
+ const CONFIG_FILES = [
634
+ "kuckit.config.ts",
635
+ "kuckit.config.js",
636
+ "kuckit.config.mjs"
637
+ ];
638
+ async function fileExists(path) {
639
+ try {
640
+ await access(path, constants.F_OK);
641
+ return true;
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+ function findConfigFile(cwd) {
647
+ for (const file of CONFIG_FILES) {
648
+ const configPath = join(cwd, file);
649
+ if (existsSync(configPath)) return configPath;
650
+ }
651
+ return null;
652
+ }
653
+ function detectPackageManager(cwd) {
654
+ for (const [file, pm] of Object.entries({
655
+ "bun.lock": "bun",
656
+ "bun.lockb": "bun",
657
+ "package-lock.json": "npm",
658
+ "yarn.lock": "yarn",
659
+ "pnpm-lock.yaml": "pnpm"
660
+ })) try {
661
+ accessSync(join(cwd, file), constants.F_OK);
662
+ return pm;
663
+ } catch {}
664
+ return "npm";
665
+ }
666
+ async function readPackageKuckitMetadata(packageName, cwd) {
667
+ const packageJsonPath = join(cwd, "node_modules", packageName, "package.json");
668
+ try {
669
+ const content = await readFile(packageJsonPath, "utf-8");
670
+ return JSON.parse(content).kuckit ?? null;
671
+ } catch {
672
+ return null;
673
+ }
674
+ }
675
+ const MODULES_MARKER_START = "// KUCKIT_MODULES_START";
676
+ const MODULES_MARKER_END = "// KUCKIT_MODULES_END";
677
+ const CLIENT_MODULES_MARKER_START = "// KUCKIT_CLIENT_MODULES_START";
678
+ const CLIENT_MODULES_MARKER_END = "// KUCKIT_CLIENT_MODULES_END";
679
+ /**
680
+ * Add module to unified kuckit.config.ts
681
+ */
682
+ async function addToUnifiedConfig(packageName, configPath) {
683
+ let content = await readFile(configPath, "utf-8");
684
+ if (content.includes(`'${packageName}'`) || content.includes(`"${packageName}"`)) {
685
+ console.log(` Module ${packageName} is already in ${configPath}`);
686
+ return true;
687
+ }
688
+ const modulesArrayMatch = content.match(/modules:\s*\[/);
689
+ if (!modulesArrayMatch) {
690
+ console.warn(`Warning: Could not find 'modules' array in ${configPath}`);
691
+ console.log(` Please manually add: { package: '${packageName}' }`);
692
+ return false;
693
+ }
694
+ const insertPos = modulesArrayMatch.index + modulesArrayMatch[0].length;
695
+ const newEntry = `\n\t\t{ package: '${packageName}' },`;
696
+ content = content.slice(0, insertPos) + newEntry + content.slice(insertPos);
697
+ await writeFile(configPath, content);
698
+ console.log(` Added to unified config: ${configPath}`);
699
+ return true;
700
+ }
701
+ async function addToServerModules(packageName, cwd) {
702
+ const modulesPath = join(cwd, "apps", "server", "src", "config", "modules.ts");
703
+ if (!await fileExists(modulesPath)) {
704
+ console.warn(`Warning: Server modules file not found at ${modulesPath}`);
705
+ return false;
706
+ }
707
+ let content = await readFile(modulesPath, "utf-8");
708
+ if (content.includes(`package: '${packageName}'`)) {
709
+ console.log(` Module ${packageName} is already in server modules`);
710
+ return true;
711
+ }
712
+ if (!content.includes(MODULES_MARKER_START)) {
713
+ if (content.match(/return modules\s*$/)) {
714
+ const insertPos = content.lastIndexOf("return modules");
715
+ const before$1 = content.slice(0, insertPos);
716
+ const after$1 = content.slice(insertPos);
717
+ content = before$1 + `\n\t\t${MODULES_MARKER_START}\n\t\t// Modules installed via 'kuckit add' will be added here\n\t\t${MODULES_MARKER_END}\n\n\t\t` + after$1;
718
+ }
719
+ }
720
+ const newEntry = `{ package: '${packageName}' },`;
721
+ const markerEndPos = content.indexOf(MODULES_MARKER_END);
722
+ if (markerEndPos === -1) {
723
+ console.warn(`Warning: Could not find marker in ${modulesPath}`);
724
+ console.log(` Please manually add: ${newEntry}`);
725
+ return false;
726
+ }
727
+ const before = content.slice(0, markerEndPos);
728
+ const after = content.slice(markerEndPos);
729
+ content = before + `${newEntry}\n\t\t` + after;
730
+ await writeFile(modulesPath, content);
731
+ console.log(` Added to server modules: ${modulesPath}`);
732
+ return true;
733
+ }
734
+ async function addToClientModules(packageName, clientPath, cwd) {
735
+ const clientModulesPath = join(cwd, "apps", "web", "src", "modules.client.ts");
736
+ if (!await fileExists(clientModulesPath)) {
737
+ await writeFile(clientModulesPath, `import type { ClientModuleSpec } from '@kuckit/sdk-react'
738
+
739
+ /**
740
+ * Client module specifications
741
+ *
742
+ * Add your Kuckit client modules here. Modules can be:
743
+ * - Direct references: { module: myModule }
744
+ * - Package imports: { package: '@acme/billing-module/client' }
745
+ *
746
+ * The CLI command \`kuckit add <package>\` will automatically update this file.
747
+ */
748
+ export const getClientModuleSpecs = (): ClientModuleSpec[] => [
749
+ ${CLIENT_MODULES_MARKER_START}
750
+ ${CLIENT_MODULES_MARKER_END}
751
+ ]
752
+ `);
753
+ console.log(` Created client modules file: ${clientModulesPath}`);
754
+ }
755
+ let content = await readFile(clientModulesPath, "utf-8");
756
+ const clientPackagePath = `${packageName}/${clientPath.replace("./", "")}`;
757
+ if (content.includes(`package: '${clientPackagePath}'`)) {
758
+ console.log(` Client module is already registered`);
759
+ return true;
760
+ }
761
+ const newEntry = `{ package: '${clientPackagePath}' },`;
762
+ const markerEndPos = content.indexOf(CLIENT_MODULES_MARKER_END);
763
+ if (markerEndPos === -1) {
764
+ console.warn(`Warning: Could not find marker in ${clientModulesPath}`);
765
+ console.log(` Please manually add: ${newEntry}`);
766
+ return false;
767
+ }
768
+ const before = content.slice(0, markerEndPos);
769
+ const after = content.slice(markerEndPos);
770
+ content = before + `${newEntry}\n\t` + after;
771
+ await writeFile(clientModulesPath, content);
772
+ console.log(` Added to client modules: ${clientModulesPath}`);
773
+ return true;
774
+ }
775
+ async function addModule(packageName, options) {
776
+ const cwd = process.cwd();
777
+ console.log(`Adding module: ${packageName}`);
778
+ if (!options.skipInstall) {
779
+ const pm = detectPackageManager(cwd);
780
+ const installCmd = pm === "npm" ? `${pm} install ${packageName}` : `${pm} add ${packageName}`;
781
+ console.log(` Installing with ${pm}...`);
782
+ try {
783
+ execSync(installCmd, {
784
+ cwd,
785
+ stdio: "inherit"
786
+ });
787
+ } catch {
788
+ console.error(`Failed to install ${packageName}`);
789
+ process.exit(1);
790
+ }
791
+ }
792
+ const metadata = await readPackageKuckitMetadata(packageName, cwd);
793
+ if (!metadata) {
794
+ console.error(`\nError: ${packageName} is not a valid Kuckit module.`);
795
+ console.error(`The package.json must contain a "kuckit" field with module metadata.`);
796
+ console.error(`\nExpected structure:`);
797
+ console.error(`{
798
+ "kuckit": {
799
+ "id": "acme.billing",
800
+ "server": ".",
801
+ "client": "./client"
802
+ }
803
+ }`);
804
+ process.exit(1);
805
+ }
806
+ console.log(` Found Kuckit module: ${metadata.id}`);
807
+ const configPath = findConfigFile(cwd);
808
+ if (configPath) await addToUnifiedConfig(packageName, configPath);
809
+ else {
810
+ if (metadata.server) await addToServerModules(packageName, cwd);
811
+ if (metadata.client) await addToClientModules(packageName, metadata.client, cwd);
812
+ }
813
+ console.log(`
814
+ Module ${packageName} added successfully!
815
+
816
+ Run 'bun run dev' to start the server with the new module.
817
+ `);
818
+ }
819
+
820
+ //#endregion
821
+ //#region src/lib/sdk-loader.ts
822
+ async function loadTryLoadKuckitConfig() {
823
+ try {
824
+ const sdk = await import("@kuckit/sdk");
825
+ if (!sdk.tryLoadKuckitConfig) throw new Error("Invalid @kuckit/sdk version: tryLoadKuckitConfig not found.");
826
+ return sdk.tryLoadKuckitConfig;
827
+ } catch {
828
+ console.error("Error: This command requires @kuckit/sdk to be installed in your project.");
829
+ console.error("Install it with one of:");
830
+ console.error(" npm install -D @kuckit/sdk");
831
+ console.error(" pnpm add -D @kuckit/sdk");
832
+ console.error(" bun add -d @kuckit/sdk");
833
+ process.exit(1);
834
+ }
835
+ }
836
+
837
+ //#endregion
838
+ //#region src/commands/discover-module.ts
839
+ async function readKuckitMetadataFromPath(packageJsonPath) {
840
+ try {
841
+ const content = await readFile(packageJsonPath, "utf-8");
842
+ return JSON.parse(content).kuckit ?? null;
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+ async function readPackageName(packageJsonPath) {
848
+ try {
849
+ const content = await readFile(packageJsonPath, "utf-8");
850
+ return JSON.parse(content).name ?? null;
851
+ } catch {
852
+ return null;
853
+ }
854
+ }
855
+ async function scanWorkspacePackages(cwd) {
856
+ const discovered = [];
857
+ const packagesDir = join(cwd, "packages");
858
+ if (!existsSync(packagesDir)) return discovered;
859
+ try {
860
+ const entries = await readdir(packagesDir, { withFileTypes: true });
861
+ for (const entry of entries) {
862
+ if (!entry.isDirectory()) continue;
863
+ const packageJsonPath = join(packagesDir, entry.name, "package.json");
864
+ const metadata = await readKuckitMetadataFromPath(packageJsonPath);
865
+ if (metadata) {
866
+ const packageName = await readPackageName(packageJsonPath);
867
+ if (packageName) discovered.push({
868
+ package: packageName,
869
+ id: metadata.id,
870
+ server: metadata.server,
871
+ client: metadata.client,
872
+ enabled: false,
873
+ local: true
874
+ });
875
+ }
876
+ }
877
+ } catch {}
878
+ return discovered;
879
+ }
880
+ async function scanNodeModules(cwd) {
881
+ const nodeModulesPath = join(cwd, "node_modules");
882
+ const discovered = [];
883
+ try {
884
+ const entries = await readdir(nodeModulesPath, { withFileTypes: true });
885
+ for (const entry of entries) {
886
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
887
+ if (entry.name.startsWith(".")) continue;
888
+ if (entry.name.startsWith("@")) {
889
+ const scopePath = join(nodeModulesPath, entry.name);
890
+ try {
891
+ const scopedEntries = await readdir(scopePath, { withFileTypes: true });
892
+ for (const scopedEntry of scopedEntries) {
893
+ if (!scopedEntry.isDirectory() && !scopedEntry.isSymbolicLink()) continue;
894
+ const packageName = `${entry.name}/${scopedEntry.name}`;
895
+ const metadata = await readKuckitMetadataFromPath(join(nodeModulesPath, packageName, "package.json"));
896
+ if (metadata) discovered.push({
897
+ package: packageName,
898
+ id: metadata.id,
899
+ server: metadata.server,
900
+ client: metadata.client,
901
+ enabled: false
902
+ });
903
+ }
904
+ } catch {}
905
+ } else {
906
+ const metadata = await readKuckitMetadataFromPath(join(nodeModulesPath, entry.name, "package.json"));
907
+ if (metadata) discovered.push({
908
+ package: entry.name,
909
+ id: metadata.id,
910
+ server: metadata.server,
911
+ client: metadata.client,
912
+ enabled: false
913
+ });
914
+ }
915
+ }
916
+ } catch {}
917
+ return discovered;
918
+ }
919
+ async function addModulesToConfig(packages, configPath) {
920
+ const content = await readFile(configPath, "utf-8");
921
+ let updated = content;
922
+ for (const pkg of packages) {
923
+ if (updated.includes(`'${pkg}'`) || updated.includes(`"${pkg}"`)) continue;
924
+ const modulesArrayMatch = updated.match(/modules:\s*\[/);
925
+ if (!modulesArrayMatch) continue;
926
+ const insertPos = modulesArrayMatch.index + modulesArrayMatch[0].length;
927
+ const newEntry = `\n\t\t{ package: '${pkg}' },`;
928
+ updated = updated.slice(0, insertPos) + newEntry + updated.slice(insertPos);
929
+ }
930
+ if (updated !== content) {
931
+ const { writeFile: writeFile$1 } = await import("node:fs/promises");
932
+ await writeFile$1(configPath, updated);
933
+ }
934
+ }
935
+ async function discoverModules(options) {
936
+ const cwd = process.cwd();
937
+ if (!options.json) console.log("Scanning for Kuckit modules...\n");
938
+ const config = await (await loadTryLoadKuckitConfig())(cwd);
939
+ const configuredPackages = new Set(config?.modules.map((m) => m.package) ?? []);
940
+ const workspaceModules = await scanWorkspacePackages(cwd);
941
+ const nodeModules = await scanNodeModules(cwd);
942
+ const seen = /* @__PURE__ */ new Set();
943
+ const discovered = [];
944
+ for (const mod of workspaceModules) {
945
+ seen.add(mod.package);
946
+ mod.enabled = configuredPackages.has(mod.package);
947
+ discovered.push(mod);
948
+ }
949
+ for (const mod of nodeModules) if (!seen.has(mod.package)) {
950
+ mod.enabled = configuredPackages.has(mod.package);
951
+ discovered.push(mod);
952
+ }
953
+ discovered.sort((a, b) => {
954
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
955
+ return a.package.localeCompare(b.package);
956
+ });
957
+ const result = {
958
+ discovered,
959
+ enabled: discovered.filter((m) => m.enabled).length,
960
+ unconfigured: discovered.filter((m) => !m.enabled).length
961
+ };
962
+ if (options.json) {
963
+ console.log(JSON.stringify(result, null, 2));
964
+ return;
965
+ }
966
+ if (discovered.length === 0) {
967
+ console.log("No Kuckit modules found.");
968
+ console.log("\nInstall modules with: kuckit add <package>");
969
+ return;
970
+ }
971
+ console.log(`Found ${discovered.length} Kuckit module${discovered.length === 1 ? "" : "s"}:`);
972
+ for (const mod of discovered) {
973
+ const status = mod.enabled ? "✓" : "○";
974
+ const label = mod.enabled ? "(enabled)" : "(not configured)";
975
+ const localLabel = mod.local ? " [local]" : "";
976
+ console.log(` ${status} ${mod.package}${localLabel} ${label}`);
977
+ }
978
+ const unconfigured = discovered.filter((m) => !m.enabled);
979
+ if (unconfigured.length === 0) {
980
+ console.log("\nAll discovered modules are already configured.");
981
+ return;
982
+ }
983
+ if (!options.interactive) {
984
+ console.log(`\n${unconfigured.length} module${unconfigured.length === 1 ? "" : "s"} not configured.`);
985
+ console.log("Run without --no-interactive to enable them.");
986
+ return;
987
+ }
988
+ if (!config?._configPath) {
989
+ console.log("\nNo kuckit.config.ts found. Create one first with:");
990
+ console.log(" npx create-kuckit-app --init");
991
+ return;
992
+ }
993
+ try {
994
+ const { checkbox } = await import("@inquirer/prompts");
995
+ const selected = await checkbox({
996
+ message: "Enable modules:",
997
+ choices: unconfigured.map((m) => ({
998
+ name: m.package,
999
+ value: m.package,
1000
+ checked: true
1001
+ }))
1002
+ });
1003
+ if (selected.length === 0) {
1004
+ console.log("\nNo modules selected.");
1005
+ return;
1006
+ }
1007
+ await addModulesToConfig(selected, config._configPath);
1008
+ console.log(`\nUpdated kuckit.config.ts with ${selected.length} new module${selected.length === 1 ? "" : "s"}.`);
1009
+ } catch (error) {
1010
+ if (error.name === "ExitPromptError") {
1011
+ console.log("\nCancelled.");
1012
+ return;
1013
+ }
1014
+ throw error;
1015
+ }
1016
+ }
1017
+
1018
+ //#endregion
1019
+ export { generateModule as i, loadTryLoadKuckitConfig as n, addModule as r, discoverModules as t };