@nexical/cli 0.11.10 → 0.11.14

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.
@@ -6,16 +6,8 @@ export default class SetupCommand extends BaseCommand {
6
6
  static description = 'Setup the application environment by symlinking core assets.';
7
7
 
8
8
  async run() {
9
- // We assume we are in the project root
10
- // But the CLI might be run from anywhere?
11
- // findProjectRoot in index.ts handles finding the root.
12
- // BaseCommand has this.projectRoot?
13
-
14
- // BaseCommand doesn't expose projectRoot directly in current implementation seen in memory, checking source if possible?
15
- // InitCommand used process.cwd().
16
-
17
- // Let's assume process.cwd() is project root if run via `npm run setup` from root.
18
- const rootDir = process.cwd();
9
+ // Use projectRoot from BaseCommand if available, fallback to cwd
10
+ const rootDir = this.projectRoot || process.cwd();
19
11
 
20
12
  // Verify we are in the right place
21
13
  if (!fs.existsSync(path.join(rootDir, 'core'))) {
@@ -56,13 +48,14 @@ export default class SetupCommand extends BaseCommand {
56
48
  fs.lstatSync(dest);
57
49
  fs.removeSync(dest);
58
50
  } catch (e: unknown) {
59
- if (
51
+ const isEnoent =
60
52
  e &&
61
53
  typeof e === 'object' &&
62
54
  'code' in e &&
63
- (e as { code: string }).code !== 'ENOENT'
64
- )
55
+ (e as { code: string }).code === 'ENOENT';
56
+ if (!isEnoent) {
65
57
  throw e;
58
+ }
66
59
  }
67
60
 
68
61
  const relSource = path.relative(destDir, source);
package/src/utils/git.ts CHANGED
@@ -10,8 +10,27 @@ export async function clone(
10
10
  options: { recursive?: boolean; depth?: number } = {},
11
11
  ): Promise<void> {
12
12
  const { recursive = false, depth } = options;
13
- const cmd = `git clone ${recursive ? '--recursive ' : ''}${depth ? `--depth ${depth} ` : ''}${url} .`;
14
- logger.debug(`Git clone: ${url} to ${destination}`);
13
+ const args = `${recursive ? '--recursive ' : ''}${depth ? `--depth ${depth} ` : ''}${url} .`;
14
+
15
+ // Attempt 1: Anonymous (no credentials)
16
+ // We use execAsync directly here to handle the error silently if it fails due to auth
17
+ try {
18
+ const cmd = `git -c credential.helper= clone ${args}`;
19
+ logger.debug(`Git clone (anonymous): ${url} to ${destination}`);
20
+ const { stdout } = await execAsync(cmd, { cwd: destination });
21
+ if (stdout) {
22
+ console.log(stdout);
23
+ }
24
+ return;
25
+ } catch (e) {
26
+ logger.debug(
27
+ `Anonymous clone failed (${e instanceof Error ? e.message : String(e)}), retrying with default credentials...`,
28
+ );
29
+ }
30
+
31
+ // Attempt 2: Default (Authenticated or whatever is configured)
32
+ const cmd = `git clone ${args}`;
33
+ logger.debug(`Git clone (authenticated): ${url} to ${destination}`);
15
34
  await runCommand(cmd, destination);
16
35
  }
17
36
 
@@ -33,6 +52,28 @@ export async function updateSubmodules(cwd: string): Promise<void> {
33
52
  );
34
53
  }
35
54
 
55
+ export async function addSubmodule(url: string, path: string, cwd: string): Promise<void> {
56
+ // Attempt 1: Anonymous
57
+ try {
58
+ const cmd = `git -c credential.helper= submodule add ${url} ${path}`;
59
+ logger.debug(`Git submodule add (anonymous): ${url} to ${path}`);
60
+ const { stdout } = await execAsync(cmd, { cwd });
61
+ if (stdout) {
62
+ console.log(stdout);
63
+ }
64
+ return;
65
+ } catch (e) {
66
+ logger.debug(
67
+ `Anonymous submodule add failed (${e instanceof Error ? e.message : String(e)}), retrying with default credentials...`,
68
+ );
69
+ }
70
+
71
+ // Attempt 2: Default
72
+ const cmd = `git submodule add ${url} ${path}`;
73
+ logger.debug(`Git submodule add (authenticated): ${url} to ${path}`);
74
+ await runCommand(cmd, cwd);
75
+ }
76
+
36
77
  export async function checkoutOrphan(branch: string, cwd: string): Promise<void> {
37
78
  await runCommand(`git checkout --orphan ${branch}`, cwd);
38
79
  }
@@ -65,16 +65,17 @@ if (args[0] === 'build') {
65
65
  },
66
66
  }),
67
67
  'README.md': '# E2E Starter',
68
- 'nexical.yml': 'name: e2e-test\nversion: 0.0.1', // ESSENTIAL for CLI to recognize project
69
- 'src/pages/index.astro': '--- ---',
70
- 'src/core/index.ts': '// core',
71
- 'src/core/package.json': JSON.stringify({
68
+ 'nexical.yaml': 'name: e2e-test\nversion: 0.0.1', // ESSENTIAL for CLI to recognize project
69
+ 'core/src/index.ts': '// core',
70
+ 'core/package.json': JSON.stringify({
72
71
  scripts: {
73
72
  build: 'astro build',
74
73
  dev: 'astro dev',
75
74
  preview: 'astro preview',
76
75
  },
77
76
  }),
77
+ 'apps/frontend/package.json': '{}',
78
+ 'apps/backend/package.json': '{}',
78
79
  });
79
80
 
80
81
  // 3. Setup Mock Module Repo
@@ -27,6 +27,9 @@ describe('InitCommand Integration', () => {
27
27
  },
28
28
  }),
29
29
  'README.md': '# Starter Template',
30
+ 'core/src/index.ts': 'console.log("core")',
31
+ 'apps/frontend/README.md': '# Frontend',
32
+ 'apps/backend/README.md': '# Backend',
30
33
  });
31
34
 
32
35
  // Set Git Identity for the test process so InitCommand's commit works in CI
@@ -257,6 +257,32 @@ describe('DeployCommand', () => {
257
257
  await expect(command.run({})).rejects.toThrow('CLI ERROR');
258
258
  });
259
259
 
260
+ it('should handle non-Error exceptions during frontend secret resolution', async () => {
261
+ const mockBackend = {
262
+ name: 'railway',
263
+ provision: vi.fn(),
264
+ getSecrets: vi.fn().mockResolvedValue({}),
265
+ getVariables: vi.fn().mockResolvedValue({}),
266
+ };
267
+ const mockFrontend = {
268
+ name: 'cloudflare',
269
+ provision: vi.fn(),
270
+ getSecrets: vi.fn().mockRejectedValue('String front secret fail'),
271
+ getVariables: vi.fn().mockResolvedValue({}),
272
+ };
273
+ mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
274
+ if (name === 'railway') return mockBackend;
275
+ if (name === 'cloudflare') return mockFrontend;
276
+ });
277
+ mockRegistry.getRepositoryProvider.mockReturnValue({
278
+ configureSecrets: vi.fn(),
279
+ configureVariables: vi.fn(),
280
+ generateWorkflow: vi.fn(),
281
+ });
282
+
283
+ await expect(command.run({})).rejects.toThrow('CLI ERROR');
284
+ });
285
+
260
286
  it('should handle errors during frontend variable resolution', async () => {
261
287
  const mockBackend = {
262
288
  name: 'railway',
@@ -282,4 +308,48 @@ describe('DeployCommand', () => {
282
308
 
283
309
  await expect(command.run({})).rejects.toThrow('CLI ERROR');
284
310
  });
311
+
312
+ it('should handle non-Error exceptions during frontend variable resolution', async () => {
313
+ const mockBackend = {
314
+ name: 'railway',
315
+ provision: vi.fn(),
316
+ getSecrets: vi.fn().mockResolvedValue({}),
317
+ getVariables: vi.fn().mockResolvedValue({}),
318
+ };
319
+ const mockFrontend = {
320
+ name: 'cloudflare',
321
+ provision: vi.fn(),
322
+ getSecrets: vi.fn().mockResolvedValue({}),
323
+ getVariables: vi.fn().mockRejectedValue('String front var fail'),
324
+ };
325
+ mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
326
+ if (name === 'railway') return mockBackend;
327
+ if (name === 'cloudflare') return mockFrontend;
328
+ });
329
+ mockRegistry.getRepositoryProvider.mockReturnValue({
330
+ configureSecrets: vi.fn(),
331
+ configureVariables: vi.fn(),
332
+ generateWorkflow: vi.fn(),
333
+ });
334
+
335
+ await expect(command.run({})).rejects.toThrow('CLI ERROR');
336
+ });
337
+
338
+ it('should throw if frontend provider is not found in registry', async () => {
339
+ mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
340
+ if (name === 'railway') return { name: 'railway' };
341
+ return undefined;
342
+ });
343
+ await expect(command.run({ frontend: 'unknown' })).rejects.toThrow(
344
+ "Frontend provider 'unknown' not found.",
345
+ );
346
+ });
347
+
348
+ it('should throw if repo provider is not found in registry', async () => {
349
+ mockRegistry.getDeploymentProvider.mockReturnValue({ name: 'mock' });
350
+ mockRegistry.getRepositoryProvider.mockReturnValue(undefined);
351
+ await expect(command.run({ repo: 'unknown' })).rejects.toThrow(
352
+ "Repository provider 'unknown' not found.",
353
+ );
354
+ });
285
355
  });
@@ -1,6 +1,7 @@
1
1
  import { runCommand } from '@nexical/cli-core';
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import InitCommand from '../../../src/commands/init.js';
4
+ import SetupCommand from '../../../src/commands/setup.js';
4
5
  import * as git from '../../../src/utils/git.js';
5
6
  import fs from 'fs-extra';
6
7
 
@@ -34,6 +35,15 @@ vi.mock('../../../src/utils/git.js', () => ({
34
35
  getRemoteUrl: vi.fn(),
35
36
  }));
36
37
 
38
+ vi.mock('../../../src/commands/setup.js', () => {
39
+ const MockSetup = vi.fn();
40
+ MockSetup.prototype.init = vi.fn();
41
+ MockSetup.prototype.run = vi.fn();
42
+ return {
43
+ default: MockSetup,
44
+ };
45
+ });
46
+
37
47
  vi.mock('fs-extra');
38
48
 
39
49
  describe('InitCommand', () => {
@@ -43,7 +53,9 @@ describe('InitCommand', () => {
43
53
  beforeEach(() => {
44
54
  vi.clearAllMocks();
45
55
  command = new InitCommand({});
46
- vi.spyOn(command, 'error').mockImplementation(() => {});
56
+ vi.spyOn(command, 'error').mockImplementation((msg) => {
57
+ console.error('COMMAND ERROR:', msg);
58
+ });
47
59
  vi.spyOn(command, 'info').mockImplementation(() => {});
48
60
  vi.spyOn(command, 'success').mockImplementation(() => {});
49
61
 
@@ -111,6 +123,12 @@ describe('InitCommand', () => {
111
123
  expect.stringContaining(targetDir),
112
124
  );
113
125
 
126
+ // SetupCommand verification
127
+ expect(SetupCommand).toHaveBeenCalled();
128
+ const setupInstance = vi.mocked(SetupCommand).mock.instances[0];
129
+ expect(setupInstance.init).toHaveBeenCalled();
130
+ expect(setupInstance.run).toHaveBeenCalled();
131
+
114
132
  expect(command.success).toHaveBeenCalledWith(expect.stringContaining('successfully'));
115
133
  });
116
134
 
@@ -43,6 +43,7 @@ describe('ModuleAddCommand', () => {
43
43
  (fs.remove as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
44
44
  (fs.writeFile as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
45
45
  (gitUtils.clone as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
46
+ (gitUtils.addSubmodule as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
46
47
  (cliCore.runCommand as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
47
48
  });
48
49
 
@@ -69,10 +70,9 @@ describe('ModuleAddCommand', () => {
69
70
  await command.run({ url: repoUrl });
70
71
 
71
72
  expect(gitUtils.clone).toHaveBeenCalled();
72
- expect(cliCore.runCommand).toHaveBeenCalledWith(
73
- expect.stringContaining(
74
- `git submodule add ${repoUrl} apps/backend/modules/my-backend-module`,
75
- ),
73
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
74
+ repoUrl,
75
+ 'apps/backend/modules/my-backend-module',
76
76
  projectRoot,
77
77
  );
78
78
  });
@@ -95,10 +95,9 @@ describe('ModuleAddCommand', () => {
95
95
 
96
96
  await command.run({ url: repoUrl });
97
97
 
98
- expect(cliCore.runCommand).toHaveBeenCalledWith(
99
- expect.stringContaining(
100
- `git submodule add ${repoUrl} apps/frontend/modules/my-frontend-module`,
101
- ),
98
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
99
+ repoUrl,
100
+ 'apps/frontend/modules/my-frontend-module',
102
101
  projectRoot,
103
102
  );
104
103
  });
@@ -124,8 +123,9 @@ describe('ModuleAddCommand', () => {
124
123
 
125
124
  await command.run({ url: repoUrl });
126
125
 
127
- expect(cliCore.runCommand).toHaveBeenCalledWith(
128
- expect.stringContaining('apps/backend/modules/pkg-mod'),
126
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
127
+ expect.anything(),
128
+ 'apps/backend/modules/pkg-mod',
129
129
  projectRoot,
130
130
  );
131
131
  });
@@ -142,8 +142,9 @@ describe('ModuleAddCommand', () => {
142
142
 
143
143
  await command.run({ url: repoUrl });
144
144
 
145
- expect(cliCore.runCommand).toHaveBeenCalledWith(
146
- expect.stringContaining('apps/backend/modules/fallback-mod'),
145
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
146
+ expect.anything(),
147
+ 'apps/backend/modules/fallback-mod',
147
148
  projectRoot,
148
149
  );
149
150
  });
@@ -160,8 +161,9 @@ describe('ModuleAddCommand', () => {
160
161
 
161
162
  await command.run({ url: repoUrl });
162
163
 
163
- expect(cliCore.runCommand).toHaveBeenCalledWith(
164
- expect.stringContaining('apps/frontend/modules/comp-mod'),
164
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
165
+ expect.anything(),
166
+ 'apps/frontend/modules/comp-mod',
165
167
  projectRoot,
166
168
  );
167
169
  });
@@ -212,8 +214,16 @@ describe('ModuleAddCommand', () => {
212
214
 
213
215
  await command.run({ url: rootUrl });
214
216
 
215
- expect(cliCore.runCommand).toHaveBeenCalledWith(expect.stringContaining('root'), projectRoot);
216
- expect(cliCore.runCommand).toHaveBeenCalledWith(expect.stringContaining('dep'), projectRoot);
217
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
218
+ expect.stringContaining('root'),
219
+ expect.anything(),
220
+ projectRoot,
221
+ );
222
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
223
+ expect.stringContaining('dep'),
224
+ expect.anything(),
225
+ projectRoot,
226
+ );
217
227
  });
218
228
 
219
229
  it('should throw error on dependency conflict', async () => {
@@ -359,8 +369,9 @@ describe('ModuleAddCommand', () => {
359
369
  });
360
370
 
361
371
  await command.run({ url: 'https://github.com/org/yml.git' });
362
- expect(cliCore.runCommand).toHaveBeenCalledWith(
363
- expect.stringContaining('yml-mod'),
372
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
373
+ expect.stringContaining('yml'),
374
+ 'apps/backend/modules/yml-mod',
364
375
  projectRoot,
365
376
  );
366
377
  });
@@ -391,8 +402,9 @@ describe('ModuleAddCommand', () => {
391
402
  });
392
403
 
393
404
  await command.run({ url: 'https://github.com/org/obj.git' });
394
- expect(cliCore.runCommand).toHaveBeenCalledWith(
395
- expect.stringContaining('dep-mod'),
405
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
406
+ expect.stringContaining('dep'),
407
+ 'apps/backend/modules/dep-mod',
396
408
  projectRoot,
397
409
  );
398
410
  });
@@ -435,4 +447,192 @@ describe('ModuleAddCommand', () => {
435
447
  expect(writeCall[1]).toContain('backend:');
436
448
  expect(writeCall[1]).toContain('new-mod');
437
449
  });
450
+ it('should handle module name from package.json without scope', async () => {
451
+ const repoUrl = 'https://github.com/org/noscope-repo.git';
452
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
453
+ const pStr = p.toString();
454
+ if (pStr.endsWith('package.json')) return true;
455
+ if (pStr.includes('nexical.yaml')) return true;
456
+ return false;
457
+ });
458
+ (fs.readJson as unknown as { mockResolvedValue: any }).mockResolvedValue({
459
+ name: 'noscope-mod',
460
+ });
461
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('modules: {}');
462
+
463
+ await command.run({ url: repoUrl });
464
+
465
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
466
+ expect.anything(),
467
+ 'apps/backend/modules/noscope-mod',
468
+ projectRoot,
469
+ );
470
+ });
471
+
472
+ it('should handle package.json without name', async () => {
473
+ const repoUrl = 'https://github.com/org/noname-repo.git';
474
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
475
+ const pStr = p.toString();
476
+ if (pStr.endsWith('package.json')) return true;
477
+ if (pStr.includes('nexical.yaml')) return true;
478
+ return false;
479
+ });
480
+ (fs.readJson as unknown as { mockResolvedValue: any }).mockResolvedValue({});
481
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('modules: {}');
482
+
483
+ await command.run({ url: repoUrl });
484
+
485
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
486
+ expect.anything(),
487
+ 'apps/backend/modules/noname-repo',
488
+ projectRoot,
489
+ );
490
+ });
491
+
492
+ it('should skip adding if already in nexical.yaml', async () => {
493
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
494
+ if (p.includes('nexical.yaml')) return true;
495
+ if (p.endsWith('module.yaml')) return true;
496
+ return false;
497
+ });
498
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
499
+ if (p.includes('nexical.yaml')) return 'modules:\n backend:\n - existing';
500
+ if (p.endsWith('module.yaml')) return 'name: existing\n';
501
+ return '';
502
+ });
503
+
504
+ await command.run({ url: 'http://example.com/existing.git' });
505
+
506
+ expect(fs.writeFile).not.toHaveBeenCalled();
507
+ });
508
+ it('should handle missing name in module.yaml', async () => {
509
+ const repoUrl = 'https://github.com/org/noname-yaml.git';
510
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
511
+ const pStr = p.toString();
512
+ if (pStr.endsWith('module.yaml')) return true;
513
+ if (pStr.includes('nexical.yaml')) return true;
514
+ return false;
515
+ });
516
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
517
+ if (p.endsWith('module.yaml')) return 'version: 1.0.0\n'; // name missing
518
+ if (p.includes('nexical.yaml')) return 'modules: {}';
519
+ return '';
520
+ });
521
+
522
+ await command.run({ url: repoUrl });
523
+
524
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
525
+ expect.anything(),
526
+ 'apps/backend/modules/noname-yaml',
527
+ projectRoot,
528
+ );
529
+ });
530
+
531
+ it('should handle subpath in url', async () => {
532
+ const repoUrl = 'https://github.com/org/repo.git';
533
+ const subpath = 'my/sub';
534
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
535
+ if (p.includes('nexical.yaml')) return true;
536
+ if (p.endsWith('module.yaml')) return true;
537
+ return false;
538
+ });
539
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
540
+ if (p.includes('nexical.yaml')) return 'modules: {}';
541
+ if (p.endsWith('module.yaml')) return 'name: sub-mod\n';
542
+ return '';
543
+ });
544
+
545
+ await command.run({ url: `${repoUrl}//${subpath}` });
546
+
547
+ expect(gitUtils.clone).toHaveBeenCalledWith(repoUrl, expect.anything(), expect.anything());
548
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
549
+ repoUrl,
550
+ 'apps/backend/modules/sub-mod',
551
+ projectRoot,
552
+ );
553
+ });
554
+ it('should handle url without subpath explicitly', async () => {
555
+ const repoUrl = 'https://github.com/org/nosub.git';
556
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
557
+ if (p.includes('nexical.yaml')) return true;
558
+ if (p.endsWith('module.yaml')) return true;
559
+ return false;
560
+ });
561
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
562
+ if (p.includes('nexical.yaml')) return 'modules: {}';
563
+ if (p.endsWith('module.yaml')) return 'name: nosub-mod\n';
564
+ return '';
565
+ });
566
+
567
+ await command.run({ url: repoUrl });
568
+
569
+ expect(gitUtils.addSubmodule).toHaveBeenCalledWith(
570
+ repoUrl,
571
+ 'apps/backend/modules/nosub-mod',
572
+ projectRoot,
573
+ );
574
+ });
575
+ it('should handle dependencies as an object', async () => {
576
+ const repoUrl = 'https://github.com/org/dep-obj.git';
577
+
578
+ // We must ensure that targetDir (apps/backend/modules/dep-obj) does NOT exist for initial call
579
+ // and ALSO for some-dep if we want to see install logs.
580
+ (fs.pathExists as any).mockImplementation((p: string) => {
581
+ const pStr = p.toString();
582
+ if (pStr.includes('nexical.yaml')) return true;
583
+ if (pStr.includes('module.yaml')) return true;
584
+ if (pStr.includes('models.yaml') || pStr.includes('api.yaml')) return true;
585
+ return false;
586
+ });
587
+
588
+ (fs.readFile as any).mockImplementation((p: string) => {
589
+ const pStr = p.toString();
590
+ if (pStr.endsWith('module.yaml')) {
591
+ if (pStr.includes('staging')) {
592
+ return 'name: dep-obj\ndependencies:\n some-dep: "1.0.0"\n';
593
+ }
594
+ }
595
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
596
+ return '';
597
+ });
598
+
599
+ await command.run({ url: repoUrl });
600
+
601
+ // Check if error was called which might explain failure
602
+ if ((command.error as any).mock.calls.length > 0) {
603
+ // eslint-disable-next-line no-console
604
+ console.log('COMMAND ERROR:', (command.error as any).mock.calls[0][0]);
605
+ }
606
+
607
+ expect(command.info).toHaveBeenCalledWith(expect.stringContaining('Resolving 1 dependencies'));
608
+ });
609
+
610
+ it('should handle already installed module with matching remote', async () => {
611
+ const repoUrl = 'https://github.com/org/match.git';
612
+ (fs.pathExists as any).mockImplementation((p: string) => {
613
+ if (p.includes('apps/backend/modules/match')) return true;
614
+ return true;
615
+ });
616
+ (fs.readFile as any).mockImplementation((p: string) => {
617
+ if (p.endsWith('module.yaml')) return 'name: match\n';
618
+ return 'modules: {}';
619
+ });
620
+ (gitUtils.getRemoteUrl as any).mockResolvedValue('https://github.com/org/match.git');
621
+
622
+ await command.run({ url: repoUrl });
623
+ expect(command.info).toHaveBeenCalledWith(expect.stringContaining('already installed'));
624
+ });
625
+
626
+ it('should handle already installed module with empty remote', async () => {
627
+ const repoUrl = 'https://github.com/org/empty-rem.git';
628
+ (fs.pathExists as any).mockImplementation((p: string) => true);
629
+ (fs.readFile as any).mockImplementation((p: string) => {
630
+ if (p.endsWith('module.yaml')) return 'name: empty-rem\n';
631
+ return 'modules: {}';
632
+ });
633
+ (gitUtils.getRemoteUrl as any).mockResolvedValue('');
634
+
635
+ await command.run({ url: repoUrl });
636
+ expect(command.info).toHaveBeenCalledWith(expect.stringContaining('already installed'));
637
+ });
438
638
  });