@nexical/cli 0.11.7 → 0.11.9

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 (46) hide show
  1. package/dist/{chunk-LZ3YQWAR.js → chunk-OUGA4CB4.js} +15 -11
  2. package/dist/chunk-OUGA4CB4.js.map +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/commands/init.js +1 -1
  6. package/dist/src/commands/module/add.js +51 -20
  7. package/dist/src/commands/module/add.js.map +1 -1
  8. package/dist/src/commands/module/list.d.ts +1 -0
  9. package/dist/src/commands/module/list.js +55 -46
  10. package/dist/src/commands/module/list.js.map +1 -1
  11. package/dist/src/commands/module/remove.js +38 -13
  12. package/dist/src/commands/module/remove.js.map +1 -1
  13. package/dist/src/commands/module/update.js +16 -4
  14. package/dist/src/commands/module/update.js.map +1 -1
  15. package/dist/src/commands/run.js +19 -2
  16. package/dist/src/commands/run.js.map +1 -1
  17. package/dist/src/commands/setup.js +1 -1
  18. package/package.json +1 -1
  19. package/src/commands/module/add.ts +74 -31
  20. package/src/commands/module/list.ts +80 -57
  21. package/src/commands/module/remove.ts +50 -14
  22. package/src/commands/module/update.ts +19 -5
  23. package/src/commands/run.ts +21 -1
  24. package/test/e2e/lifecycle.e2e.test.ts +3 -2
  25. package/test/integration/commands/deploy.integration.test.ts +102 -0
  26. package/test/integration/commands/init.integration.test.ts +16 -1
  27. package/test/integration/commands/module.integration.test.ts +81 -55
  28. package/test/integration/commands/run.integration.test.ts +69 -74
  29. package/test/integration/commands/setup.integration.test.ts +53 -0
  30. package/test/unit/commands/deploy.test.ts +285 -0
  31. package/test/unit/commands/init.test.ts +15 -0
  32. package/test/unit/commands/module/add.test.ts +363 -254
  33. package/test/unit/commands/module/list.test.ts +100 -99
  34. package/test/unit/commands/module/remove.test.ts +143 -58
  35. package/test/unit/commands/module/update.test.ts +45 -62
  36. package/test/unit/commands/run.test.ts +16 -1
  37. package/test/unit/commands/setup.test.ts +25 -66
  38. package/test/unit/deploy/config-manager.test.ts +65 -0
  39. package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
  40. package/test/unit/deploy/providers/github.test.ts +139 -0
  41. package/test/unit/deploy/providers/railway.test.ts +328 -0
  42. package/test/unit/deploy/registry.test.ts +227 -0
  43. package/test/unit/deploy/utils.test.ts +30 -0
  44. package/test/unit/utils/command-discovery.test.ts +145 -142
  45. package/test/unit/utils/git_utils.test.ts +49 -0
  46. package/dist/chunk-LZ3YQWAR.js.map +0 -1
@@ -1,329 +1,438 @@
1
- import { runCommand } from '@nexical/cli-core';
2
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
2
  import ModuleAddCommand from '../../../../src/commands/module/add.js';
4
3
  import fs from 'fs-extra';
5
- import * as git from '../../../../src/utils/git.js';
4
+ import * as cliCore from '@nexical/cli-core';
5
+ import * as gitUtils from '../../../../src/utils/git.js';
6
+ import * as urlResolver from '../../../../src/utils/url-resolver.js';
6
7
 
8
+ vi.mock('fs-extra');
7
9
  vi.mock('@nexical/cli-core', async (importOriginal) => {
8
10
  const mod = await importOriginal<typeof import('@nexical/cli-core')>();
9
11
  return {
10
12
  ...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
13
  runCommand: vi.fn(),
20
14
  };
21
15
  });
22
- vi.mock('fs-extra');
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(),
35
- }));
16
+ vi.mock('../../../../src/utils/git.js');
17
+ vi.mock('../../../../src/utils/url-resolver.js');
36
18
 
37
19
  describe('ModuleAddCommand', () => {
38
20
  let command: ModuleAddCommand;
21
+ const projectRoot = '/mock/project/root';
39
22
 
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;
56
- });
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';
23
+ beforeEach(() => {
24
+ vi.resetAllMocks();
25
+
26
+ // Mock logger
27
+ vi.spyOn(cliCore.logger, 'debug').mockImplementation(() => {});
28
+ vi.spyOn(cliCore.logger, 'warn').mockImplementation(() => {});
29
+ vi.spyOn(cliCore.logger, 'info').mockImplementation(() => {});
30
+
31
+ command = new ModuleAddCommand({} as unknown as any, { rootDir: projectRoot });
32
+ (command as unknown as { projectRoot: string }).projectRoot = projectRoot;
33
+
34
+ vi.spyOn(command, 'info').mockImplementation(() => {});
35
+ vi.spyOn(command, 'success').mockImplementation(() => {});
36
+ vi.spyOn(command, 'error').mockImplementation(() => {});
37
+ vi.spyOn(command, 'warn').mockImplementation(() => {});
38
+
39
+ (urlResolver.resolveGitUrl as unknown as { mockImplementation: any }).mockImplementation(
40
+ (url: string) => url,
41
+ );
42
+ (fs.ensureDir as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
43
+ (fs.remove as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
44
+ (fs.writeFile as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
45
+ (gitUtils.clone as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
46
+ (cliCore.runCommand as unknown as { mockResolvedValue: any }).mockResolvedValue(undefined);
70
47
  });
71
48
 
72
49
  afterEach(() => {
73
- vi.resetAllMocks();
50
+ vi.restoreAllMocks();
74
51
  });
75
52
 
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
- });
53
+ it('should install a backend module correctly', async () => {
54
+ const repoUrl = 'https://github.com/org/repo.git';
55
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
56
+ const pStr = p.toString();
57
+ if (pStr.endsWith('module.yaml')) return true;
58
+ if (pStr.endsWith('models.yaml')) return true;
59
+ if (pStr.includes('nexical.yaml')) return true;
60
+ return false;
61
+ });
62
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
63
+ const pStr = p.toString();
64
+ if (pStr.endsWith('module.yaml')) return 'name: my-backend-module\n';
65
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
66
+ return '';
67
+ });
82
68
 
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,
69
+ await command.run({ url: repoUrl });
70
+
71
+ expect(gitUtils.clone).toHaveBeenCalled();
72
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
73
+ expect.stringContaining(
74
+ `git submodule add ${repoUrl} apps/backend/modules/my-backend-module`,
75
+ ),
76
+ projectRoot,
95
77
  );
96
78
  });
97
79
 
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
80
+ it('should install a frontend module correctly', async () => {
81
+ const repoUrl = 'https://github.com/org/ui-repo.git';
82
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
83
+ const pStr = p.toString();
84
+ if (pStr.endsWith('module.yaml')) return true;
85
+ if (pStr.endsWith('ui.yaml')) return true;
86
+ if (pStr.includes('nexical.yaml')) return true;
87
+ return false;
88
+ });
89
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
90
+ const pStr = p.toString();
91
+ if (pStr.endsWith('module.yaml')) return 'name: my-frontend-module\n';
92
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
93
+ return '';
94
+ });
104
95
 
105
- await command.run({ url: 'gh@org/repo.git' }); // With .git suffix
96
+ await command.run({ url: repoUrl });
106
97
 
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 }),
98
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
99
+ expect.stringContaining(
100
+ `git submodule add ${repoUrl} apps/frontend/modules/my-frontend-module`,
101
+ ),
102
+ projectRoot,
112
103
  );
113
104
  });
114
105
 
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 });
106
+ it('should error if no url provided', async () => {
107
+ await command.run({ url: '' });
118
108
  expect(command.error).toHaveBeenCalledWith('Please specify a repository URL.');
119
109
  });
120
110
 
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'));
111
+ it('should handle module name from package.json', async () => {
112
+ const repoUrl = 'https://github.com/org/pkg-repo.git';
113
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
114
+ const pStr = p.toString();
115
+ if (pStr.endsWith('package.json')) return true;
116
+ if (pStr.endsWith('models.yaml')) return true;
117
+ if (pStr.includes('nexical.yaml')) return true;
118
+ return false;
119
+ });
120
+ (fs.readJson as unknown as { mockResolvedValue: any }).mockResolvedValue({
121
+ name: '@modules/pkg-mod',
122
+ });
123
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('modules: {}');
124
+
125
+ await command.run({ url: repoUrl });
126
+
127
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
128
+ expect.stringContaining('apps/backend/modules/pkg-mod'),
129
+ projectRoot,
130
+ );
126
131
  });
127
132
 
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
133
+ it('should fallback to git repo name if no config found', async () => {
134
+ const repoUrl = 'https://github.com/org/fallback-mod.git';
135
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
136
+ const pStr = p.toString();
137
+ if (pStr.endsWith('models.yaml')) return true;
138
+ if (pStr.includes('nexical.yaml')) return true;
139
+ return false;
140
+ });
141
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('modules: {}');
131
142
 
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
143
+ await command.run({ url: repoUrl });
137
144
 
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'),
145
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
146
+ expect.stringContaining('apps/backend/modules/fallback-mod'),
147
+ projectRoot,
141
148
  );
142
149
  });
143
150
 
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"),
151
+ it('should detect frontend module via src/components', async () => {
152
+ const repoUrl = 'https://github.com/org/comp-mod.git';
153
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
154
+ const pStr = p.toString();
155
+ if (pStr.includes('src/components')) return true;
156
+ if (pStr.includes('nexical.yaml')) return true;
157
+ return false;
158
+ });
159
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('modules: {}');
160
+
161
+ await command.run({ url: repoUrl });
162
+
163
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
164
+ expect.stringContaining('apps/frontend/modules/comp-mod'),
165
+ projectRoot,
155
166
  );
156
167
  });
157
168
 
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
- );
169
+ it('should skip already visited modules', async () => {
170
+ const repoUrl = 'https://github.com/org/cycle.git';
171
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
172
+ const pStr = p.toString();
173
+ if (pStr.includes('staging') && pStr.endsWith('module.yaml')) return true;
174
+ if (pStr.includes('nexical.yaml')) return true;
175
+ return false;
176
+ });
177
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
178
+ const pStr = p.toString();
179
+ if (pStr.endsWith('module.yaml'))
180
+ return 'name: cycle\ndependencies:\n - https://github.com/org/cycle.git';
181
+ return 'modules: {}';
182
+ });
183
+
184
+ await command.run({ url: repoUrl });
185
+
186
+ expect(cliCore.logger.debug).toHaveBeenCalledWith(expect.stringContaining('Already visited'));
164
187
  });
165
188
 
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);
189
+ it('should handle dependency recursion', async () => {
190
+ const rootUrl = 'https://github.com/org/root.git';
191
+ const depUrl = 'https://github.com/org/dep.git';
192
+ let callCount = 0;
193
+
194
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
195
+ const pStr = p.toString();
196
+ if (pStr.includes('staging') && pStr.endsWith('module.yaml')) return true;
197
+ if (pStr.includes('nexical.yaml')) return true;
198
+ return false;
199
+ });
200
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
201
+ const pStr = p.toString();
202
+ if (pStr.includes('staging') && pStr.endsWith('module.yaml')) {
203
+ if (callCount === 0) {
204
+ callCount++;
205
+ return `name: root\ndependencies:\n - ${depUrl}`;
206
+ } else {
207
+ return 'name: dep';
208
+ }
209
+ }
210
+ return 'modules: {}';
211
+ });
212
+
213
+ await command.run({ url: rootUrl });
173
214
 
174
- await command.run({ url: 'https://github.com/org/repo.git' });
215
+ expect(cliCore.runCommand).toHaveBeenCalledWith(expect.stringContaining('root'), projectRoot);
216
+ expect(cliCore.runCommand).toHaveBeenCalledWith(expect.stringContaining('dep'), projectRoot);
217
+ });
175
218
 
176
- expect(git.clone).toHaveBeenCalledWith(
177
- 'https://github.com/org/repo.git',
178
- expect.stringContaining('staging-'),
179
- { depth: 1 },
219
+ it('should throw error on dependency conflict', async () => {
220
+ const repoUrl = 'https://github.com/org/conflict.git';
221
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
222
+ const pStr = p.toString();
223
+ if (pStr.includes('apps/backend/modules/conflict')) return true;
224
+ if (pStr.includes('staging') && pStr.endsWith('module.yaml')) return true;
225
+ if (pStr.includes('nexical.yaml')) return true;
226
+ return false;
227
+ });
228
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
229
+ const pStr = p.toString();
230
+ if (pStr.endsWith('module.yaml')) return 'name: conflict';
231
+ return 'modules: {}';
232
+ });
233
+ (gitUtils.getRemoteUrl as unknown as { mockResolvedValue: any }).mockResolvedValue(
234
+ 'https://github.com/org/other.git',
180
235
  );
181
236
 
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
- );
237
+ await command.run({ url: repoUrl });
189
238
 
190
- expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
191
- expect(command.success).toHaveBeenCalledWith('All modules installed successfully.');
239
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Dependency Conflict'));
192
240
  });
193
241
 
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(),
242
+ it('should handle missing nexical.yaml', async () => {
243
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
244
+ const pStr = p.toString();
245
+ if (pStr.includes('nexical.yaml')) return false;
246
+ if (pStr.endsWith('module.yaml')) return true;
247
+ return false;
248
+ });
249
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('name: mod');
250
+
251
+ await command.run({ url: 'https://github.com/org/mod.git' });
252
+
253
+ expect(cliCore.logger.warn).toHaveBeenCalledWith(
254
+ expect.stringContaining('nexical.yaml not found'),
231
255
  );
256
+ });
232
257
 
233
- expect(runCommand).toHaveBeenCalledTimes(3); // 2 submodules + npm install
258
+ it('should handle error during run', async () => {
259
+ (gitUtils.clone as unknown as { mockRejectedValue: any }).mockRejectedValue(
260
+ new Error('Git fail'),
261
+ );
262
+ await command.run({ url: 'https://git.com/fail' });
263
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Git fail'));
234
264
  });
235
265
 
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
266
+ it('should handle non-Error objects in catch', async () => {
267
+ (gitUtils.clone as unknown as { mockRejectedValue: any }).mockRejectedValue('String error');
268
+ await command.run({ url: 'https://git.com/fail' });
269
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('String error'));
270
+ });
244
271
 
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
272
+ it('should migrate modules array to object', async () => {
273
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
274
+ const pStr = p.toString();
275
+ if (pStr.endsWith('module.yaml')) return true;
276
+ if (pStr.endsWith('models.yaml')) return true; // BACKEND
277
+ if (pStr.includes('nexical.yaml')) return true;
278
+ return false;
279
+ });
280
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
281
+ const pStr = p.toString();
282
+ if (pStr.endsWith('module.yaml')) return 'name: mod\n';
283
+ if (pStr.includes('nexical.yaml')) return 'modules:\n - old-mod';
284
+ return '';
285
+ });
249
286
 
250
- await command.run({ url: 'gh@org/parent' });
287
+ await command.run({ url: 'https://github.com/org/mod.git' });
251
288
 
252
- expect(git.clone).toHaveBeenCalledTimes(2); // parent + child
289
+ expect(fs.writeFile).toHaveBeenCalledWith(
290
+ expect.stringContaining('nexical.yaml'),
291
+ expect.stringContaining('backend:\n - old-mod\n - mod'),
292
+ );
253
293
  });
254
294
 
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);
295
+ it('should handle error during nexical.yaml update', async () => {
296
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
297
+ const pStr = p.toString();
298
+ if (pStr.endsWith('module.yaml')) return true;
299
+ if (pStr.endsWith('models.yaml')) return true; // BACKEND
300
+ if (pStr.includes('nexical.yaml')) return true;
301
+ return false;
302
+ });
303
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
304
+ const pStr = p.toString();
305
+ if (pStr.endsWith('module.yaml')) return 'name: mod\n';
306
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
307
+ return '';
308
+ });
309
+ (fs.writeFile as any).mockImplementation((p: string) => {
310
+ const pStr = p.toString();
311
+ if (pStr.includes('nexical.yaml')) throw new Error('Write fail');
312
+ });
313
+
314
+ await command.run({ url: 'https://github.com/org/mod.git' });
315
+ expect(cliCore.logger.warn).toHaveBeenCalledWith(
316
+ expect.stringContaining('Failed to update nexical.yaml: Write fail'),
317
+ );
318
+ });
263
319
 
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);
320
+ it('should handle non-Error exception during nexical.yaml update', async () => {
321
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
322
+ const pStr = p.toString();
323
+ if (pStr.endsWith('module.yaml')) return true;
324
+ if (pStr.endsWith('models.yaml')) return true; // BACKEND
325
+ if (pStr.includes('nexical.yaml')) return true;
326
+ return false;
327
+ });
328
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
329
+ const pStr = p.toString();
330
+ if (pStr.endsWith('module.yaml')) return 'name: mod\n';
331
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
332
+ return '';
333
+ });
334
+ (fs.writeFile as any).mockImplementation((p: string) => {
335
+ const pStr = p.toString();
336
+ if (pStr.includes('nexical.yaml')) throw 'String fail';
337
+ });
267
338
 
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);
339
+ await command.run({ url: 'https://github.com/org/mod.git' });
340
+ expect(cliCore.logger.warn).toHaveBeenCalledWith(
341
+ expect.stringContaining('Failed to update nexical.yaml: String fail'),
342
+ );
343
+ });
271
344
 
272
- await command.run({ url: 'https://github.com/org/repo.git' });
345
+ it('should handle .yml instead of .yaml', async () => {
346
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
347
+ const pStr = p.toString();
348
+ if (pStr.endsWith('module.yml')) return true;
349
+ if (pStr.endsWith('module.yaml')) return false;
350
+ if (pStr.endsWith('models.yaml')) return true;
351
+ if (pStr.includes('nexical.yaml')) return true;
352
+ return false;
353
+ });
354
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
355
+ const pStr = p.toString();
356
+ if (pStr.endsWith('module.yml')) return 'name: yml-mod\n';
357
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
358
+ return '';
359
+ });
273
360
 
274
- expect(command.error).toHaveBeenCalledWith(
275
- expect.stringContaining("Dependency Conflict! Module 'conflict-mod' exists but remote"),
361
+ await command.run({ url: 'https://github.com/org/yml.git' });
362
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
363
+ expect.stringContaining('yml-mod'),
364
+ projectRoot,
276
365
  );
277
366
  });
278
367
 
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(),
368
+ it('should handle dependencies as object', async () => {
369
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
370
+ const pStr = p.toString();
371
+ if (
372
+ pStr.endsWith('module.yaml') ||
373
+ pStr.endsWith('models.yaml') ||
374
+ pStr.includes('nexical.yaml')
375
+ )
376
+ return true;
377
+ return false;
378
+ });
379
+ let firstCall = true;
380
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
381
+ const pStr = p.toString();
382
+ if (pStr.endsWith('module.yaml')) {
383
+ if (firstCall) {
384
+ firstCall = false;
385
+ return 'name: obj-mod\ndependencies:\n https://github.com/org/dep.git: latest';
386
+ }
387
+ return 'name: dep-mod';
388
+ }
389
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
390
+ return '';
391
+ });
392
+
393
+ await command.run({ url: 'https://github.com/org/obj.git' });
394
+ expect(cliCore.runCommand).toHaveBeenCalledWith(
395
+ expect.stringContaining('dep-mod'),
396
+ projectRoot,
299
397
  );
300
- // But SHOULD call npm install at end
301
- expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
302
398
  });
303
399
 
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
400
+ it('should handle already installed module with matching remote', async () => {
401
+ (fs.pathExists as any).mockImplementation((p: string) => true);
402
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
403
+ const pStr = p.toString();
404
+ if (pStr.endsWith('module.yaml')) return 'name: existing-mod\n';
405
+ if (pStr.includes('nexical.yaml')) return 'modules: {}';
406
+ return '';
407
+ });
408
+ (gitUtils.getRemoteUrl as any).mockResolvedValue('https://github.com/org/existing-mod.git');
409
+
410
+ await command.run({ url: 'https://github.com/org/existing-mod.git' });
411
+ expect(command.info).toHaveBeenCalledWith(expect.stringContaining('already installed'));
412
+ });
413
+ it('should initialize modules object if missing from config', async () => {
414
+ (fs.pathExists as unknown as { mockResolvedValue: any }).mockResolvedValue(true);
415
+ (fs.readFile as unknown as { mockResolvedValue: any }).mockResolvedValue('key: value');
416
+
417
+ // Mock getModuleConfig via urlResolver logic or direct fs mocks if urlResolver calls fs
418
+ // command.installModule -> ...
419
+ (fs.pathExists as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
420
+ if (p.includes('nexical.yaml')) return true;
421
+ if (p.endsWith('module.yaml')) return true;
422
+ return false;
423
+ });
424
+ (fs.readFile as unknown as { mockImplementation: any }).mockImplementation((p: string) => {
425
+ if (p.includes('nexical.yaml')) return '';
426
+ if (p.endsWith('module.yaml')) return 'name: new-mod\n';
427
+ return '';
320
428
  });
321
429
 
322
- await command.run({ url: 'gh@org/mod-a' });
430
+ await command.run({ url: 'http://example.com/mod.git' });
323
431
 
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();
432
+ expect(fs.writeFile).toHaveBeenCalled();
433
+ const writeCall = (fs.writeFile as any).mock.calls[0];
434
+ expect(writeCall[1]).toContain('modules:');
435
+ expect(writeCall[1]).toContain('backend:');
436
+ expect(writeCall[1]).toContain('new-mod');
328
437
  });
329
438
  });