@nexical/cli 0.10.0 → 0.11.1

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.
Files changed (76) hide show
  1. package/.github/workflows/deploy.yml +1 -1
  2. package/.husky/pre-commit +1 -0
  3. package/.prettierignore +8 -0
  4. package/.prettierrc +7 -0
  5. package/GEMINI.md +199 -0
  6. package/README.md +85 -56
  7. package/dist/chunk-AC4B3HPJ.js +93 -0
  8. package/dist/chunk-AC4B3HPJ.js.map +1 -0
  9. package/dist/{chunk-JYASTIIW.js → chunk-PJIOCW2A.js} +1 -1
  10. package/dist/chunk-PJIOCW2A.js.map +1 -0
  11. package/dist/{chunk-WKERTCM6.js → chunk-Q7YLW5HJ.js} +5 -2
  12. package/dist/chunk-Q7YLW5HJ.js.map +1 -0
  13. package/dist/index.js +41 -12
  14. package/dist/index.js.map +1 -1
  15. package/dist/src/commands/init.d.ts +4 -1
  16. package/dist/src/commands/init.js +15 -10
  17. package/dist/src/commands/init.js.map +1 -1
  18. package/dist/src/commands/module/add.d.ts +3 -1
  19. package/dist/src/commands/module/add.js +27 -16
  20. package/dist/src/commands/module/add.js.map +1 -1
  21. package/dist/src/commands/module/list.js +9 -5
  22. package/dist/src/commands/module/list.js.map +1 -1
  23. package/dist/src/commands/module/remove.d.ts +3 -1
  24. package/dist/src/commands/module/remove.js +13 -7
  25. package/dist/src/commands/module/remove.js.map +1 -1
  26. package/dist/src/commands/module/update.d.ts +3 -1
  27. package/dist/src/commands/module/update.js +7 -5
  28. package/dist/src/commands/module/update.js.map +1 -1
  29. package/dist/src/commands/run.d.ts +4 -1
  30. package/dist/src/commands/run.js +10 -2
  31. package/dist/src/commands/run.js.map +1 -1
  32. package/dist/src/commands/setup.d.ts +8 -0
  33. package/dist/src/commands/setup.js +75 -0
  34. package/dist/src/commands/setup.js.map +1 -0
  35. package/dist/src/utils/discovery.js +1 -1
  36. package/dist/src/utils/git.js +1 -1
  37. package/dist/src/utils/url-resolver.js +1 -1
  38. package/eslint.config.mjs +67 -0
  39. package/index.ts +34 -20
  40. package/package.json +57 -33
  41. package/src/commands/init.ts +79 -75
  42. package/src/commands/module/add.ts +158 -148
  43. package/src/commands/module/list.ts +61 -50
  44. package/src/commands/module/remove.ts +59 -54
  45. package/src/commands/module/update.ts +44 -42
  46. package/src/commands/run.ts +89 -81
  47. package/src/commands/setup.ts +92 -0
  48. package/src/utils/discovery.ts +98 -113
  49. package/src/utils/git.ts +35 -28
  50. package/src/utils/url-resolver.ts +50 -45
  51. package/test/e2e/lifecycle.e2e.test.ts +139 -130
  52. package/test/integration/commands/init.integration.test.ts +64 -61
  53. package/test/integration/commands/module.integration.test.ts +122 -122
  54. package/test/integration/commands/run.integration.test.ts +70 -63
  55. package/test/integration/utils/command-loading.integration.test.ts +40 -53
  56. package/test/unit/commands/init.test.ts +163 -128
  57. package/test/unit/commands/module/add.test.ts +312 -245
  58. package/test/unit/commands/module/list.test.ts +108 -91
  59. package/test/unit/commands/module/remove.test.ts +74 -67
  60. package/test/unit/commands/module/update.test.ts +74 -70
  61. package/test/unit/commands/run.test.ts +253 -201
  62. package/test/unit/commands/setup.test.ts +187 -0
  63. package/test/unit/utils/command-discovery.test.ts +138 -125
  64. package/test/unit/utils/git.test.ts +135 -117
  65. package/test/unit/utils/integration-helpers.test.ts +59 -49
  66. package/test/unit/utils/url-resolver.test.ts +46 -34
  67. package/test/utils/integration-helpers.ts +36 -29
  68. package/tsconfig.json +15 -25
  69. package/tsup.config.ts +14 -14
  70. package/vitest.config.ts +10 -10
  71. package/vitest.e2e.config.ts +6 -6
  72. package/vitest.integration.config.ts +17 -17
  73. package/dist/chunk-JYASTIIW.js.map +0 -1
  74. package/dist/chunk-OKXOCNXP.js +0 -105
  75. package/dist/chunk-OKXOCNXP.js.map +0 -1
  76. package/dist/chunk-WKERTCM6.js.map +0 -1
@@ -1,262 +1,329 @@
1
- import { logger, runCommand } from '@nexical/cli-core';
1
+ import { runCommand } from '@nexical/cli-core';
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import ModuleAddCommand from '../../../../src/commands/module/add.js';
4
4
  import fs from 'fs-extra';
5
5
  import * as git from '../../../../src/utils/git.js';
6
6
 
7
7
  vi.mock('@nexical/cli-core', async (importOriginal) => {
8
- const mod = await importOriginal<typeof import('@nexical/cli-core')>();
9
- return {
10
- ...mod,
11
- logger: {
12
- ...mod.logger,
13
- success: vi.fn(),
14
- info: vi.fn(),
15
- debug: vi.fn(),
16
- error: vi.fn(),
17
- warn: vi.fn(),
18
- },
19
- runCommand: vi.fn(),
20
- };
8
+ const mod = await importOriginal<typeof import('@nexical/cli-core')>();
9
+ return {
10
+ ...mod,
11
+ logger: {
12
+ ...mod.logger,
13
+ success: vi.fn(),
14
+ info: vi.fn(),
15
+ debug: vi.fn(),
16
+ error: vi.fn(),
17
+ warn: vi.fn(),
18
+ },
19
+ runCommand: vi.fn(),
20
+ };
21
21
  });
22
22
  vi.mock('fs-extra');
23
23
  vi.mock('../../../../src/utils/git.js', () => ({
24
- clone: vi.fn(),
25
- updateSubmodules: vi.fn(),
26
- checkoutOrphan: vi.fn(),
27
- addAll: vi.fn(),
28
- commit: vi.fn(),
29
- deleteBranch: vi.fn(),
30
- renameBranch: vi.fn(),
31
- removeRemote: vi.fn(),
32
- branchExists: vi.fn(),
33
- renameRemote: vi.fn(),
34
- getRemoteUrl: vi.fn()
24
+ clone: vi.fn(),
25
+ updateSubmodules: vi.fn(),
26
+ checkoutOrphan: vi.fn(),
27
+ addAll: vi.fn(),
28
+ commit: vi.fn(),
29
+ deleteBranch: vi.fn(),
30
+ renameBranch: vi.fn(),
31
+ removeRemote: vi.fn(),
32
+ branchExists: vi.fn(),
33
+ renameRemote: vi.fn(),
34
+ getRemoteUrl: vi.fn(),
35
35
  }));
36
36
 
37
37
  describe('ModuleAddCommand', () => {
38
- let command: ModuleAddCommand;
39
-
40
- beforeEach(async () => {
41
- vi.clearAllMocks();
42
- command = new ModuleAddCommand({}, { rootDir: '/mock/root' });
43
- vi.spyOn(command, 'error').mockImplementation((() => { }) as any);
44
- vi.spyOn(command, 'success').mockImplementation((() => { }) as any);
45
- vi.spyOn(command, 'info').mockImplementation((() => { }) as any);
46
-
47
- // Setup mocks
48
- vi.mocked(fs.ensureDir).mockImplementation(async () => { });
49
- vi.mocked(fs.remove).mockImplementation(async () => { });
50
- vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
51
- // We don't rely on this for init anymore since we force projectRoot
52
- return false;
53
- });
54
- vi.mocked(fs.readFile).mockResolvedValue('name: test-module\n' as any);
55
-
56
- // Mock git default behaviors
57
- vi.mocked(git.clone).mockResolvedValue(undefined as any);
58
- vi.mocked(git.getRemoteUrl).mockResolvedValue('' as any);
59
-
60
- // Force project root
61
- await command.init();
62
- (command as any).projectRoot = '/mock/root';
38
+ let command: ModuleAddCommand;
39
+
40
+ beforeEach(async () => {
41
+ vi.clearAllMocks();
42
+ command = new ModuleAddCommand({}, { rootDir: '/mock/root' });
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ vi.spyOn(command, 'error').mockImplementation((() => {}) as any);
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ vi.spyOn(command, 'success').mockImplementation((() => {}) as any);
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ vi.spyOn(command, 'info').mockImplementation((() => {}) as any);
49
+
50
+ // Setup mocks
51
+ vi.mocked(fs.ensureDir).mockImplementation(async () => {});
52
+ vi.mocked(fs.remove).mockImplementation(async () => {});
53
+ vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
54
+ // We don't rely on this for init anymore since we force projectRoot
55
+ return false;
63
56
  });
64
-
65
- afterEach(() => {
66
- vi.resetAllMocks();
67
- });
68
-
69
- it('should have correct static properties', () => {
70
- expect(ModuleAddCommand.usage).toContain('module add');
71
- expect(ModuleAddCommand.description).toBeDefined();
72
- expect(ModuleAddCommand.requiresProject).toBe(true);
73
- expect(ModuleAddCommand.args).toBeDefined();
74
- });
75
-
76
- it('should error if project root is missing', async () => {
77
- command = new ModuleAddCommand({}, { rootDir: undefined });
78
- vi.spyOn(command, 'error').mockImplementation(() => { });
79
- // Ensure init doesn't set it (mocked in beforeEach but this constructor overrides logic?)
80
- // In beforeEach, we call command.init() then set projectRoot.
81
- // Here we just created new command.
82
- vi.spyOn(command, 'init').mockImplementation(async () => { });
83
-
84
- await command.runInit({ url: 'arg' });
85
- expect(command.error).toHaveBeenCalledWith(expect.stringContaining('requires to be run within an app project'), 1);
86
- });
87
-
88
- it('should handle gh@ syntax with .git suffix', async () => {
89
- // We mock fs.pathExists to return false for targetDir to trigger install
90
- // return true for stagingDir to simulate clone success
91
- // return true for module.yaml check
92
- vi.mocked(fs.pathExists).mockResolvedValue(false as any); // Default
93
-
94
- await command.run({ url: 'gh@org/repo.git' }); // With .git suffix
95
-
96
- // Should NOT append another .git
97
- expect(git.clone).toHaveBeenCalledWith(
98
- 'https://github.com/org/repo.git',
99
- expect.any(String),
100
- expect.objectContaining({ depth: 1 })
101
- );
102
- });
103
-
104
- it('should error if url is missing', async () => {
105
- await command.run({ url: undefined });
106
- expect(command.error).toHaveBeenCalledWith('Please specify a repository URL.');
107
- });
108
-
109
- it('should error if module.yaml is missing', async () => {
110
- vi.mocked(fs.pathExists).mockResolvedValue(false as any); // No yaml found
111
- await command.run({ url: 'https://github.com/org/repo.git' });
112
- expect(command.error).toHaveBeenCalledWith(expect.stringContaining('No module.yaml found'));
113
- });
114
-
115
- it('should error if module.yaml is missing in subdirectory', async () => {
116
- vi.mocked(fs.pathExists).mockResolvedValue(false as any); // No yaml
117
-
118
- // We mocked fs.pathExists to return false for everything in this test setup unless selective
119
- // But the run() logic:
120
- // await clone(...)
121
- // if (subPath) ...
122
- // if (!exists) throw
123
-
124
- await command.run({ url: 'https://github.com/org/repo.git//subdir' });
125
- expect(command.error).toHaveBeenCalledWith(expect.stringContaining('No module.yaml found in https://github.com/org/repo.git//subdir'));
126
- });
127
-
128
- it('should error if name is missing in module.yaml', async () => {
129
- vi.mocked(fs.pathExists).mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any);
130
- vi.mocked(fs.readFile).mockResolvedValueOnce('dependencies: []' as any); // No name
131
- await command.run({ url: 'https://github.com/org/repo.git' });
132
- expect(command.error).toHaveBeenCalledWith(expect.stringContaining('missing \'name\' in module.yaml'));
133
- });
134
-
135
- it('should handle generic errors during install', async () => {
136
- vi.mocked(git.clone).mockRejectedValue(new Error('Clone failed'));
137
- await command.run({ url: 'https://github.com/org/repo.git' });
138
- expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to add module: Clone failed'));
139
- });
140
-
141
- it('should install a module using git submodule add', async () => {
142
- vi.mocked(fs.pathExists).mockResolvedValueOnce(false as any); // Staging yaml
143
- vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any); // module.yaml in staging exists
144
- vi.mocked(fs.readFile).mockResolvedValueOnce('name: my-module\n' as any);
145
-
146
- await command.run({ url: 'https://github.com/org/repo.git' });
147
-
148
- expect(git.clone).toHaveBeenCalledWith(
149
- 'https://github.com/org/repo.git',
150
- expect.stringContaining('staging-'),
151
- { depth: 1 }
152
- );
153
-
154
- // Should use submodule add
155
- expect(runCommand).toHaveBeenCalledWith(
156
- expect.stringContaining('git submodule add https://github.com/org/repo.git modules/my-module'),
157
- '/mock/root'
158
- );
159
-
160
- expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
161
- expect(command.success).toHaveBeenCalledWith('All modules installed successfully.');
162
- });
163
-
164
- it('should recursively install dependencies', async () => {
165
- // First module calls
166
- vi.mocked(fs.pathExists)
167
- .mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any) // module 1 yaml exists
168
- .mockResolvedValueOnce(false as any) // target dir check
169
- .mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any) // module 2 yaml exists
170
- .mockResolvedValueOnce(false as any); // target dir check
171
-
172
- vi.mocked(fs.readFile)
173
- .mockResolvedValueOnce('name: parent\ndependencies:\n - gh@org/child' as any)
174
- .mockResolvedValueOnce('name: child' as any);
175
-
176
- await command.run({ url: 'gh@org/parent' });
177
-
178
- // Should clone parent
179
- expect(git.clone).toHaveBeenCalledWith(
180
- expect.stringContaining('parent.git'),
181
- expect.anything(),
182
- expect.anything()
183
- );
184
- // Should clone child
185
- expect(git.clone).toHaveBeenCalledWith(
186
- expect.stringContaining('child.git'),
187
- expect.anything(),
188
- expect.anything()
189
- );
190
-
191
- expect(runCommand).toHaveBeenCalledTimes(3); // 2 submodules + npm install
192
- });
193
-
194
- it('should handle object-style dependencies', async () => {
195
- vi.mocked(fs.pathExists)
196
- .mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any) // module exists
197
- .mockResolvedValueOnce(false as any); // target dir check
198
-
199
- vi.mocked(fs.readFile)
200
- .mockResolvedValueOnce('name: parent\ndependencies:\n gh@org/child: main' as any); // Object style
201
-
202
- await command.run({ url: 'gh@org/parent' });
203
-
204
- expect(git.clone).toHaveBeenCalledTimes(2); // parent + child
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ vi.mocked(fs.readFile).mockResolvedValue('name: test-module\n' as any);
59
+
60
+ // Mock git default behaviors
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ vi.mocked(git.clone).mockResolvedValue(undefined as any);
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ vi.mocked(git.getRemoteUrl).mockResolvedValue('' as any);
65
+
66
+ // Force project root
67
+ await command.init();
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ (command as any).projectRoot = '/mock/root';
70
+ });
71
+
72
+ afterEach(() => {
73
+ vi.resetAllMocks();
74
+ });
75
+
76
+ it('should have correct static properties', () => {
77
+ expect(ModuleAddCommand.usage).toContain('module add');
78
+ expect(ModuleAddCommand.description).toBeDefined();
79
+ expect(ModuleAddCommand.requiresProject).toBe(true);
80
+ expect(ModuleAddCommand.args).toBeDefined();
81
+ });
82
+
83
+ it('should error if project root is missing', async () => {
84
+ command = new ModuleAddCommand({}, { rootDir: undefined });
85
+ vi.spyOn(command, 'error').mockImplementation(() => {});
86
+ // Ensure init doesn't set it (mocked in beforeEach but this constructor overrides logic?)
87
+ // In beforeEach, we call command.init() then set projectRoot.
88
+ // Here we just created new command.
89
+ vi.spyOn(command, 'init').mockImplementation(async () => {});
90
+
91
+ await command.runInit({ url: 'arg' });
92
+ expect(command.error).toHaveBeenCalledWith(
93
+ expect.stringContaining('requires to be run within an app project'),
94
+ 1,
95
+ );
96
+ });
97
+
98
+ it('should handle gh@ syntax with .git suffix', async () => {
99
+ // We mock fs.pathExists to return false for targetDir to trigger install
100
+ // return true for stagingDir to simulate clone success
101
+ // return true for module.yaml check
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any); // Default
104
+
105
+ await command.run({ url: 'gh@org/repo.git' }); // With .git suffix
106
+
107
+ // Should NOT append another .git
108
+ expect(git.clone).toHaveBeenCalledWith(
109
+ 'https://github.com/org/repo.git',
110
+ expect.any(String),
111
+ expect.objectContaining({ depth: 1 }),
112
+ );
113
+ });
114
+
115
+ it('should error if url is missing', async () => {
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ await command.run({ url: undefined as any });
118
+ expect(command.error).toHaveBeenCalledWith('Please specify a repository URL.');
119
+ });
120
+
121
+ it('should error if module.yaml is missing', async () => {
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any); // No yaml found
124
+ await command.run({ url: 'https://github.com/org/repo.git' });
125
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('No module.yaml found'));
126
+ });
127
+
128
+ it('should error if module.yaml is missing in subdirectory', async () => {
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ vi.mocked(fs.pathExists).mockResolvedValue(false as any); // No yaml
131
+
132
+ // We mocked fs.pathExists to return false for everything in this test setup unless selective
133
+ // But the run() logic:
134
+ // await clone(...)
135
+ // if (subPath) ...
136
+ // if (!exists) throw
137
+
138
+ await command.run({ url: 'https://github.com/org/repo.git//subdir' });
139
+ expect(command.error).toHaveBeenCalledWith(
140
+ expect.stringContaining('No module.yaml found in https://github.com/org/repo.git//subdir'),
141
+ );
142
+ });
143
+
144
+ it('should error if name is missing in module.yaml', async () => {
145
+ vi.mocked(fs.pathExists)
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
+ .mockResolvedValueOnce(false as any)
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ .mockResolvedValueOnce(true as any);
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ vi.mocked(fs.readFile).mockResolvedValueOnce('dependencies: []' as any); // No name
152
+ await command.run({ url: 'https://github.com/org/repo.git' });
153
+ expect(command.error).toHaveBeenCalledWith(
154
+ expect.stringContaining("missing 'name' in module.yaml"),
155
+ );
156
+ });
157
+
158
+ it('should handle generic errors during install', async () => {
159
+ vi.mocked(git.clone).mockRejectedValue(new Error('Clone failed'));
160
+ await command.run({ url: 'https://github.com/org/repo.git' });
161
+ expect(command.error).toHaveBeenCalledWith(
162
+ expect.stringContaining('Failed to add module: Clone failed'),
163
+ );
164
+ });
165
+
166
+ it('should install a module using git submodule add', async () => {
167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
+ vi.mocked(fs.pathExists).mockResolvedValueOnce(false as any); // Staging yaml
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any); // module.yaml in staging exists
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ vi.mocked(fs.readFile).mockResolvedValueOnce('name: my-module\n' as any);
173
+
174
+ await command.run({ url: 'https://github.com/org/repo.git' });
175
+
176
+ expect(git.clone).toHaveBeenCalledWith(
177
+ 'https://github.com/org/repo.git',
178
+ expect.stringContaining('staging-'),
179
+ { depth: 1 },
180
+ );
181
+
182
+ // Should use submodule add
183
+ expect(runCommand).toHaveBeenCalledWith(
184
+ expect.stringContaining(
185
+ 'git submodule add https://github.com/org/repo.git modules/my-module',
186
+ ),
187
+ '/mock/root',
188
+ );
189
+
190
+ expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
191
+ expect(command.success).toHaveBeenCalledWith('All modules installed successfully.');
192
+ });
193
+
194
+ it('should recursively install dependencies', async () => {
195
+ // First module calls
196
+ vi.mocked(fs.pathExists)
197
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
+ .mockResolvedValueOnce(false as any)
199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
+ .mockResolvedValueOnce(true as any) // module 1 yaml exists
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ .mockResolvedValueOnce(false as any) // target dir check
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ .mockResolvedValueOnce(false as any) // target dir check
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ .mockResolvedValueOnce(false as any)
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ .mockResolvedValueOnce(true as any) // module 2 yaml exists
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ .mockResolvedValueOnce(false as any); // target dir check
211
+
212
+ vi.mocked(fs.readFile)
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ .mockResolvedValueOnce('name: parent\ndependencies:\n - gh@org/child' as any)
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
+ .mockResolvedValueOnce('name: child' as any);
217
+
218
+ await command.run({ url: 'gh@org/parent' });
219
+
220
+ // Should clone parent
221
+ expect(git.clone).toHaveBeenCalledWith(
222
+ expect.stringContaining('parent.git'),
223
+ expect.anything(),
224
+ expect.anything(),
225
+ );
226
+ // Should clone child
227
+ expect(git.clone).toHaveBeenCalledWith(
228
+ expect.stringContaining('child.git'),
229
+ expect.anything(),
230
+ expect.anything(),
231
+ );
232
+
233
+ expect(runCommand).toHaveBeenCalledTimes(3); // 2 submodules + npm install
234
+ });
235
+
236
+ it('should handle object-style dependencies', async () => {
237
+ vi.mocked(fs.pathExists)
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ .mockResolvedValueOnce(false as any)
240
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
241
+ .mockResolvedValueOnce(true as any) // module exists
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ .mockResolvedValueOnce(false as any); // target dir check
244
+
245
+ vi.mocked(fs.readFile).mockResolvedValueOnce(
246
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
+ 'name: parent\ndependencies:\n gh@org/child: main' as any,
248
+ ); // Object style
249
+
250
+ await command.run({ url: 'gh@org/parent' });
251
+
252
+ expect(git.clone).toHaveBeenCalledTimes(2); // parent + child
253
+ });
254
+
255
+ it('should detect conflicts (installed but different origin)', async () => {
256
+ vi.mocked(fs.pathExists)
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ .mockResolvedValueOnce(false as any)
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ .mockResolvedValueOnce(true as any); // staging yaml
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ vi.mocked(fs.readFile).mockResolvedValueOnce('name: conflict-mod' as any);
263
+
264
+ // Target dir check returns true (exists)
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any);
267
+
268
+ // Origin check returns different URL
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ vi.mocked(git.getRemoteUrl).mockResolvedValueOnce('https://other.com/repo.git' as any);
271
+
272
+ await command.run({ url: 'https://github.com/org/repo.git' });
273
+
274
+ expect(command.error).toHaveBeenCalledWith(
275
+ expect.stringContaining("Dependency Conflict! Module 'conflict-mod' exists but remote"),
276
+ );
277
+ });
278
+
279
+ it('should skip installation if same module/origin already exists', async () => {
280
+ vi.mocked(fs.pathExists)
281
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
282
+ .mockResolvedValueOnce(false as any)
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
+ .mockResolvedValueOnce(true as any); // staging yaml
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ vi.mocked(fs.readFile).mockResolvedValueOnce('name: existing-mod' as any);
287
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
288
+ vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any); // Exists
289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
290
+ vi.mocked(git.getRemoteUrl).mockResolvedValueOnce('https://github.com/org/repo.git' as any); // Same URL
291
+
292
+ await command.run({ url: 'https://github.com/org/repo.git' });
293
+
294
+ expect(command.info).toHaveBeenCalledWith('Module existing-mod already installed.');
295
+ // Should NOT call submodule add
296
+ expect(runCommand).not.toHaveBeenCalledWith(
297
+ expect.stringContaining('git submodule add'),
298
+ expect.anything(),
299
+ );
300
+ // But SHOULD call npm install at end
301
+ expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
302
+ });
303
+
304
+ it('should handle circular dependencies', async () => {
305
+ // Module A depends on B, B depends on A
306
+ // A
307
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
308
+ vi.mocked(fs.pathExists).mockResolvedValue(true as any); // Simplify pathExists to always true for yamls
309
+ vi.mocked(fs.readFile)
310
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
311
+ .mockResolvedValueOnce('name: mod-a\ndependencies:\n - gh@org/mod-b' as any)
312
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
+ .mockResolvedValueOnce('name: mod-b\ndependencies:\n - gh@org/mod-a' as any);
314
+
315
+ // Target dir checks (false = not installed)
316
+ // We need to carefully mock pathExists sequence or use implementation based on path
317
+ vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
318
+ if (p.includes('modules')) return false; // Not installed yet
319
+ return true; // Yaml exists
205
320
  });
206
321
 
207
- it('should detect conflicts (installed but different origin)', async () => {
208
- vi.mocked(fs.pathExists).mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any); // staging yaml
209
- vi.mocked(fs.readFile).mockResolvedValueOnce('name: conflict-mod' as any);
322
+ await command.run({ url: 'gh@org/mod-a' });
210
323
 
211
- // Target dir check returns true (exists)
212
- vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any);
213
-
214
- // Origin check returns different URL
215
- vi.mocked(git.getRemoteUrl).mockResolvedValueOnce('https://other.com/repo.git' as any);
216
-
217
- await command.run({ url: 'https://github.com/org/repo.git' });
218
-
219
- expect(command.error).toHaveBeenCalledWith(
220
- expect.stringContaining('Dependency Conflict! Module \'conflict-mod\' exists but remote')
221
- );
222
- });
223
-
224
- it('should skip installation if same module/origin already exists', async () => {
225
- vi.mocked(fs.pathExists).mockResolvedValueOnce(false as any).mockResolvedValueOnce(true as any); // staging yaml
226
- vi.mocked(fs.readFile).mockResolvedValueOnce('name: existing-mod' as any);
227
- vi.mocked(fs.pathExists).mockResolvedValueOnce(true as any); // Exists
228
- vi.mocked(git.getRemoteUrl).mockResolvedValueOnce('https://github.com/org/repo.git' as any); // Same URL
229
-
230
- await command.run({ url: 'https://github.com/org/repo.git' });
231
-
232
- expect(command.info).toHaveBeenCalledWith('Module existing-mod already installed.');
233
- // Should NOT call submodule add
234
- expect(runCommand).not.toHaveBeenCalledWith(expect.stringContaining('git submodule add'), expect.anything());
235
- // But SHOULD call npm install at end
236
- expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
237
- });
238
-
239
- it('should handle circular dependencies', async () => {
240
- // Module A depends on B, B depends on A
241
- // A
242
- vi.mocked(fs.pathExists).mockResolvedValue(true as any); // Simplify pathExists to always true for yamls
243
- vi.mocked(fs.readFile)
244
- .mockResolvedValueOnce('name: mod-a\ndependencies:\n - gh@org/mod-b' as any)
245
- .mockResolvedValueOnce('name: mod-b\ndependencies:\n - gh@org/mod-a' as any);
246
-
247
- // Target dir checks (false = not installed)
248
- // We need to carefully mock pathExists sequence or use implementation based on path
249
- vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
250
- if (p.includes('modules')) return false; // Not installed yet
251
- return true; // Yaml exists
252
- });
253
-
254
- await command.run({ url: 'gh@org/mod-a' });
255
-
256
- // Should install A and B, then see A again and skip
257
- expect(git.clone).toHaveBeenCalledTimes(2);
258
- // Should succeed
259
- expect(command.success).toHaveBeenCalled();
260
- });
324
+ // Should install A and B, then see A again and skip
325
+ expect(git.clone).toHaveBeenCalledTimes(2);
326
+ // Should succeed
327
+ expect(command.success).toHaveBeenCalled();
328
+ });
261
329
  });
262
-