@nexical/cli 0.11.22 → 0.12.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 (92) hide show
  1. package/README.md +90 -235
  2. package/dist/{chunk-OYFWMYPG.js → chunk-6DE5Q66O.js} +6 -1
  3. package/dist/{chunk-OYFWMYPG.js.map → chunk-6DE5Q66O.js.map} +1 -1
  4. package/dist/chunk-G66GMEFE.js +31 -0
  5. package/dist/chunk-G66GMEFE.js.map +1 -0
  6. package/dist/{chunk-2FKDEDDE.js → chunk-HOVS7SCD.js} +16 -3
  7. package/dist/chunk-HOVS7SCD.js.map +1 -0
  8. package/dist/{chunk-GUUPSHWC.js → chunk-JEMIKBGX.js} +3 -3
  9. package/dist/chunk-JGAMEJTL.js +4101 -0
  10. package/dist/chunk-JGAMEJTL.js.map +1 -0
  11. package/dist/{chunk-OUGA4CB4.js → chunk-JS6WL5NS.js} +2 -2
  12. package/dist/{chunk-GEESHGE4.js → chunk-L2RUXOL4.js} +2 -2
  13. package/dist/{chunk-54HY52LH.js → chunk-QTJIGPQ3.js} +2 -2
  14. package/dist/{chunk-EKCOW7FM.js → chunk-USP2MI63.js} +41 -23
  15. package/dist/chunk-USP2MI63.js.map +1 -0
  16. package/dist/{chunk-2JW5BYZW.js → chunk-VKE7R2EZ.js} +2 -2
  17. package/dist/{chunk-AC4B3HPJ.js → chunk-XONR27KC.js} +2 -2
  18. package/dist/{chunk-PJIOCW2A.js → chunk-ZWNIZB3Q.js} +2 -2
  19. package/dist/index.js +5 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/commands/deploy.d.ts +3 -3
  22. package/dist/src/commands/deploy.js +134 -78
  23. package/dist/src/commands/deploy.js.map +1 -1
  24. package/dist/src/commands/init.js +5 -5
  25. package/dist/src/commands/module/add.js +4 -4
  26. package/dist/src/commands/module/list.js +2 -2
  27. package/dist/src/commands/module/remove.js +2 -2
  28. package/dist/src/commands/module/update.js +2 -2
  29. package/dist/src/commands/prompt.js +2 -2
  30. package/dist/src/commands/run.js +2 -2
  31. package/dist/src/commands/setup.js +3 -3
  32. package/dist/src/deploy/config-manager.js +3 -2
  33. package/dist/src/deploy/providers/cloudflare.d.ts +13 -8
  34. package/dist/src/deploy/providers/cloudflare.js +161 -52
  35. package/dist/src/deploy/providers/cloudflare.js.map +1 -1
  36. package/dist/src/deploy/providers/dns-cloudflare.d.ts +9 -0
  37. package/dist/src/deploy/providers/dns-cloudflare.js +123 -0
  38. package/dist/src/deploy/providers/dns-cloudflare.js.map +1 -0
  39. package/dist/src/deploy/providers/github.d.ts +6 -2
  40. package/dist/src/deploy/providers/github.js +37 -45
  41. package/dist/src/deploy/providers/github.js.map +1 -1
  42. package/dist/src/deploy/providers/railway.d.ts +17 -8
  43. package/dist/src/deploy/providers/railway.js +106 -45
  44. package/dist/src/deploy/providers/railway.js.map +1 -1
  45. package/dist/src/deploy/registry.d.ts +7 -4
  46. package/dist/src/deploy/registry.js +2 -2
  47. package/dist/src/deploy/schema.d.ts +188 -0
  48. package/dist/src/deploy/schema.js +11 -0
  49. package/dist/src/deploy/schema.js.map +1 -0
  50. package/dist/src/deploy/template-manager.d.ts +12 -0
  51. package/dist/src/deploy/template-manager.js +9 -0
  52. package/dist/src/deploy/template-manager.js.map +1 -0
  53. package/dist/src/deploy/types.d.ts +42 -17
  54. package/dist/src/deploy/types.js +1 -1
  55. package/dist/src/deploy/types.js.map +1 -1
  56. package/dist/src/deploy/utils.js +2 -2
  57. package/dist/src/utils/discovery.js +2 -2
  58. package/dist/src/utils/filter.js +2 -2
  59. package/dist/src/utils/git.js +2 -2
  60. package/dist/src/utils/url-resolver.js +2 -2
  61. package/dist/templates/github-workflow.yaml +23 -0
  62. package/package.json +2 -2
  63. package/src/commands/deploy.ts +157 -93
  64. package/src/deploy/config-manager.ts +14 -1
  65. package/src/deploy/providers/cloudflare.ts +203 -80
  66. package/src/deploy/providers/dns-cloudflare.ts +134 -0
  67. package/src/deploy/providers/github.ts +44 -47
  68. package/src/deploy/providers/railway.ts +135 -55
  69. package/src/deploy/registry.ts +49 -28
  70. package/src/deploy/schema.ts +39 -0
  71. package/src/deploy/template-manager.ts +32 -0
  72. package/src/deploy/templates/github-workflow.yaml +23 -0
  73. package/src/deploy/types.ts +48 -16
  74. package/test/integration/commands/deploy.integration.test.ts +79 -3
  75. package/test/unit/commands/deploy.test.ts +96 -198
  76. package/test/unit/deploy/config-manager.test.ts +9 -5
  77. package/test/unit/deploy/providers/cloudflare.test.ts +95 -96
  78. package/test/unit/deploy/providers/dns-cloudflare.test.ts +148 -0
  79. package/test/unit/deploy/providers/github.test.ts +43 -47
  80. package/test/unit/deploy/providers/railway.test.ts +50 -261
  81. package/test/unit/deploy/registry.test.ts +20 -17
  82. package/tsconfig.json +1 -1
  83. package/tsup.config.ts +3 -0
  84. package/dist/chunk-2FKDEDDE.js.map +0 -1
  85. package/dist/chunk-EKCOW7FM.js.map +0 -1
  86. /package/dist/{chunk-GUUPSHWC.js.map → chunk-JEMIKBGX.js.map} +0 -0
  87. /package/dist/{chunk-OUGA4CB4.js.map → chunk-JS6WL5NS.js.map} +0 -0
  88. /package/dist/{chunk-GEESHGE4.js.map → chunk-L2RUXOL4.js.map} +0 -0
  89. /package/dist/{chunk-54HY52LH.js.map → chunk-QTJIGPQ3.js.map} +0 -0
  90. /package/dist/{chunk-2JW5BYZW.js.map → chunk-VKE7R2EZ.js.map} +0 -0
  91. /package/dist/{chunk-AC4B3HPJ.js.map → chunk-XONR27KC.js.map} +0 -0
  92. /package/dist/{chunk-PJIOCW2A.js.map → chunk-ZWNIZB3Q.js.map} +0 -0
@@ -5,6 +5,10 @@ import path from 'node:path';
5
5
  import fs from 'fs-extra';
6
6
  import { CLI } from '@nexical/cli-core';
7
7
 
8
+ const mocks = vi.hoisted(() => ({
9
+ dnsProvision: vi.fn().mockResolvedValue(undefined),
10
+ }));
11
+
8
12
  // Mock ConfigManager and Registry to control provider behavior without relying on real files or dynamic imports
9
13
  vi.mock('../../../src/deploy/config-manager.js', () => {
10
14
  return {
@@ -12,8 +16,11 @@ vi.mock('../../../src/deploy/config-manager.js', () => {
12
16
  return {
13
17
  load: vi.fn().mockResolvedValue({
14
18
  deploy: {
15
- backend: { provider: 'railway' },
16
- frontend: { provider: 'cloudflare' },
19
+ apps: {
20
+ api: { provider: 'railway', domain: 'api.test.com' },
21
+ web: { provider: 'cloudflare' },
22
+ },
23
+ dns: { provider: 'cloudflare' },
17
24
  repository: { provider: 'github' },
18
25
  },
19
26
  }),
@@ -28,13 +35,14 @@ vi.mock('../../../src/deploy/registry.js', () => {
28
35
  return {
29
36
  loadCoreProviders: vi.fn(),
30
37
  loadLocalProviders: vi.fn(),
31
- getDeploymentProvider: vi.fn().mockImplementation((name) => {
38
+ getHostingProvider: vi.fn().mockImplementation((name) => {
32
39
  if (name === 'railway') {
33
40
  return {
34
41
  name: 'railway',
35
42
  provision: vi.fn().mockResolvedValue(undefined),
36
43
  getSecrets: vi.fn().mockResolvedValue({ R_SEC: 'val' }),
37
44
  getVariables: vi.fn().mockResolvedValue({ R_VAR: 'val' }),
45
+ getDefaultDnsTarget: vi.fn().mockReturnValue('deploy.railway.app'),
38
46
  };
39
47
  }
40
48
  if (name === 'cloudflare') {
@@ -43,6 +51,7 @@ vi.mock('../../../src/deploy/registry.js', () => {
43
51
  provision: vi.fn().mockResolvedValue(undefined),
44
52
  getSecrets: vi.fn().mockResolvedValue({ C_SEC: 'val' }),
45
53
  getVariables: vi.fn().mockResolvedValue({ C_VAR: 'val' }),
54
+ getDefaultDnsTarget: vi.fn().mockReturnValue('test.pages.dev'),
46
55
  };
47
56
  }
48
57
  return undefined;
@@ -59,6 +68,10 @@ vi.mock('../../../src/deploy/registry.js', () => {
59
68
  await fs.writeFile(targetFile, 'yaml content');
60
69
  }),
61
70
  }),
71
+ getDnsProvider: vi.fn().mockReturnValue({
72
+ name: 'cloudflare',
73
+ provision: mocks.dnsProvision,
74
+ }),
62
75
  };
63
76
  }),
64
77
  };
@@ -99,4 +112,67 @@ describe('Deploy Command Integration', () => {
99
112
  process.chdir(originalCwd);
100
113
  }
101
114
  });
115
+
116
+ it('should filter applications when --apps is specified', async () => {
117
+ const originalCwd = process.cwd();
118
+ try {
119
+ const cli = new CLI({ commandName: 'nexical' });
120
+ process.chdir(projectDir);
121
+
122
+ const deployCmd = new DeployCommand(cli);
123
+
124
+ // Execute run with only backend
125
+ await deployCmd.run({ env: 'production', apps: 'api' });
126
+
127
+ // Verification: The mock ProviderRegistry.getDeploymentProvider was only called for 'backend'
128
+ // and NOT for 'frontend'. In this simpler verification, we just check no error was thrown.
129
+ const workflowPath = path.join(projectDir, '.github/workflows/deploy.yml');
130
+ expect(await fs.pathExists(workflowPath)).toBe(true);
131
+ } finally {
132
+ process.chdir(originalCwd);
133
+ }
134
+ });
135
+
136
+ it('should throw error if specified application does not exist', async () => {
137
+ const originalCwd = process.cwd();
138
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((code) => {
139
+ throw new Error(`Process exited with code ${code}`);
140
+ });
141
+
142
+ try {
143
+ const cli = new CLI({ commandName: 'nexical' });
144
+ process.chdir(projectDir);
145
+
146
+ const deployCmd = new DeployCommand(cli);
147
+
148
+ // Execute run with non-existent app
149
+ // We expect the 'process.exit unexpectedly called' error OR our custom error
150
+ // Depending on how vitest/cli-core interacts.
151
+ await expect(deployCmd.run({ env: 'production', apps: 'invalid-app' })).rejects.toThrow(
152
+ /The following applications were not found in nexical.yaml: invalid-app|Process exited with code 1/,
153
+ );
154
+ } finally {
155
+ mockExit.mockRestore();
156
+ process.chdir(originalCwd);
157
+ }
158
+ });
159
+
160
+ it('should provision DNS records successfully', async () => {
161
+ const originalCwd = process.cwd();
162
+ try {
163
+ const cli = new CLI({ commandName: 'nexical' });
164
+ process.chdir(projectDir);
165
+
166
+ const deployCmd = new DeployCommand(cli);
167
+
168
+ // We expect this to execute our mock DnsProvider since it's in the simulated config
169
+ await deployCmd.run({ env: 'production' });
170
+
171
+ expect(mocks.dnsProvision).toHaveBeenCalledWith(expect.anything(), [
172
+ { type: 'CNAME', name: 'api.test.com', content: 'deploy.railway.app', proxied: true },
173
+ ]);
174
+ } finally {
175
+ process.chdir(originalCwd);
176
+ }
177
+ });
102
178
  });
@@ -2,15 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import dotenv from 'dotenv';
3
3
  import DeployCommand from '../../../src/commands/deploy.js';
4
4
 
5
- // Fully mock the modules to return classes
6
5
  vi.mock('../../../src/deploy/config-manager.js', () => {
7
6
  return {
8
7
  ConfigManager: vi.fn().mockImplementation(function () {
9
8
  return {
10
9
  load: vi.fn().mockResolvedValue({
11
10
  deploy: {
12
- backend: { provider: 'railway' },
13
- frontend: { provider: 'cloudflare' },
11
+ apps: {
12
+ api: { provider: 'railway' },
13
+ web: { provider: 'vercel' },
14
+ },
14
15
  repository: { provider: 'github' },
15
16
  },
16
17
  }),
@@ -25,8 +26,9 @@ vi.mock('../../../src/deploy/registry.js', () => {
25
26
  return {
26
27
  loadCoreProviders: vi.fn(),
27
28
  loadLocalProviders: vi.fn(),
28
- getDeploymentProvider: vi.fn(),
29
+ getHostingProvider: vi.fn(),
29
30
  getRepositoryProvider: vi.fn(),
31
+ getDnsProvider: vi.fn(),
30
32
  };
31
33
  }),
32
34
  };
@@ -47,32 +49,33 @@ vi.mock('@nexical/cli-core', async (importOriginal) => {
47
49
  };
48
50
  });
49
51
 
50
- // Import them after vi.mock to get the mocked versions
51
52
  import { ConfigManager } from '../../../src/deploy/config-manager.js';
52
53
  import { ProviderRegistry } from '../../../src/deploy/registry.js';
53
54
 
54
55
  describe('DeployCommand', () => {
55
56
  let command: DeployCommand;
56
57
  let mockRegistry: {
57
- loadCoreProviders: any;
58
- loadLocalProviders: any;
59
- getDeploymentProvider: any;
60
- getRepositoryProvider: any;
58
+ loadCoreProviders: ReturnType<typeof vi.fn>;
59
+ loadLocalProviders: ReturnType<typeof vi.fn>;
60
+ getHostingProvider: ReturnType<typeof vi.fn>;
61
+ getRepositoryProvider: ReturnType<typeof vi.fn>;
62
+ getDnsProvider: ReturnType<typeof vi.fn>;
61
63
  };
62
- let mockConfigManager: { load: any };
64
+ let mockConfigManager: { load: ReturnType<typeof vi.fn> };
63
65
 
64
66
  beforeEach(() => {
65
67
  vi.clearAllMocks();
66
68
 
67
- command = new DeployCommand({} as unknown as any, { rootDir: '/mock/root' });
69
+ command = new DeployCommand({} as never, { rootDir: '/mock/root' } as never);
68
70
  (command as unknown as { projectRoot: string }).projectRoot = '/mock/root';
69
71
 
70
- // Get the instance that will be returned by the constructor
71
72
  mockConfigManager = {
72
73
  load: vi.fn().mockResolvedValue({
73
74
  deploy: {
74
- backend: { provider: 'railway' },
75
- frontend: { provider: 'cloudflare' },
75
+ apps: {
76
+ api: { provider: 'railway' },
77
+ web: { provider: 'vercel' },
78
+ },
76
79
  repository: { provider: 'github' },
77
80
  },
78
81
  }),
@@ -84,8 +87,9 @@ describe('DeployCommand', () => {
84
87
  mockRegistry = {
85
88
  loadCoreProviders: vi.fn(),
86
89
  loadLocalProviders: vi.fn(),
87
- getDeploymentProvider: vi.fn(),
90
+ getHostingProvider: vi.fn(),
88
91
  getRepositoryProvider: vi.fn(),
92
+ getDnsProvider: vi.fn(),
89
93
  };
90
94
  vi.mocked(ProviderRegistry).mockImplementation(function () {
91
95
  return mockRegistry;
@@ -96,6 +100,7 @@ describe('DeployCommand', () => {
96
100
  throw new Error('CLI ERROR');
97
101
  });
98
102
  vi.spyOn(command, 'success').mockImplementation(() => {});
103
+ vi.spyOn(command, 'warn').mockImplementation(() => {});
99
104
  });
100
105
 
101
106
  afterEach(() => {
@@ -103,17 +108,17 @@ describe('DeployCommand', () => {
103
108
  });
104
109
 
105
110
  it('should run a full deployment successfully', async () => {
106
- const mockBackend = {
111
+ const mockApi = {
107
112
  name: 'railway',
108
113
  provision: vi.fn().mockResolvedValue(undefined),
109
- getSecrets: vi.fn().mockResolvedValue({ B_SEC: 'val' }),
110
- getVariables: vi.fn().mockResolvedValue({ B_VAR: 'val' }),
114
+ getSecrets: vi.fn().mockResolvedValue({ API_SEC: 'val' }),
115
+ getVariables: vi.fn().mockResolvedValue({ API_VAR: 'val' }),
111
116
  };
112
- const mockFrontend = {
113
- name: 'cloudflare',
117
+ const mockWeb = {
118
+ name: 'vercel',
114
119
  provision: vi.fn().mockResolvedValue(undefined),
115
- getSecrets: vi.fn().mockResolvedValue({ F_SEC: 'val' }),
116
- getVariables: vi.fn().mockResolvedValue({ F_VAR: 'val' }),
120
+ getSecrets: vi.fn().mockResolvedValue({ WEB_SEC: 'val' }),
121
+ getVariables: vi.fn().mockResolvedValue({ WEB_VAR: 'val' }),
117
122
  };
118
123
  const mockRepo = {
119
124
  name: 'github',
@@ -122,9 +127,9 @@ describe('DeployCommand', () => {
122
127
  generateWorkflow: vi.fn().mockResolvedValue(undefined),
123
128
  };
124
129
 
125
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
126
- if (name === 'railway') return mockBackend;
127
- if (name === 'cloudflare') return mockFrontend;
130
+ mockRegistry.getHostingProvider.mockImplementation((name: string) => {
131
+ if (name === 'railway') return mockApi;
132
+ if (name === 'vercel') return mockWeb;
128
133
  return undefined;
129
134
  });
130
135
  mockRegistry.getRepositoryProvider.mockReturnValue(mockRepo);
@@ -132,224 +137,117 @@ describe('DeployCommand', () => {
132
137
  await command.run({ env: 'production' });
133
138
 
134
139
  expect(dotenv.config).toHaveBeenCalled();
135
- expect(mockBackend.provision).toHaveBeenCalled();
136
- expect(mockFrontend.provision).toHaveBeenCalled();
137
- expect(mockRepo.configureSecrets).toHaveBeenCalled();
138
- expect(mockRepo.configureVariables).toHaveBeenCalled();
140
+ expect(mockApi.provision).toHaveBeenCalled();
141
+ expect(mockWeb.provision).toHaveBeenCalled();
142
+ expect(mockRepo.configureSecrets).toHaveBeenCalledWith(
143
+ expect.anything(),
144
+ expect.objectContaining({ API_SEC: 'val', WEB_SEC: 'val' }),
145
+ );
146
+ expect(mockRepo.configureVariables).toHaveBeenCalledWith(
147
+ expect.anything(),
148
+ expect.objectContaining({ API_VAR: 'val', WEB_VAR: 'val' }),
149
+ );
139
150
  expect(mockRepo.generateWorkflow).toHaveBeenCalled();
140
151
  expect(command.success).toHaveBeenCalledWith('Deployment configuration complete!');
141
152
  });
142
153
 
143
- it('should error if backend provider is missing', async () => {
144
- mockConfigManager.load.mockResolvedValue({ deploy: { frontend: { provider: 'cf' } } });
145
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
146
- });
147
-
148
- it('should error if frontend provider is missing', async () => {
149
- mockConfigManager.load.mockResolvedValue({ deploy: { backend: { provider: 'rw' } } });
150
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
151
- });
152
-
153
- it('should error if repo provider is missing', async () => {
154
- mockConfigManager.load.mockResolvedValue({
155
- deploy: { backend: { provider: 'rw' }, frontend: { provider: 'cf' } },
156
- });
157
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
158
- });
159
-
160
- it('should throw if provider is not found in registry', async () => {
161
- mockRegistry.getDeploymentProvider.mockReturnValue(undefined);
162
- await expect(command.run({ backend: 'unknown' })).rejects.toThrow(
163
- "Backend provider 'unknown' not found.",
164
- );
165
- });
166
-
167
- it('should handle errors during secret resolution', async () => {
168
- const mockBackend = {
154
+ it('should filter apps if --apps is provided', async () => {
155
+ const mockApi = {
169
156
  name: 'railway',
170
157
  provision: vi.fn(),
171
- getSecrets: vi.fn().mockRejectedValue(new Error('Secret fail')),
158
+ getSecrets: vi.fn().mockResolvedValue({}),
172
159
  getVariables: vi.fn().mockResolvedValue({}),
173
160
  };
174
- mockRegistry.getDeploymentProvider.mockReturnValue(mockBackend);
161
+ mockRegistry.getHostingProvider.mockReturnValue(mockApi);
175
162
  mockRegistry.getRepositoryProvider.mockReturnValue({
176
163
  configureSecrets: vi.fn(),
177
164
  configureVariables: vi.fn(),
178
165
  generateWorkflow: vi.fn(),
179
166
  });
180
167
 
181
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
168
+ await command.run({ apps: 'api' });
169
+ expect(mockApi.provision).toHaveBeenCalledTimes(1);
170
+ expect(command.success).toHaveBeenCalledWith('Deployment configuration complete!');
182
171
  });
183
172
 
184
- it('should handle non-Error exceptions during secret resolution', async () => {
185
- const mockBackend = {
186
- name: 'railway',
187
- provision: vi.fn(),
188
- getSecrets: vi.fn().mockRejectedValue('String error'),
189
- getVariables: vi.fn().mockResolvedValue({}),
190
- };
191
- mockRegistry.getDeploymentProvider.mockReturnValue(mockBackend);
192
- mockRegistry.getRepositoryProvider.mockReturnValue({
193
- configureSecrets: vi.fn(),
194
- configureVariables: vi.fn(),
195
- generateWorkflow: vi.fn(),
196
- });
197
-
173
+ it('should error if no apps found', async () => {
174
+ mockConfigManager.load.mockResolvedValue({ deploy: {} });
198
175
  await expect(command.run({})).rejects.toThrow('CLI ERROR');
176
+ expect(command.error).toHaveBeenCalledWith(
177
+ 'No applications found in nexical.yaml. Please configure [deploy.apps].',
178
+ );
199
179
  });
200
180
 
201
- it('should handle errors during variable resolution', async () => {
202
- const mockBackend = {
203
- name: 'railway',
204
- provision: vi.fn(),
205
- getSecrets: vi.fn().mockResolvedValue({}),
206
- getVariables: vi.fn().mockRejectedValue(new Error('Var fail')),
207
- };
208
- mockRegistry.getDeploymentProvider.mockReturnValue(mockBackend);
209
- mockRegistry.getRepositoryProvider.mockReturnValue({
210
- configureSecrets: vi.fn(),
211
- configureVariables: vi.fn(),
212
- generateWorkflow: vi.fn(),
213
- });
214
-
215
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
181
+ it('should error if requested app is missing', async () => {
182
+ await expect(command.run({ apps: 'missing' })).rejects.toThrow('CLI ERROR');
183
+ expect(command.error).toHaveBeenCalledWith(
184
+ 'The following applications were not found in nexical.yaml: missing',
185
+ );
216
186
  });
217
187
 
218
- it('should handle non-Error exceptions during variable resolution', async () => {
219
- const mockBackend = {
220
- name: 'railway',
221
- provision: vi.fn(),
222
- getSecrets: vi.fn().mockResolvedValue({}),
223
- getVariables: vi.fn().mockRejectedValue('String var fail'),
224
- };
225
- mockRegistry.getDeploymentProvider.mockReturnValue(mockBackend);
226
- mockRegistry.getRepositoryProvider.mockReturnValue({
227
- configureSecrets: vi.fn(),
228
- configureVariables: vi.fn(),
229
- generateWorkflow: vi.fn(),
188
+ it('should error if repo provider is missing', async () => {
189
+ mockConfigManager.load.mockResolvedValue({
190
+ deploy: { apps: { api: { provider: 'pw' } } },
230
191
  });
231
-
232
192
  await expect(command.run({})).rejects.toThrow('CLI ERROR');
193
+ expect(command.error).toHaveBeenCalledWith(
194
+ expect.stringContaining('Repository provider not specified'),
195
+ );
233
196
  });
234
- it('should handle errors during frontend secret resolution', async () => {
235
- const mockBackend = {
236
- name: 'railway',
237
- provision: vi.fn(),
238
- getSecrets: vi.fn().mockResolvedValue({}),
239
- getVariables: vi.fn().mockResolvedValue({}),
240
- };
241
- const mockFrontend = {
242
- name: 'cloudflare',
243
- provision: vi.fn(),
244
- getSecrets: vi.fn().mockRejectedValue(new Error('Front secret fail')),
245
- getVariables: vi.fn().mockResolvedValue({}),
246
- };
247
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
248
- if (name === 'railway') return mockBackend;
249
- if (name === 'cloudflare') return mockFrontend;
250
- });
251
- mockRegistry.getRepositoryProvider.mockReturnValue({
252
- configureSecrets: vi.fn(),
253
- configureVariables: vi.fn(),
254
- generateWorkflow: vi.fn(),
255
- });
256
197
 
198
+ it('should throw if provider is not found for an app', async () => {
199
+ mockRegistry.getHostingProvider.mockReturnValue(undefined);
200
+ mockRegistry.getRepositoryProvider.mockReturnValue({});
257
201
  await expect(command.run({})).rejects.toThrow('CLI ERROR');
202
+ expect(command.error).toHaveBeenCalledWith(
203
+ "Provider 'railway' not found for application 'api'.",
204
+ );
258
205
  });
259
206
 
260
- it('should handle non-Error exceptions during frontend secret resolution', async () => {
261
- const mockBackend = {
262
- name: 'railway',
207
+ it('should throw if repo provider is not found in registry', async () => {
208
+ mockRegistry.getHostingProvider.mockReturnValue({
263
209
  provision: vi.fn(),
264
210
  getSecrets: vi.fn().mockResolvedValue({}),
265
211
  getVariables: vi.fn().mockResolvedValue({}),
266
- };
267
- const mockFrontend = {
268
- name: 'cloudflare',
269
- provision: vi.fn(),
270
- getSecrets: vi.fn().mockRejectedValue('String front secret fail'),
271
- getVariables: vi.fn().mockResolvedValue({}),
272
- };
273
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
274
- if (name === 'railway') return mockBackend;
275
- if (name === 'cloudflare') return mockFrontend;
276
212
  });
277
- mockRegistry.getRepositoryProvider.mockReturnValue({
278
- configureSecrets: vi.fn(),
279
- configureVariables: vi.fn(),
280
- generateWorkflow: vi.fn(),
281
- });
282
-
283
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
213
+ mockRegistry.getRepositoryProvider.mockReturnValue(undefined);
214
+ await expect(command.run({})).rejects.toThrow("Repository provider 'github' not found.");
284
215
  });
285
216
 
286
- it('should handle errors during frontend variable resolution', async () => {
287
- const mockBackend = {
288
- name: 'railway',
289
- provision: vi.fn(),
290
- getSecrets: vi.fn().mockResolvedValue({}),
291
- getVariables: vi.fn().mockResolvedValue({}),
292
- };
293
- const mockFrontend = {
294
- name: 'cloudflare',
295
- provision: vi.fn(),
296
- getSecrets: vi.fn().mockResolvedValue({}),
297
- getVariables: vi.fn().mockRejectedValue(new Error('Front var fail')),
298
- };
299
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
300
- if (name === 'railway') return mockBackend;
301
- if (name === 'cloudflare') return mockFrontend;
302
- });
303
- mockRegistry.getRepositoryProvider.mockReturnValue({
304
- configureSecrets: vi.fn(),
305
- configureVariables: vi.fn(),
306
- generateWorkflow: vi.fn(),
217
+ it('should handle DNS provisioning if configured', async () => {
218
+ mockConfigManager.load.mockResolvedValue({
219
+ deploy: {
220
+ apps: {
221
+ web: { provider: 'vercel', domain: 'example.com' },
222
+ },
223
+ repository: { provider: 'github' },
224
+ dns: { provider: 'cloudflare' },
225
+ },
307
226
  });
308
227
 
309
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
310
- });
311
-
312
- it('should handle non-Error exceptions during frontend variable resolution', async () => {
313
- const mockBackend = {
314
- name: 'railway',
315
- provision: vi.fn(),
228
+ const mockWeb = {
229
+ name: 'vercel',
230
+ provision: vi.fn().mockResolvedValue(undefined),
316
231
  getSecrets: vi.fn().mockResolvedValue({}),
317
232
  getVariables: vi.fn().mockResolvedValue({}),
233
+ getDefaultDnsTarget: vi.fn().mockReturnValue('proxy.com'),
318
234
  };
319
- const mockFrontend = {
320
- name: 'cloudflare',
321
- provision: vi.fn(),
322
- getSecrets: vi.fn().mockResolvedValue({}),
323
- getVariables: vi.fn().mockRejectedValue('String front var fail'),
324
- };
325
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
326
- if (name === 'railway') return mockBackend;
327
- if (name === 'cloudflare') return mockFrontend;
328
- });
235
+ mockRegistry.getHostingProvider.mockReturnValue(mockWeb);
329
236
  mockRegistry.getRepositoryProvider.mockReturnValue({
330
237
  configureSecrets: vi.fn(),
331
238
  configureVariables: vi.fn(),
332
239
  generateWorkflow: vi.fn(),
333
240
  });
241
+ const mockDns = {
242
+ name: 'cloudflare',
243
+ provision: vi.fn().mockResolvedValue(undefined),
244
+ };
245
+ mockRegistry.getDnsProvider.mockReturnValue(mockDns);
334
246
 
335
- await expect(command.run({})).rejects.toThrow('CLI ERROR');
336
- });
337
-
338
- it('should throw if frontend provider is not found in registry', async () => {
339
- mockRegistry.getDeploymentProvider.mockImplementation((name: string) => {
340
- if (name === 'railway') return { name: 'railway' };
341
- return undefined;
342
- });
343
- await expect(command.run({ frontend: 'unknown' })).rejects.toThrow(
344
- "Frontend provider 'unknown' not found.",
345
- );
346
- });
247
+ await command.run({});
347
248
 
348
- it('should throw if repo provider is not found in registry', async () => {
349
- mockRegistry.getDeploymentProvider.mockReturnValue({ name: 'mock' });
350
- mockRegistry.getRepositoryProvider.mockReturnValue(undefined);
351
- await expect(command.run({ repo: 'unknown' })).rejects.toThrow(
352
- "Repository provider 'unknown' not found.",
353
- );
249
+ expect(mockDns.provision).toHaveBeenCalledWith(expect.anything(), [
250
+ { type: 'CNAME', name: 'example.com', content: 'proxy.com', proxied: true },
251
+ ]);
354
252
  });
355
253
  });
@@ -16,12 +16,14 @@ describe('ConfigManager', () => {
16
16
  it('should load config', async () => {
17
17
  const mockConfig: NexicalConfig = {
18
18
  deploy: {
19
- backend: {
20
- provider: 'railway',
19
+ apps: {
20
+ backend: { provider: 'railway' },
21
21
  },
22
22
  },
23
23
  };
24
- vi.mocked(fs.readFile).mockResolvedValue('deploy:\n backend:\n provider: railway');
24
+ vi.mocked(fs.readFile).mockResolvedValue(
25
+ 'deploy:\n apps:\n backend:\n provider: railway',
26
+ );
25
27
  const config = await manager.load();
26
28
  expect(config).toEqual(mockConfig);
27
29
  });
@@ -42,8 +44,10 @@ describe('ConfigManager', () => {
42
44
  it('should save config', async () => {
43
45
  const mockConfig: NexicalConfig = {
44
46
  deploy: {
45
- frontend: {
46
- provider: 'cloudflare',
47
+ apps: {
48
+ frontend: {
49
+ provider: 'cloudflare',
50
+ },
47
51
  },
48
52
  },
49
53
  };