@geekmidas/cli 1.2.2 → 1.2.3

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.
@@ -1,11 +1,18 @@
1
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
2
8
  import { tmpdir } from 'node:os';
3
9
  import { join } from 'node:path';
4
10
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
11
  import {
6
- clearSpecHashCache,
7
- generateClientForFrontend,
12
+ copyAllClients,
13
+ copyClientToFrontends,
8
14
  getBackendDependencies,
15
+ getBackendOpenApiPath,
9
16
  getDependentFrontends,
10
17
  getFirstRoute,
11
18
  normalizeRoutes,
@@ -264,7 +271,71 @@ describe('Client Generator', () => {
264
271
  });
265
272
  });
266
273
 
267
- describe('generateClientForFrontend', () => {
274
+ describe('getBackendOpenApiPath', () => {
275
+ it('should return correct path for backend app', () => {
276
+ const workspace: NormalizedWorkspace = {
277
+ name: 'test',
278
+ root: '/project',
279
+ apps: {
280
+ api: {
281
+ type: 'backend',
282
+ path: 'apps/api',
283
+ port: 3000,
284
+ dependencies: [],
285
+ routes: './src/**/*.ts',
286
+ resolvedDeployTarget: 'dokploy',
287
+ },
288
+ },
289
+ services: {},
290
+ deploy: { default: 'dokploy' },
291
+ shared: { packages: [] },
292
+ secrets: {},
293
+ };
294
+
295
+ const path = getBackendOpenApiPath(workspace, 'api');
296
+ expect(path).toBe('/project/apps/api/.gkm/openapi.ts');
297
+ });
298
+
299
+ it('should return null for non-backend app', () => {
300
+ const workspace: NormalizedWorkspace = {
301
+ name: 'test',
302
+ root: '/project',
303
+ apps: {
304
+ web: {
305
+ type: 'frontend',
306
+ path: 'apps/web',
307
+ port: 3000,
308
+ dependencies: [],
309
+ resolvedDeployTarget: 'dokploy',
310
+ },
311
+ },
312
+ services: {},
313
+ deploy: { default: 'dokploy' },
314
+ shared: { packages: [] },
315
+ secrets: {},
316
+ };
317
+
318
+ const path = getBackendOpenApiPath(workspace, 'web');
319
+ expect(path).toBeNull();
320
+ });
321
+
322
+ it('should return null for non-existent app', () => {
323
+ const workspace: NormalizedWorkspace = {
324
+ name: 'test',
325
+ root: '/project',
326
+ apps: {},
327
+ services: {},
328
+ deploy: { default: 'dokploy' },
329
+ shared: { packages: [] },
330
+ secrets: {},
331
+ };
332
+
333
+ const path = getBackendOpenApiPath(workspace, 'nonexistent');
334
+ expect(path).toBeNull();
335
+ });
336
+ });
337
+
338
+ describe('copyClientToFrontends', () => {
268
339
  let testDir: string;
269
340
 
270
341
  beforeEach(() => {
@@ -273,7 +344,6 @@ describe('Client Generator', () => {
273
344
  `gkm-client-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
274
345
  );
275
346
  mkdirSync(testDir, { recursive: true });
276
- clearSpecHashCache();
277
347
  });
278
348
 
279
349
  afterEach(() => {
@@ -282,16 +352,57 @@ describe('Client Generator', () => {
282
352
  }
283
353
  });
284
354
 
285
- it('should return empty array for frontend with no backend dependencies', async () => {
355
+ function createOpenApiFile(
356
+ appDir: string,
357
+ endpoints: Array<{ method: string; path: string }>,
358
+ ): void {
359
+ const endpointAuthEntries = endpoints
360
+ .map((ep) => ` '${ep.method.toUpperCase()} ${ep.path}': null,`)
361
+ .join('\n');
362
+
363
+ const content = `// Auto-generated by @geekmidas/cli - DO NOT EDIT
364
+
365
+ export const endpointAuth = {
366
+ ${endpointAuthEntries}
367
+ } as const;
368
+
369
+ export const paths = {};
370
+ export function createApi() { return {}; }
371
+ `;
372
+
373
+ const gkmDir = join(appDir, '.gkm');
374
+ mkdirSync(gkmDir, { recursive: true });
375
+ writeFileSync(join(gkmDir, 'openapi.ts'), content);
376
+ }
377
+
378
+ it('should copy client to dependent frontends', async () => {
379
+ const apiDir = join(testDir, 'apps/api');
380
+ const webDir = join(testDir, 'apps/web');
381
+ mkdirSync(webDir, { recursive: true });
382
+
383
+ createOpenApiFile(apiDir, [
384
+ { method: 'GET', path: '/users' },
385
+ { method: 'POST', path: '/users' },
386
+ ]);
387
+
286
388
  const workspace: NormalizedWorkspace = {
287
389
  name: 'test',
288
390
  root: testDir,
289
391
  apps: {
392
+ api: {
393
+ type: 'backend',
394
+ path: 'apps/api',
395
+ port: 3000,
396
+ dependencies: [],
397
+ routes: './src/endpoints/**/*.ts',
398
+ resolvedDeployTarget: 'dokploy',
399
+ },
290
400
  web: {
291
401
  type: 'frontend',
292
402
  path: 'apps/web',
293
403
  port: 3001,
294
- dependencies: [],
404
+ dependencies: ['api'],
405
+ client: { output: 'src/api' },
295
406
  resolvedDeployTarget: 'dokploy',
296
407
  },
297
408
  },
@@ -301,11 +412,25 @@ describe('Client Generator', () => {
301
412
  secrets: {},
302
413
  };
303
414
 
304
- const results = await generateClientForFrontend(workspace, 'web');
305
- expect(results).toEqual([]);
415
+ const results = await copyClientToFrontends(workspace, 'api', {
416
+ silent: true,
417
+ });
418
+
419
+ expect(results).toHaveLength(1);
420
+ expect(results[0]?.success).toBe(true);
421
+ expect(results[0]?.frontendApp).toBe('web');
422
+ expect(results[0]?.backendApp).toBe('api');
423
+ expect(results[0]?.endpointCount).toBe(2);
424
+ expect(existsSync(join(webDir, 'src/api/api.ts'))).toBe(true);
306
425
  });
307
426
 
308
- it('should return empty array for non-frontend app', async () => {
427
+ it('should skip frontends without client.output configured', async () => {
428
+ const apiDir = join(testDir, 'apps/api');
429
+ const webDir = join(testDir, 'apps/web');
430
+ mkdirSync(webDir, { recursive: true });
431
+
432
+ createOpenApiFile(apiDir, [{ method: 'GET', path: '/users' }]);
433
+
309
434
  const workspace: NormalizedWorkspace = {
310
435
  name: 'test',
311
436
  root: testDir,
@@ -315,7 +440,15 @@ describe('Client Generator', () => {
315
440
  path: 'apps/api',
316
441
  port: 3000,
317
442
  dependencies: [],
318
- routes: './src/**/*.ts',
443
+ routes: './src/endpoints/**/*.ts',
444
+ resolvedDeployTarget: 'dokploy',
445
+ },
446
+ web: {
447
+ type: 'frontend',
448
+ path: 'apps/web',
449
+ port: 3001,
450
+ dependencies: ['api'],
451
+ // No client.output configured
319
452
  resolvedDeployTarget: 'dokploy',
320
453
  },
321
454
  },
@@ -325,16 +458,45 @@ describe('Client Generator', () => {
325
458
  secrets: {},
326
459
  };
327
460
 
328
- const results = await generateClientForFrontend(workspace, 'api');
461
+ const results = await copyClientToFrontends(workspace, 'api', {
462
+ silent: true,
463
+ });
464
+
465
+ expect(results).toHaveLength(0);
466
+ });
467
+
468
+ it('should return empty results for non-backend app', async () => {
469
+ const workspace: NormalizedWorkspace = {
470
+ name: 'test',
471
+ root: testDir,
472
+ apps: {
473
+ web: {
474
+ type: 'frontend',
475
+ path: 'apps/web',
476
+ port: 3001,
477
+ dependencies: [],
478
+ resolvedDeployTarget: 'dokploy',
479
+ },
480
+ },
481
+ services: {},
482
+ deploy: { default: 'dokploy' },
483
+ shared: { packages: [] },
484
+ secrets: {},
485
+ };
486
+
487
+ const results = await copyClientToFrontends(workspace, 'web', {
488
+ silent: true,
489
+ });
490
+
329
491
  expect(results).toEqual([]);
330
492
  });
331
493
 
332
- it('should use configured client output path', async () => {
333
- // Create minimal workspace structure
494
+ it('should return empty results when openapi file does not exist', async () => {
334
495
  const apiDir = join(testDir, 'apps/api');
335
496
  const webDir = join(testDir, 'apps/web');
336
- mkdirSync(join(apiDir, 'src/endpoints'), { recursive: true });
497
+ mkdirSync(apiDir, { recursive: true });
337
498
  mkdirSync(webDir, { recursive: true });
499
+ // Don't create openapi file
338
500
 
339
501
  const workspace: NormalizedWorkspace = {
340
502
  name: 'test',
@@ -353,7 +515,7 @@ describe('Client Generator', () => {
353
515
  path: 'apps/web',
354
516
  port: 3001,
355
517
  dependencies: ['api'],
356
- client: { output: 'lib/api' },
518
+ client: { output: 'src/api' },
357
519
  resolvedDeployTarget: 'dokploy',
358
520
  },
359
521
  },
@@ -363,58 +525,25 @@ describe('Client Generator', () => {
363
525
  secrets: {},
364
526
  };
365
527
 
366
- // This will fail to generate because there are no endpoints,
367
- // but we can verify the output path would be correct
368
- const results = await generateClientForFrontend(workspace, 'web');
369
- expect(results).toHaveLength(1);
370
- expect(results[0]?.reason).toBe('No endpoints found in backend');
371
- });
372
- });
373
-
374
- describe('hot reload - hash-based change detection', () => {
375
- let testDir: string;
528
+ const results = await copyClientToFrontends(workspace, 'api', {
529
+ silent: true,
530
+ });
376
531
 
377
- beforeEach(() => {
378
- testDir = join(
379
- tmpdir(),
380
- `gkm-hotreload-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
381
- );
382
- mkdirSync(testDir, { recursive: true });
383
- clearSpecHashCache();
532
+ expect(results).toEqual([]);
384
533
  });
385
534
 
386
- afterEach(() => {
387
- if (existsSync(testDir)) {
388
- rmSync(testDir, { recursive: true, force: true });
389
- }
390
- });
535
+ it('should copy to multiple frontends', async () => {
536
+ const apiDir = join(testDir, 'apps/api');
537
+ const webDir = join(testDir, 'apps/web');
538
+ const adminDir = join(testDir, 'apps/admin');
539
+ mkdirSync(webDir, { recursive: true });
540
+ mkdirSync(adminDir, { recursive: true });
391
541
 
392
- function createEndpointFile(
393
- dir: string,
394
- filename: string,
395
- exportName: string,
396
- path: string,
397
- method: string,
398
- ): void {
399
- const content = `
400
- import { e } from '@geekmidas/constructs/endpoints';
401
- import { z } from 'zod';
402
-
403
- export const ${exportName} = e
404
- .${method.toLowerCase()}('${path}')
405
- .output(z.object({ message: z.string() }))
406
- .handle(async () => ({ message: 'Hello' }));
407
- `;
408
- const { writeFileSync } = require('node:fs');
409
- const { dirname } = require('node:path');
410
- mkdirSync(dirname(join(dir, filename)), { recursive: true });
411
- writeFileSync(join(dir, filename), content);
412
- }
542
+ createOpenApiFile(apiDir, [{ method: 'GET', path: '/users' }]);
413
543
 
414
- function createWorkspace(root: string): NormalizedWorkspace {
415
- return {
544
+ const workspace: NormalizedWorkspace = {
416
545
  name: 'test',
417
- root,
546
+ root: testDir,
418
547
  apps: {
419
548
  api: {
420
549
  type: 'backend',
@@ -432,202 +561,80 @@ export const ${exportName} = e
432
561
  client: { output: 'src/api' },
433
562
  resolvedDeployTarget: 'dokploy',
434
563
  },
564
+ admin: {
565
+ type: 'frontend',
566
+ path: 'apps/admin',
567
+ port: 3002,
568
+ dependencies: ['api'],
569
+ client: { output: 'lib/client' },
570
+ resolvedDeployTarget: 'dokploy',
571
+ },
435
572
  },
436
573
  services: {},
437
574
  deploy: { default: 'dokploy' },
438
575
  shared: { packages: [] },
439
576
  secrets: {},
440
577
  };
441
- }
442
578
 
443
- it('should generate client on first call', async () => {
444
- const apiDir = join(testDir, 'apps/api');
445
- const webDir = join(testDir, 'apps/web');
446
- mkdirSync(webDir, { recursive: true });
447
-
448
- createEndpointFile(
449
- apiDir,
450
- 'src/endpoints/users.ts',
451
- 'getUsers',
452
- '/users',
453
- 'GET',
454
- );
455
-
456
- const workspace = createWorkspace(testDir);
457
- const results = await generateClientForFrontend(workspace, 'web', {
458
- force: true,
579
+ const results = await copyClientToFrontends(workspace, 'api', {
580
+ silent: true,
459
581
  });
460
582
 
461
- expect(results).toHaveLength(1);
462
- expect(results[0]?.generated).toBe(true);
463
- expect(results[0]?.endpointCount).toBe(1);
464
- expect(existsSync(join(webDir, 'src/api/openapi.ts'))).toBe(true);
465
- });
466
-
467
- it('should skip regeneration when schema has not changed', async () => {
468
- const apiDir = join(testDir, 'apps/api');
469
- const webDir = join(testDir, 'apps/web');
470
- mkdirSync(webDir, { recursive: true });
471
-
472
- createEndpointFile(
473
- apiDir,
474
- 'src/endpoints/users.ts',
475
- 'getUsers',
476
- '/users',
477
- 'GET',
478
- );
479
-
480
- const workspace = createWorkspace(testDir);
481
-
482
- // First call - should generate
483
- const firstResults = await generateClientForFrontend(workspace, 'web', {
484
- force: true,
485
- });
486
- expect(firstResults[0]?.generated).toBe(true);
487
-
488
- // Second call - should skip (no changes)
489
- const secondResults = await generateClientForFrontend(workspace, 'web');
490
- expect(secondResults[0]?.generated).toBe(false);
491
- expect(secondResults[0]?.reason).toBe('No schema changes detected');
492
- });
493
-
494
- it('should regenerate when endpoint schema changes', async () => {
495
- const apiDir = join(testDir, 'apps/api');
496
- const webDir = join(testDir, 'apps/web');
497
- mkdirSync(webDir, { recursive: true });
498
-
499
- createEndpointFile(
500
- apiDir,
501
- 'src/endpoints/users.ts',
502
- 'getUsers',
503
- '/users',
504
- 'GET',
505
- );
506
-
507
- const workspace = createWorkspace(testDir);
508
-
509
- // First call - generate initial client
510
- const firstResults = await generateClientForFrontend(workspace, 'web', {
511
- force: true,
512
- });
513
- expect(firstResults[0]?.generated).toBe(true);
514
-
515
- // Second call without changes - should skip
516
- const secondResults = await generateClientForFrontend(workspace, 'web');
517
- expect(secondResults[0]?.generated).toBe(false);
518
-
519
- // Modify endpoint - add new route
520
- createEndpointFile(
521
- apiDir,
522
- 'src/endpoints/posts.ts',
523
- 'getPosts',
524
- '/posts',
525
- 'GET',
526
- );
527
-
528
- // Third call - should regenerate due to schema change
529
- const thirdResults = await generateClientForFrontend(workspace, 'web');
530
- expect(thirdResults[0]?.generated).toBe(true);
531
- expect(thirdResults[0]?.endpointCount).toBe(2);
583
+ expect(results).toHaveLength(2);
584
+ expect(results.every((r) => r.success)).toBe(true);
585
+ expect(existsSync(join(webDir, 'src/api/api.ts'))).toBe(true);
586
+ expect(existsSync(join(adminDir, 'lib/client/api.ts'))).toBe(true);
532
587
  });
533
588
 
534
- it('should regenerate when endpoint path changes (via new file)', async () => {
589
+ it('should add header comment to copied file', async () => {
535
590
  const apiDir = join(testDir, 'apps/api');
536
591
  const webDir = join(testDir, 'apps/web');
537
592
  mkdirSync(webDir, { recursive: true });
538
593
 
539
- createEndpointFile(
540
- apiDir,
541
- 'src/endpoints/users.ts',
542
- 'getUsers',
543
- '/users',
544
- 'GET',
545
- );
546
-
547
- const workspace = createWorkspace(testDir);
548
-
549
- // First call
550
- await generateClientForFrontend(workspace, 'web', { force: true });
551
-
552
- // Second call - skip
553
- const skipResult = await generateClientForFrontend(workspace, 'web');
554
- expect(skipResult[0]?.generated).toBe(false);
555
-
556
- // Add endpoint with different path (new file to avoid ESM cache)
557
- // This simulates what happens when a developer changes a path -
558
- // in real dev server, the watcher would reload the module
559
- createEndpointFile(
560
- apiDir,
561
- 'src/endpoints/api-users.ts',
562
- 'getApiUsers',
563
- '/api/users',
564
- 'GET',
565
- );
594
+ createOpenApiFile(apiDir, [{ method: 'GET', path: '/users' }]);
566
595
 
567
- // Third call - should regenerate because schema changed
568
- const regenerateResult = await generateClientForFrontend(
569
- workspace,
570
- 'web',
571
- );
572
- expect(regenerateResult[0]?.generated).toBe(true);
573
- expect(regenerateResult[0]?.endpointCount).toBe(2); // Now has 2 endpoints
574
- });
575
-
576
- it('should skip when only handler implementation changes', async () => {
577
- const apiDir = join(testDir, 'apps/api');
578
- const webDir = join(testDir, 'apps/web');
579
- mkdirSync(webDir, { recursive: true });
580
-
581
- createEndpointFile(
582
- apiDir,
583
- 'src/endpoints/users.ts',
584
- 'getUsers',
585
- '/users',
586
- 'GET',
587
- );
596
+ const workspace: NormalizedWorkspace = {
597
+ name: 'test',
598
+ root: testDir,
599
+ apps: {
600
+ api: {
601
+ type: 'backend',
602
+ path: 'apps/api',
603
+ port: 3000,
604
+ dependencies: [],
605
+ routes: './src/endpoints/**/*.ts',
606
+ resolvedDeployTarget: 'dokploy',
607
+ },
608
+ web: {
609
+ type: 'frontend',
610
+ path: 'apps/web',
611
+ port: 3001,
612
+ dependencies: ['api'],
613
+ client: { output: 'src/api' },
614
+ resolvedDeployTarget: 'dokploy',
615
+ },
616
+ },
617
+ services: {},
618
+ deploy: { default: 'dokploy' },
619
+ shared: { packages: [] },
620
+ secrets: {},
621
+ };
588
622
 
589
- const workspace = createWorkspace(testDir);
590
-
591
- // First call
592
- await generateClientForFrontend(workspace, 'web', { force: true });
593
-
594
- // Change only the handler implementation (not the schema)
595
- const { writeFileSync } = require('node:fs');
596
- const modifiedContent = `
597
- import { e } from '@geekmidas/constructs/endpoints';
598
- import { z } from 'zod';
599
-
600
- export const getUsers = e
601
- .get('/users')
602
- .output(z.object({ message: z.string() }))
603
- .handle(async () => {
604
- // Different implementation - added a comment and console.log
605
- console.log('Getting users');
606
- return { message: 'Hello World' };
607
- });
608
- `;
609
- writeFileSync(join(apiDir, 'src/endpoints/users.ts'), modifiedContent);
623
+ await copyClientToFrontends(workspace, 'api', { silent: true });
610
624
 
611
- // Second call - should skip (schema unchanged, only implementation changed)
612
- const results = await generateClientForFrontend(workspace, 'web');
613
- expect(results[0]?.generated).toBe(false);
614
- expect(results[0]?.reason).toBe('No schema changes detected');
625
+ const content = readFileSync(join(webDir, 'src/api/api.ts'), 'utf-8');
626
+ expect(content).toContain('Auto-generated API client for api');
627
+ expect(content).toContain('DO NOT EDIT');
615
628
  });
616
629
 
617
- it('should regenerate for multiple frontends when backend changes', async () => {
630
+ it('should use backend name in filename when frontend has multiple backends', async () => {
618
631
  const apiDir = join(testDir, 'apps/api');
632
+ const authDir = join(testDir, 'apps/auth');
619
633
  const webDir = join(testDir, 'apps/web');
620
- const adminDir = join(testDir, 'apps/admin');
621
634
  mkdirSync(webDir, { recursive: true });
622
- mkdirSync(adminDir, { recursive: true });
623
635
 
624
- createEndpointFile(
625
- apiDir,
626
- 'src/endpoints/users.ts',
627
- 'getUsers',
628
- '/users',
629
- 'GET',
630
- );
636
+ createOpenApiFile(apiDir, [{ method: 'GET', path: '/users' }]);
637
+ createOpenApiFile(authDir, [{ method: 'POST', path: '/login' }]);
631
638
 
632
639
  const workspace: NormalizedWorkspace = {
633
640
  name: 'test',
@@ -641,20 +648,20 @@ export const getUsers = e
641
648
  routes: './src/endpoints/**/*.ts',
642
649
  resolvedDeployTarget: 'dokploy',
643
650
  },
644
- web: {
645
- type: 'frontend',
646
- path: 'apps/web',
651
+ auth: {
652
+ type: 'backend',
653
+ path: 'apps/auth',
647
654
  port: 3001,
648
- dependencies: ['api'],
649
- client: { output: 'src/api' },
655
+ dependencies: [],
656
+ routes: './src/endpoints/**/*.ts',
650
657
  resolvedDeployTarget: 'dokploy',
651
658
  },
652
- admin: {
659
+ web: {
653
660
  type: 'frontend',
654
- path: 'apps/admin',
661
+ path: 'apps/web',
655
662
  port: 3002,
656
- dependencies: ['api'],
657
- client: { output: 'src/client' },
663
+ dependencies: ['api', 'auth'], // Multiple backends
664
+ client: { output: 'src/api' },
658
665
  resolvedDeployTarget: 'dokploy',
659
666
  },
660
667
  },
@@ -664,33 +671,56 @@ export const getUsers = e
664
671
  secrets: {},
665
672
  };
666
673
 
667
- // Generate for both frontends
668
- await generateClientForFrontend(workspace, 'web', { force: true });
669
- await generateClientForFrontend(workspace, 'admin', { force: true });
670
-
671
- // Both should skip
672
- const webSkip = await generateClientForFrontend(workspace, 'web');
673
- const adminSkip = await generateClientForFrontend(workspace, 'admin');
674
- expect(webSkip[0]?.generated).toBe(false);
675
- expect(adminSkip[0]?.generated).toBe(false);
676
-
677
- // Add new endpoint
678
- createEndpointFile(
679
- apiDir,
680
- 'src/endpoints/products.ts',
681
- 'getProducts',
682
- '/products',
683
- 'GET',
674
+ await copyClientToFrontends(workspace, 'api', { silent: true });
675
+ await copyClientToFrontends(workspace, 'auth', { silent: true });
676
+
677
+ // Should use {backend}.ts naming
678
+ expect(existsSync(join(webDir, 'src/api/api.ts'))).toBe(true);
679
+ expect(existsSync(join(webDir, 'src/api/auth.ts'))).toBe(true);
680
+ });
681
+ });
682
+
683
+ describe('copyAllClients', () => {
684
+ let testDir: string;
685
+
686
+ beforeEach(() => {
687
+ testDir = join(
688
+ tmpdir(),
689
+ `gkm-client-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
684
690
  );
691
+ mkdirSync(testDir, { recursive: true });
692
+ });
685
693
 
686
- // Both should regenerate
687
- const webRegen = await generateClientForFrontend(workspace, 'web');
688
- const adminRegen = await generateClientForFrontend(workspace, 'admin');
689
- expect(webRegen[0]?.generated).toBe(true);
690
- expect(adminRegen[0]?.generated).toBe(true);
694
+ afterEach(() => {
695
+ if (existsSync(testDir)) {
696
+ rmSync(testDir, { recursive: true, force: true });
697
+ }
691
698
  });
692
699
 
693
- it('should only regenerate affected frontend when specific backend changes', async () => {
700
+ function createOpenApiFile(
701
+ appDir: string,
702
+ endpoints: Array<{ method: string; path: string }>,
703
+ ): void {
704
+ const endpointAuthEntries = endpoints
705
+ .map((ep) => ` '${ep.method.toUpperCase()} ${ep.path}': null,`)
706
+ .join('\n');
707
+
708
+ const content = `// Auto-generated by @geekmidas/cli - DO NOT EDIT
709
+
710
+ export const endpointAuth = {
711
+ ${endpointAuthEntries}
712
+ } as const;
713
+
714
+ export const paths = {};
715
+ export function createApi() { return {}; }
716
+ `;
717
+
718
+ const gkmDir = join(appDir, '.gkm');
719
+ mkdirSync(gkmDir, { recursive: true });
720
+ writeFileSync(join(gkmDir, 'openapi.ts'), content);
721
+ }
722
+
723
+ it('should copy from all backends to their dependent frontends', async () => {
694
724
  const apiDir = join(testDir, 'apps/api');
695
725
  const authDir = join(testDir, 'apps/auth');
696
726
  const webDir = join(testDir, 'apps/web');
@@ -698,20 +728,8 @@ export const getUsers = e
698
728
  mkdirSync(webDir, { recursive: true });
699
729
  mkdirSync(adminDir, { recursive: true });
700
730
 
701
- createEndpointFile(
702
- apiDir,
703
- 'src/endpoints/users.ts',
704
- 'getUsers',
705
- '/users',
706
- 'GET',
707
- );
708
- createEndpointFile(
709
- authDir,
710
- 'src/endpoints/login.ts',
711
- 'login',
712
- '/login',
713
- 'POST',
714
- );
731
+ createOpenApiFile(apiDir, [{ method: 'GET', path: '/users' }]);
732
+ createOpenApiFile(authDir, [{ method: 'POST', path: '/login' }]);
715
733
 
716
734
  const workspace: NormalizedWorkspace = {
717
735
  name: 'test',
@@ -737,7 +755,7 @@ export const getUsers = e
737
755
  type: 'frontend',
738
756
  path: 'apps/web',
739
757
  port: 3002,
740
- dependencies: ['api'], // Only depends on api
758
+ dependencies: ['api'],
741
759
  client: { output: 'src/api' },
742
760
  resolvedDeployTarget: 'dokploy',
743
761
  },
@@ -745,8 +763,8 @@ export const getUsers = e
745
763
  type: 'frontend',
746
764
  path: 'apps/admin',
747
765
  port: 3003,
748
- dependencies: ['auth'], // Only depends on auth
749
- client: { output: 'src/client' },
766
+ dependencies: ['auth'],
767
+ client: { output: 'lib/client' },
750
768
  resolvedDeployTarget: 'dokploy',
751
769
  },
752
770
  },
@@ -756,26 +774,37 @@ export const getUsers = e
756
774
  secrets: {},
757
775
  };
758
776
 
759
- // Initial generation
760
- await generateClientForFrontend(workspace, 'web', { force: true });
761
- await generateClientForFrontend(workspace, 'admin', { force: true });
762
-
763
- // Change only the api backend
764
- createEndpointFile(
765
- apiDir,
766
- 'src/endpoints/products.ts',
767
- 'getProducts',
768
- '/products',
769
- 'GET',
770
- );
777
+ const results = await copyAllClients(workspace, { silent: true });
778
+
779
+ expect(results).toHaveLength(2);
780
+ expect(results.every((r) => r.success)).toBe(true);
781
+ expect(existsSync(join(webDir, 'src/api/api.ts'))).toBe(true);
782
+ expect(existsSync(join(adminDir, 'lib/client/auth.ts'))).toBe(true);
783
+ });
784
+
785
+ it('should return empty array when no backends have routes', async () => {
786
+ const workspace: NormalizedWorkspace = {
787
+ name: 'test',
788
+ root: testDir,
789
+ apps: {
790
+ worker: {
791
+ type: 'backend',
792
+ path: 'apps/worker',
793
+ port: 3000,
794
+ dependencies: [],
795
+ // No routes
796
+ resolvedDeployTarget: 'dokploy',
797
+ },
798
+ },
799
+ services: {},
800
+ deploy: { default: 'dokploy' },
801
+ shared: { packages: [] },
802
+ secrets: {},
803
+ };
771
804
 
772
- // web should regenerate (depends on api)
773
- const webResult = await generateClientForFrontend(workspace, 'web');
774
- expect(webResult[0]?.generated).toBe(true);
805
+ const results = await copyAllClients(workspace, { silent: true });
775
806
 
776
- // admin should skip (depends on auth, which didn't change)
777
- const adminResult = await generateClientForFrontend(workspace, 'admin');
778
- expect(adminResult[0]?.generated).toBe(false);
807
+ expect(results).toEqual([]);
779
808
  });
780
809
  });
781
810
  });