@nexical/cli 0.1.6 → 0.10.0

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 (236) hide show
  1. package/.github/workflows/deploy.yml +3 -3
  2. package/README.md +317 -104
  3. package/dist/chunk-JYASTIIW.js +42 -0
  4. package/dist/chunk-JYASTIIW.js.map +1 -0
  5. package/dist/chunk-LZ3YQWAR.js +2204 -0
  6. package/dist/chunk-LZ3YQWAR.js.map +1 -0
  7. package/dist/chunk-OKXOCNXP.js +105 -0
  8. package/dist/chunk-OKXOCNXP.js.map +1 -0
  9. package/dist/chunk-OYFWMYPG.js +52 -0
  10. package/dist/chunk-OYFWMYPG.js.map +1 -0
  11. package/dist/chunk-WKERTCM6.js +74 -0
  12. package/dist/chunk-WKERTCM6.js.map +1 -0
  13. package/dist/index.js +33 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/src/commands/init.d.ts +11 -0
  16. package/dist/src/commands/init.js +88 -0
  17. package/dist/src/commands/init.js.map +1 -0
  18. package/dist/src/commands/module/add.d.ts +14 -0
  19. package/dist/src/commands/module/add.js +136 -0
  20. package/dist/src/commands/module/add.js.map +1 -0
  21. package/dist/src/commands/module/list.d.ts +10 -0
  22. package/dist/src/commands/module/list.js +73 -0
  23. package/dist/src/commands/module/list.js.map +1 -0
  24. package/dist/src/commands/module/remove.d.ts +12 -0
  25. package/dist/src/commands/module/remove.js +71 -0
  26. package/dist/src/commands/module/remove.js.map +1 -0
  27. package/dist/src/commands/module/update.d.ts +11 -0
  28. package/dist/src/commands/module/update.js +52 -0
  29. package/dist/src/commands/module/update.js.map +1 -0
  30. package/dist/src/commands/run.d.ts +11 -0
  31. package/dist/src/commands/run.js +93 -0
  32. package/dist/src/commands/run.js.map +1 -0
  33. package/dist/src/utils/discovery.d.ts +13 -0
  34. package/dist/src/utils/discovery.js +9 -0
  35. package/dist/src/utils/git.d.ts +16 -0
  36. package/dist/src/utils/git.js +29 -0
  37. package/dist/src/utils/git.js.map +1 -0
  38. package/dist/src/utils/url-resolver.d.ts +15 -0
  39. package/dist/src/utils/url-resolver.js +9 -0
  40. package/dist/src/utils/url-resolver.js.map +1 -0
  41. package/index.ts +29 -5
  42. package/package.json +32 -30
  43. package/src/commands/init.ts +85 -0
  44. package/src/commands/module/add.ts +169 -0
  45. package/src/commands/module/list.ts +69 -0
  46. package/src/commands/module/remove.ts +74 -0
  47. package/src/commands/module/update.ts +50 -0
  48. package/src/commands/run.ts +98 -0
  49. package/src/utils/discovery.ts +134 -0
  50. package/src/utils/git.ts +65 -0
  51. package/src/utils/url-resolver.ts +57 -0
  52. package/test/e2e/lifecycle.e2e.test.ts +152 -0
  53. package/test/integration/commands/init.integration.test.ts +82 -0
  54. package/test/integration/commands/module.integration.test.ts +144 -0
  55. package/test/integration/commands/run.integration.test.ts +90 -0
  56. package/test/integration/utils/command-loading.integration.test.ts +80 -0
  57. package/test/unit/commands/init.test.ts +153 -0
  58. package/test/unit/commands/module/add.test.ts +262 -0
  59. package/test/unit/commands/module/list.test.ts +115 -0
  60. package/test/unit/commands/module/remove.test.ts +89 -0
  61. package/test/unit/commands/module/update.test.ts +91 -0
  62. package/test/unit/commands/run.test.ts +252 -0
  63. package/test/unit/utils/command-discovery.test.ts +176 -0
  64. package/test/unit/utils/git.test.ts +152 -0
  65. package/test/unit/utils/integration-helpers.test.ts +72 -0
  66. package/test/unit/utils/url-resolver.test.ts +39 -0
  67. package/test/utils/integration-helpers.ts +66 -0
  68. package/vitest.e2e.config.ts +0 -1
  69. package/dist/chunk-JDRAVUKK.js +0 -48
  70. package/dist/chunk-JDRAVUKK.js.map +0 -1
  71. package/dist/src/commands/admin/create-user.d.ts +0 -15
  72. package/dist/src/commands/admin/create-user.js +0 -49
  73. package/dist/src/commands/admin/create-user.js.map +0 -1
  74. package/dist/src/commands/branch/create.d.ts +0 -19
  75. package/dist/src/commands/branch/create.js +0 -59
  76. package/dist/src/commands/branch/create.js.map +0 -1
  77. package/dist/src/commands/branch/delete.d.ts +0 -15
  78. package/dist/src/commands/branch/delete.js +0 -50
  79. package/dist/src/commands/branch/delete.js.map +0 -1
  80. package/dist/src/commands/branch/get.d.ts +0 -15
  81. package/dist/src/commands/branch/get.js +0 -53
  82. package/dist/src/commands/branch/get.js.map +0 -1
  83. package/dist/src/commands/branch/list.d.ts +0 -15
  84. package/dist/src/commands/branch/list.js +0 -51
  85. package/dist/src/commands/branch/list.js.map +0 -1
  86. package/dist/src/commands/job/get.d.ts +0 -15
  87. package/dist/src/commands/job/get.js +0 -62
  88. package/dist/src/commands/job/get.js.map +0 -1
  89. package/dist/src/commands/job/list.d.ts +0 -15
  90. package/dist/src/commands/job/list.js +0 -57
  91. package/dist/src/commands/job/list.js.map +0 -1
  92. package/dist/src/commands/job/logs.d.ts +0 -15
  93. package/dist/src/commands/job/logs.js +0 -67
  94. package/dist/src/commands/job/logs.js.map +0 -1
  95. package/dist/src/commands/job/trigger.d.ts +0 -19
  96. package/dist/src/commands/job/trigger.js +0 -74
  97. package/dist/src/commands/job/trigger.js.map +0 -1
  98. package/dist/src/commands/login.d.ts +0 -8
  99. package/dist/src/commands/login.js +0 -31
  100. package/dist/src/commands/login.js.map +0 -1
  101. package/dist/src/commands/project/create.d.ts +0 -24
  102. package/dist/src/commands/project/create.js +0 -63
  103. package/dist/src/commands/project/create.js.map +0 -1
  104. package/dist/src/commands/project/delete.d.ts +0 -20
  105. package/dist/src/commands/project/delete.js +0 -58
  106. package/dist/src/commands/project/delete.js.map +0 -1
  107. package/dist/src/commands/project/get.d.ts +0 -15
  108. package/dist/src/commands/project/get.js +0 -49
  109. package/dist/src/commands/project/get.js.map +0 -1
  110. package/dist/src/commands/project/list.d.ts +0 -15
  111. package/dist/src/commands/project/list.js +0 -45
  112. package/dist/src/commands/project/list.js.map +0 -1
  113. package/dist/src/commands/project/update.d.ts +0 -19
  114. package/dist/src/commands/project/update.js +0 -66
  115. package/dist/src/commands/project/update.js.map +0 -1
  116. package/dist/src/commands/team/create.d.ts +0 -19
  117. package/dist/src/commands/team/create.js +0 -45
  118. package/dist/src/commands/team/create.js.map +0 -1
  119. package/dist/src/commands/team/delete.d.ts +0 -20
  120. package/dist/src/commands/team/delete.js +0 -52
  121. package/dist/src/commands/team/delete.js.map +0 -1
  122. package/dist/src/commands/team/get.d.ts +0 -15
  123. package/dist/src/commands/team/get.js +0 -42
  124. package/dist/src/commands/team/get.js.map +0 -1
  125. package/dist/src/commands/team/list.d.ts +0 -8
  126. package/dist/src/commands/team/list.js +0 -30
  127. package/dist/src/commands/team/list.js.map +0 -1
  128. package/dist/src/commands/team/member/invite.d.ts +0 -20
  129. package/dist/src/commands/team/member/invite.js +0 -54
  130. package/dist/src/commands/team/member/invite.js.map +0 -1
  131. package/dist/src/commands/team/member/remove.d.ts +0 -15
  132. package/dist/src/commands/team/member/remove.js +0 -43
  133. package/dist/src/commands/team/member/remove.js.map +0 -1
  134. package/dist/src/commands/team/update.d.ts +0 -19
  135. package/dist/src/commands/team/update.js +0 -55
  136. package/dist/src/commands/team/update.js.map +0 -1
  137. package/dist/src/commands/token/generate.d.ts +0 -19
  138. package/dist/src/commands/token/generate.js +0 -48
  139. package/dist/src/commands/token/generate.js.map +0 -1
  140. package/dist/src/commands/token/list.d.ts +0 -8
  141. package/dist/src/commands/token/list.js +0 -31
  142. package/dist/src/commands/token/list.js.map +0 -1
  143. package/dist/src/commands/token/revoke.d.ts +0 -15
  144. package/dist/src/commands/token/revoke.js +0 -38
  145. package/dist/src/commands/token/revoke.js.map +0 -1
  146. package/dist/src/commands/whoami.d.ts +0 -8
  147. package/dist/src/commands/whoami.js +0 -26
  148. package/dist/src/commands/whoami.js.map +0 -1
  149. package/dist/src/utils/nexical-client.d.ts +0 -10
  150. package/dist/src/utils/nexical-client.js +0 -12
  151. package/src/commands/admin/create-user.ts +0 -46
  152. package/src/commands/branch/create.ts +0 -57
  153. package/src/commands/branch/delete.ts +0 -47
  154. package/src/commands/branch/get.ts +0 -50
  155. package/src/commands/branch/list.ts +0 -50
  156. package/src/commands/job/get.ts +0 -59
  157. package/src/commands/job/list.ts +0 -56
  158. package/src/commands/job/logs.ts +0 -67
  159. package/src/commands/job/trigger.ts +0 -73
  160. package/src/commands/login.ts +0 -31
  161. package/src/commands/project/create.ts +0 -61
  162. package/src/commands/project/delete.ts +0 -56
  163. package/src/commands/project/get.ts +0 -46
  164. package/src/commands/project/list.ts +0 -44
  165. package/src/commands/project/update.ts +0 -63
  166. package/src/commands/team/create.ts +0 -43
  167. package/src/commands/team/delete.ts +0 -50
  168. package/src/commands/team/get.ts +0 -39
  169. package/src/commands/team/list.ts +0 -26
  170. package/src/commands/team/member/invite.ts +0 -56
  171. package/src/commands/team/member/remove.ts +0 -40
  172. package/src/commands/team/update.ts +0 -53
  173. package/src/commands/token/generate.ts +0 -45
  174. package/src/commands/token/list.ts +0 -27
  175. package/src/commands/token/revoke.ts +0 -35
  176. package/src/commands/whoami.ts +0 -21
  177. package/src/utils/nexical-client.ts +0 -47
  178. package/test/e2e/auth.e2e.test.ts +0 -46
  179. package/test/e2e/job-workflow.e2e.test.ts +0 -33
  180. package/test/e2e/project-lifecycle.e2e.test.ts +0 -48
  181. package/test/e2e/setup.ts +0 -237
  182. package/test/e2e/utils.ts +0 -33
  183. package/test/integration/commands/admin/create-user.test.ts +0 -51
  184. package/test/integration/commands/branch/create.test.ts +0 -51
  185. package/test/integration/commands/branch/delete.test.ts +0 -43
  186. package/test/integration/commands/branch/get.test.ts +0 -49
  187. package/test/integration/commands/branch/list.test.ts +0 -47
  188. package/test/integration/commands/job/get.test.ts +0 -54
  189. package/test/integration/commands/job/list.test.ts +0 -47
  190. package/test/integration/commands/job/logs.test.ts +0 -47
  191. package/test/integration/commands/job/trigger.test.ts +0 -57
  192. package/test/integration/commands/login.test.ts +0 -62
  193. package/test/integration/commands/project/create.test.ts +0 -53
  194. package/test/integration/commands/project/delete.test.ts +0 -43
  195. package/test/integration/commands/project/get.test.ts +0 -51
  196. package/test/integration/commands/project/list.test.ts +0 -47
  197. package/test/integration/commands/project/update.test.ts +0 -53
  198. package/test/integration/commands/team/create.test.ts +0 -53
  199. package/test/integration/commands/team/delete.test.ts +0 -43
  200. package/test/integration/commands/team/get.test.ts +0 -50
  201. package/test/integration/commands/team/list.test.ts +0 -47
  202. package/test/integration/commands/team/member/invite.test.ts +0 -46
  203. package/test/integration/commands/team/member/remove.test.ts +0 -43
  204. package/test/integration/commands/team/update.test.ts +0 -50
  205. package/test/integration/commands/token/generate.test.ts +0 -51
  206. package/test/integration/commands/token/list.test.ts +0 -47
  207. package/test/integration/commands/token/revoke.test.ts +0 -43
  208. package/test/integration/commands/whoami.test.ts +0 -49
  209. package/test/unit/commands/admin/create-user.test.ts +0 -51
  210. package/test/unit/commands/branch/create.test.ts +0 -57
  211. package/test/unit/commands/branch/delete.test.ts +0 -49
  212. package/test/unit/commands/branch/get.test.ts +0 -67
  213. package/test/unit/commands/branch/list.test.ts +0 -62
  214. package/test/unit/commands/job/get.test.ts +0 -76
  215. package/test/unit/commands/job/list.test.ts +0 -62
  216. package/test/unit/commands/job/logs.test.ts +0 -60
  217. package/test/unit/commands/job/trigger.test.ts +0 -75
  218. package/test/unit/commands/login.test.ts +0 -64
  219. package/test/unit/commands/project/create.test.ts +0 -64
  220. package/test/unit/commands/project/delete.test.ts +0 -72
  221. package/test/unit/commands/project/get.test.ts +0 -73
  222. package/test/unit/commands/project/list.test.ts +0 -62
  223. package/test/unit/commands/project/update.test.ts +0 -58
  224. package/test/unit/commands/team/create.test.ts +0 -68
  225. package/test/unit/commands/team/delete.test.ts +0 -71
  226. package/test/unit/commands/team/get.test.ts +0 -70
  227. package/test/unit/commands/team/list.test.ts +0 -56
  228. package/test/unit/commands/team/member/invite.test.ts +0 -52
  229. package/test/unit/commands/team/member/remove.test.ts +0 -49
  230. package/test/unit/commands/team/update.test.ts +0 -63
  231. package/test/unit/commands/token/generate.test.ts +0 -65
  232. package/test/unit/commands/token/list.test.ts +0 -58
  233. package/test/unit/commands/token/revoke.test.ts +0 -49
  234. package/test/unit/commands/whoami.test.ts +0 -49
  235. package/test/unit/utils/nexical-client.test.ts +0 -113
  236. /package/dist/src/utils/{nexical-client.js.map → discovery.js.map} +0 -0
@@ -0,0 +1,153 @@
1
+ import { logger, runCommand } from '@nexical/cli-core';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import InitCommand from '../../../src/commands/init.js';
4
+ import * as git from '../../../src/utils/git.js';
5
+ import fs from 'fs-extra';
6
+
7
+ vi.mock('@nexical/cli-core', async (importOriginal) => {
8
+ const mod = await importOriginal<typeof import('@nexical/cli-core')>();
9
+ return {
10
+ ...mod,
11
+ runCommand: vi.fn(),
12
+ logger: { code: vi.fn(), debug: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn(), warn: vi.fn() }
13
+ }
14
+ });
15
+
16
+ vi.mock('../../../src/utils/git.js', () => ({
17
+ clone: vi.fn(),
18
+ updateSubmodules: vi.fn(),
19
+ checkoutOrphan: vi.fn(),
20
+ addAll: vi.fn(),
21
+ commit: vi.fn(),
22
+ deleteBranch: vi.fn(),
23
+ renameBranch: vi.fn(),
24
+ removeRemote: vi.fn(),
25
+ branchExists: vi.fn(),
26
+ renameRemote: vi.fn(),
27
+ getRemoteUrl: vi.fn()
28
+ }));
29
+
30
+ vi.mock('fs-extra');
31
+
32
+ describe('InitCommand', () => {
33
+ let command: InitCommand;
34
+ // Spy on process.exit but rely on catching the error if it throws (default vitest behavior)
35
+ // or mock it to throw a custom error we can check.
36
+ let mockExit: any;
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ command = new InitCommand({});
41
+ vi.spyOn(command, 'error').mockImplementation((() => { }) as any);
42
+ vi.spyOn(command, 'info').mockImplementation((() => { }) as any);
43
+ vi.spyOn(command, 'success').mockImplementation((() => { }) as any);
44
+
45
+ // Default fs mocks
46
+ vi.mocked(fs.pathExists as any).mockResolvedValue(false); // Target not exist
47
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
48
+ vi.mocked(fs.readdir).mockResolvedValue([] as any);
49
+ vi.mocked(fs.copy).mockResolvedValue(undefined);
50
+ vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
51
+
52
+ // Mock process.exit to throw a known error so we can stop execution and verify it
53
+ mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => {
54
+ throw new Error(`Process.exit(${code})`);
55
+ });
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.resetAllMocks();
60
+ });
61
+
62
+ it('should have correct metadata', () => {
63
+ expect(InitCommand.description).toBeDefined();
64
+ expect(InitCommand.args).toBeDefined();
65
+ expect(InitCommand.requiresProject).toBe(false);
66
+ });
67
+
68
+ it('should initialize project with default repo', async () => {
69
+ const targetDir = 'new-project';
70
+ await command.run({ directory: targetDir, repo: 'https://default.com/repo' });
71
+
72
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining(targetDir), { recursive: true });
73
+
74
+ // Clone
75
+ expect(git.clone).toHaveBeenCalledWith('https://default.com/repo.git', expect.stringContaining(targetDir), { recursive: true });
76
+
77
+ // Submodules
78
+ expect(git.updateSubmodules).toHaveBeenCalledWith(expect.stringContaining(targetDir));
79
+
80
+ // Npm install
81
+ expect(runCommand).toHaveBeenCalledWith(
82
+ 'npm install',
83
+ expect.stringContaining(targetDir)
84
+ );
85
+
86
+ // Remote rename
87
+ expect(git.renameRemote).toHaveBeenCalledWith('origin', 'upstream', expect.stringContaining(targetDir));
88
+
89
+ // Version and Config creation
90
+ expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('nexical.yaml'), expect.stringContaining('name: new-project'));
91
+ expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('VERSION'), '0.1.0');
92
+
93
+ expect(git.addAll).toHaveBeenCalledWith(expect.stringContaining(targetDir));
94
+ expect(git.commit).toHaveBeenCalledWith('Initial site commit', expect.stringContaining(targetDir));
95
+
96
+ expect(command.success).toHaveBeenCalledWith(expect.stringContaining('successfully'));
97
+ });
98
+
99
+ it('should skip version and config creation if they already exist', async () => {
100
+ const targetDir = 'existing-files';
101
+ vi.mocked(fs.pathExists as any).mockImplementation(async (p: string) => {
102
+ if (p.includes('nexical.yaml')) return true;
103
+ if (p.includes('VERSION')) return true;
104
+ return false;
105
+ });
106
+
107
+ await command.run({ directory: targetDir, repo: 'foo' });
108
+
109
+ expect(fs.writeFile).not.toHaveBeenCalledWith(expect.stringContaining('nexical.yaml'), expect.anything());
110
+ expect(fs.writeFile).not.toHaveBeenCalledWith(expect.stringContaining('VERSION'), expect.anything());
111
+ });
112
+
113
+ it('should handle gh@ syntax', async () => {
114
+ const targetDir = 'gh-project';
115
+ await command.run({ directory: targetDir, repo: 'gh@nexical/nexical-starter' });
116
+
117
+ expect(git.clone).toHaveBeenCalledWith(
118
+ 'https://github.com/nexical/nexical-starter.git',
119
+ expect.stringContaining(targetDir),
120
+ { recursive: true }
121
+ );
122
+ });
123
+
124
+ it('should proceed if directory exists but is empty', async () => {
125
+ vi.mocked(fs.pathExists as any).mockResolvedValue(true);
126
+ vi.mocked(fs.readdir).mockResolvedValue([] as any);
127
+
128
+ await command.run({ directory: 'empty-dir', repo: 'foo' });
129
+
130
+ expect(fs.mkdir).not.toHaveBeenCalled(); // Should assume dir exists
131
+ expect(git.clone).toHaveBeenCalledWith('foo.git', expect.stringContaining('empty-dir'), { recursive: true });
132
+ });
133
+
134
+ it('should fail if directory exists and is not empty', async () => {
135
+ // First exists check for targetDir
136
+ vi.mocked(fs.pathExists as any).mockResolvedValue(true);
137
+ vi.mocked(fs.readdir).mockResolvedValue(['file.txt'] as any);
138
+
139
+ await expect(command.run({ directory: 'existing-dir', repo: 'foo' }))
140
+ .rejects.toThrow('Process.exit(1)');
141
+
142
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('not empty'));
143
+ });
144
+
145
+ it('should handle git errors gracefully', async () => {
146
+ vi.mocked(git.clone).mockRejectedValueOnce(new Error('Git fail'));
147
+
148
+ await expect(command.run({ directory: 'fail-project', repo: 'foo' }))
149
+ .rejects.toThrow('Process.exit(1)');
150
+
151
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to initialize project'));
152
+ });
153
+ });
@@ -0,0 +1,262 @@
1
+ import { logger, runCommand } from '@nexical/cli-core';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import ModuleAddCommand from '../../../../src/commands/module/add.js';
4
+ import fs from 'fs-extra';
5
+ import * as git from '../../../../src/utils/git.js';
6
+
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
+ };
21
+ });
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
+ }));
36
+
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';
63
+ });
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
205
+ });
206
+
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);
210
+
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
+ });
261
+ });
262
+
@@ -0,0 +1,115 @@
1
+ import { BaseCommand, logger } from '@nexical/cli-core';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import ModuleListCommand from '../../../../src/commands/module/list.js';
4
+ import fs from 'fs-extra';
5
+
6
+ vi.mock('@nexical/cli-core', async (importOriginal) => {
7
+ const mod = await importOriginal<typeof import('@nexical/cli-core')>();
8
+ return {
9
+ ...mod,
10
+ logger: {
11
+ ...mod.logger,
12
+ success: vi.fn(),
13
+ info: vi.fn(),
14
+ debug: vi.fn(),
15
+ error: vi.fn(),
16
+ warn: vi.fn(),
17
+ },
18
+ runCommand: vi.fn(),
19
+ };
20
+ });
21
+ vi.mock('fs-extra');
22
+
23
+ describe('ModuleListCommand', () => {
24
+ let command: ModuleListCommand;
25
+ let consoleTableSpy: any;
26
+
27
+ beforeEach(async () => {
28
+ vi.clearAllMocks();
29
+ command = new ModuleListCommand({}, { rootDir: '/mock/root' });
30
+ consoleTableSpy = vi.spyOn(console, 'table').mockImplementation(() => { });
31
+ vi.spyOn(command, 'error').mockImplementation(() => { });
32
+ vi.spyOn(command, 'success').mockImplementation(() => { });
33
+ vi.spyOn(command, 'info').mockImplementation(() => { });
34
+ vi.mocked(fs.pathExists).mockImplementation(async (p: any) => {
35
+ if (p.includes('app.yml') || p.includes('nexical.yml')) return true;
36
+ return true;
37
+ });
38
+ vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
39
+ await command.init();
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.resetAllMocks();
44
+ });
45
+
46
+ it('should have correct static properties', () => {
47
+ expect(ModuleListCommand.usage).toContain('module list');
48
+ expect(ModuleListCommand.description).toBeDefined();
49
+ expect(ModuleListCommand.requiresProject).toBe(true);
50
+ });
51
+
52
+ it('should error if project root is missing', async () => {
53
+ command = new ModuleListCommand({}, { rootDir: undefined });
54
+ vi.spyOn(command, 'init').mockImplementation(async () => { });
55
+ vi.spyOn(command, 'error').mockImplementation(() => { });
56
+
57
+ await command.runInit({});
58
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('requires to be run within an app project'), 1);
59
+ });
60
+
61
+ it('should handle missing modules directory', async () => {
62
+ vi.mocked(fs.pathExists).mockImplementation(async () => false);
63
+ await command.run();
64
+ expect(command.info).toHaveBeenCalledWith(expect.stringContaining('No modules installed'));
65
+ });
66
+
67
+ it('should list modules with details', async () => {
68
+ vi.mocked(fs.readdir).mockResolvedValue(['mod1', 'file.txt', 'mod2', 'mod3', 'mod4'] as any);
69
+ // Mock directory check: mod1=dir, file.txt=file, mod2=dir, mod3=dir
70
+ vi.mocked(fs.stat).mockImplementation(async (p: any) => ({
71
+ isDirectory: () => !p.includes('file.txt')
72
+ } as any));
73
+
74
+ // Mock package.json existence: mod1=yes, mod2=no, mod3=yes
75
+ // Also ensure modules directory itself exists!
76
+ vi.mocked(fs.pathExists).mockImplementation(async (p: any) => {
77
+ if (p.includes('app.yml') || p.includes('nexical.yml')) return true;
78
+ if (p.endsWith('/modules')) return true;
79
+ return p.includes('package.json') && !p.includes('mod2');
80
+ });
81
+
82
+ // Mock reading json: mod1=valid, mod3=invalid, mod4=empty
83
+ vi.mocked(fs.readJson).mockImplementation(async (p: any) => {
84
+ if (p.includes('mod3')) throw new Error('Invalid JSON');
85
+ if (p.includes('mod4')) return {}; // No version/desc
86
+ return { version: '1.0.0', description: 'Desc' };
87
+ });
88
+
89
+ await command.run();
90
+
91
+ // mod1: listed with version
92
+ // file.txt: ignored
93
+ // mod2: listed with unknown version (dir exists, no pkg.json)
94
+ // mod3: listed with unknown version (invalid pkg.json)
95
+ // mod4: listed with unknown/empty (fallback logic)
96
+ expect(consoleTableSpy).toHaveBeenCalledWith(expect.arrayContaining([
97
+ { name: 'mod1', version: '1.0.0', description: 'Desc' },
98
+ { name: 'mod2', version: 'unknown', description: '' },
99
+ { name: 'mod3', version: 'unknown', description: '' },
100
+ { name: 'mod4', version: 'unknown', description: '' }
101
+ ]));
102
+ });
103
+
104
+ it('should handle empty modules directory', async () => {
105
+ vi.mocked(fs.readdir).mockResolvedValue([] as any);
106
+ await command.run();
107
+ expect(command.info).toHaveBeenCalledWith('No modules installed.');
108
+ });
109
+
110
+ it('should handle failure during list', async () => {
111
+ vi.mocked(fs.readdir).mockRejectedValue(new Error('FS Error'));
112
+ await command.run();
113
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to list modules'));
114
+ });
115
+ });
@@ -0,0 +1,89 @@
1
+ import { logger, runCommand } from '@nexical/cli-core';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import ModuleRemoveCommand from '../../../../src/commands/module/remove.js';
4
+ import fs from 'fs-extra';
5
+
6
+ vi.mock('@nexical/cli-core', async (importOriginal) => {
7
+ const mod = await importOriginal<typeof import('@nexical/cli-core')>();
8
+ return {
9
+ ...mod,
10
+ logger: {
11
+ ...mod.logger,
12
+ success: vi.fn(),
13
+ info: vi.fn(),
14
+ debug: vi.fn(),
15
+ error: vi.fn(),
16
+ warn: vi.fn(),
17
+ },
18
+ runCommand: vi.fn(),
19
+ };
20
+ });
21
+ vi.mock('fs-extra');
22
+
23
+ describe('ModuleRemoveCommand', () => {
24
+ let command: ModuleRemoveCommand;
25
+
26
+ beforeEach(async () => {
27
+ vi.clearAllMocks();
28
+ command = new ModuleRemoveCommand({}, { rootDir: '/mock/root' });
29
+ vi.spyOn(command, 'error').mockImplementation((() => { }) as any);
30
+ vi.spyOn(command, 'success').mockImplementation((() => { }) as any);
31
+ vi.spyOn(command, 'info').mockImplementation((() => { }) as any);
32
+ vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
33
+ if (p.includes('app.yml') || p.includes('nexical.yml')) return true;
34
+ return true;
35
+ });
36
+ vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
37
+ await command.init();
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.resetAllMocks();
42
+ });
43
+
44
+ it('should have correct static properties', () => {
45
+ expect(ModuleRemoveCommand.usage).toContain('module remove');
46
+ expect(ModuleRemoveCommand.description).toBeDefined();
47
+ expect(ModuleRemoveCommand.requiresProject).toBe(true);
48
+ expect(ModuleRemoveCommand.args).toBeDefined();
49
+ });
50
+
51
+ it('should error if project root is missing', async () => {
52
+ command = new ModuleRemoveCommand({}, { rootDir: undefined });
53
+ vi.spyOn(command, 'init').mockImplementation(async () => { });
54
+ vi.spyOn(command, 'error').mockImplementation((() => { }) as any);
55
+
56
+ await command.runInit({ name: 'mod' });
57
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('requires to be run within an app project'), 1);
58
+ });
59
+
60
+ it('should remove submodule and sync', async () => {
61
+ await command.run({ name: 'mod' });
62
+
63
+ expect(runCommand).toHaveBeenCalledWith(expect.stringContaining('git submodule deinit'), '/mock/root');
64
+ expect(runCommand).toHaveBeenCalledWith(expect.stringContaining('git rm'), '/mock/root');
65
+ expect(fs.remove).toHaveBeenCalledWith(expect.stringContaining('.git/modules'));
66
+ expect(runCommand).toHaveBeenCalledWith('npm install', '/mock/root');
67
+ });
68
+
69
+ it('should error if module not found', async () => {
70
+ vi.mocked(fs.pathExists).mockImplementation(async () => false);
71
+ await command.run({ name: 'missing' });
72
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
73
+ });
74
+
75
+ it('should handle failure during remove', async () => {
76
+ vi.mocked(runCommand).mockRejectedValue(new Error('Git remove failed'));
77
+ await command.run({ name: 'mod' });
78
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to remove module'));
79
+ });
80
+
81
+ it('should skip .git/modules cleanup if not found', async () => {
82
+ vi.mocked(fs.pathExists).mockImplementation(async (p: string) => {
83
+ if (p.includes('.git/modules')) return false;
84
+ return true;
85
+ });
86
+ await command.run({ name: 'mod' });
87
+ expect(fs.remove).not.toHaveBeenCalledWith(expect.stringContaining('.git/modules'));
88
+ });
89
+ });