@nexical/cli 0.11.23 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) 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 +148 -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 +169 -88
  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/tsup.config.ts +3 -0
  83. package/dist/chunk-2FKDEDDE.js.map +0 -1
  84. package/dist/chunk-EKCOW7FM.js.map +0 -1
  85. /package/dist/{chunk-GUUPSHWC.js.map → chunk-JEMIKBGX.js.map} +0 -0
  86. /package/dist/{chunk-OUGA4CB4.js.map → chunk-JS6WL5NS.js.map} +0 -0
  87. /package/dist/{chunk-GEESHGE4.js.map → chunk-L2RUXOL4.js.map} +0 -0
  88. /package/dist/{chunk-54HY52LH.js.map → chunk-QTJIGPQ3.js.map} +0 -0
  89. /package/dist/{chunk-2JW5BYZW.js.map → chunk-VKE7R2EZ.js.map} +0 -0
  90. /package/dist/{chunk-AC4B3HPJ.js.map → chunk-XONR27KC.js.map} +0 -0
  91. /package/dist/{chunk-PJIOCW2A.js.map → chunk-ZWNIZB3Q.js.map} +0 -0
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
2
2
  import { CloudflareProvider } from '../../../../src/deploy/providers/cloudflare.js';
3
3
  import { execAsync } from '../../../../src/deploy/utils.js';
4
4
  import { logger } from '@nexical/cli-core';
5
+ import { DeploymentContext, AppConfig } from '../../../../src/deploy/types.js';
5
6
 
6
7
  vi.mock('../../../../src/deploy/utils.js');
7
8
  vi.mock('@nexical/cli-core', () => ({
@@ -9,29 +10,32 @@ vi.mock('@nexical/cli-core', () => ({
9
10
  info: vi.fn(),
10
11
  warn: vi.fn(),
11
12
  error: vi.fn(),
13
+ success: vi.fn(),
12
14
  },
13
15
  }));
14
16
 
15
17
  describe('CloudflareProvider', () => {
16
18
  let provider: CloudflareProvider;
17
- let mockContext: { cwd: string; options: Record<string, unknown>; config: any };
19
+ let mockContext: DeploymentContext;
18
20
 
19
21
  beforeEach(() => {
20
22
  vi.resetAllMocks();
21
23
  provider = new CloudflareProvider();
22
24
  mockContext = {
23
25
  cwd: '/mock',
24
- options: {}, // Env undefined by default to test 'production' fallback
26
+ options: {},
25
27
  config: {
26
28
  deploy: {
27
- frontend: {
28
- projectName: 'my-app',
29
- // options intentionally undefined here to test fallback
29
+ apps: {
30
+ frontend: {
31
+ provider: 'cloudflare',
32
+ projectName: 'my-app',
33
+ },
30
34
  },
31
35
  },
32
- },
36
+ } as any,
33
37
  };
34
- (execAsync as unknown as { mockResolvedValue: any }).mockResolvedValue({
38
+ (execAsync as Mock).mockResolvedValue({
35
39
  stdout: '',
36
40
  stderr: '',
37
41
  });
@@ -41,28 +45,27 @@ describe('CloudflareProvider', () => {
41
45
  vi.clearAllMocks();
42
46
  delete process.env.CLOUDFLARE_API_TOKEN;
43
47
  delete process.env.CLOUDFLARE_ACCOUNT_ID;
44
- delete process.env.CUSTOM_CF_TOKEN;
45
- delete process.env.CUSTOM_CF_ACC;
46
48
  });
47
49
 
48
50
  describe('provision', () => {
49
51
  it('should error if project name is missing', async () => {
50
- mockContext.config.deploy.frontend.projectName = undefined;
51
- await expect(provider.provision(mockContext)).rejects.toThrow(
52
+ const app = { name: 'frontend', provider: 'cloudflare' } as AppConfig;
53
+ await expect(provider.provision(mockContext, app)).rejects.toThrow(
52
54
  'Cloudflare project name not found',
53
55
  );
54
56
  });
55
57
 
56
58
  it('should handle dry run', async () => {
57
59
  mockContext.options.dryRun = true;
58
- await provider.provision(mockContext);
60
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
61
+ await provider.provision(mockContext, app);
59
62
  expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
60
63
  expect(execAsync).not.toHaveBeenCalled();
61
64
  });
62
65
 
63
66
  it('should skip if credentials missing', async () => {
64
- // No env vars set
65
- await provider.provision(mockContext);
67
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
68
+ await provider.provision(mockContext, app);
66
69
  expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('credentials missing'));
67
70
  expect(execAsync).not.toHaveBeenCalled();
68
71
  });
@@ -70,8 +73,9 @@ describe('CloudflareProvider', () => {
70
73
  it('should provision successfully using default env vars', async () => {
71
74
  process.env.CLOUDFLARE_API_TOKEN = 'tok';
72
75
  process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
76
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
73
77
 
74
- await provider.provision(mockContext);
78
+ await provider.provision(mockContext, app);
75
79
 
76
80
  expect(execAsync).toHaveBeenCalledWith(
77
81
  expect.stringContaining('wrangler pages project create my-app --production-branch main'),
@@ -82,14 +86,15 @@ describe('CloudflareProvider', () => {
82
86
  it('should swallow "project already exists" error', async () => {
83
87
  process.env.CLOUDFLARE_API_TOKEN = 'tok';
84
88
  process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
85
- (execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
86
- new Error('Already exists'),
89
+ (execAsync as Mock).mockRejectedValueOnce(
90
+ new Error('A pages project with this name already exists.'),
87
91
  );
92
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
88
93
 
89
- await provider.provision(mockContext);
94
+ await provider.provision(mockContext, app);
90
95
 
91
96
  expect(logger.info).toHaveBeenCalledWith(
92
- expect.stringContaining('Cloudflare project might already exist'),
97
+ expect.stringContaining('Cloudflare project already exists'),
93
98
  );
94
99
  });
95
100
 
@@ -97,114 +102,108 @@ describe('CloudflareProvider', () => {
97
102
  process.env.CLOUDFLARE_API_TOKEN = 'tok';
98
103
  process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
99
104
 
100
- (logger.info as unknown as { mockImplementationOnce: any }).mockImplementationOnce(() => {}); // Config...
101
- (logger.info as unknown as { mockImplementationOnce: any }).mockImplementationOnce(() => {
102
- throw new Error('Critical');
103
- }); // Ensuring...
105
+ (execAsync as Mock).mockRejectedValueOnce(new Error('Critical error'));
106
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
104
107
 
105
- await expect(provider.provision(mockContext)).rejects.toThrow('Critical');
106
- expect(logger.warn).toHaveBeenCalledWith('Cloudflare setup failed.');
108
+ await expect(provider.provision(mockContext, app)).rejects.toThrow('Critical error');
107
109
  });
108
110
 
109
- it('should handle non-production environment', async () => {
110
- mockContext.options.env = 'staging';
111
- mockContext.config.deploy.frontend.options = {}; // Ensure options exist
111
+ it('should link custom domains during provision', async () => {
112
112
  process.env.CLOUDFLARE_API_TOKEN = 'tok';
113
113
  process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
114
114
 
115
- await provider.provision(mockContext);
115
+ const mockFetch = vi.fn();
116
+ vi.stubGlobal('fetch', mockFetch);
116
117
 
117
- expect(execAsync).toHaveBeenCalledWith(
118
- expect.stringContaining('wrangler pages project create my-app-staging'),
119
- expect.anything(),
120
- );
121
- });
118
+ // First call (GET) - return one existing domain
119
+ mockFetch.mockResolvedValueOnce({
120
+ ok: true,
121
+ json: async () => ({
122
+ success: true,
123
+ result: [{ domain: 'already-linked.com' }],
124
+ }),
125
+ });
126
+
127
+ // Second call (POST) - link new-domain.com
128
+ mockFetch.mockResolvedValueOnce({
129
+ ok: true,
130
+ json: async () => ({
131
+ success: true,
132
+ result: { name: 'new-domain.com' },
133
+ }),
134
+ });
122
135
 
123
- it('should use configured env vars for credentials', async () => {
124
- mockContext.config.deploy.frontend.options = {
125
- apiTokenEnvVar: 'CUSTOM_CF_TOKEN',
126
- accountIdEnvVar: 'CUSTOM_CF_ACC',
127
- };
128
- process.env.CUSTOM_CF_TOKEN = 'custom-tok';
129
- process.env.CUSTOM_CF_ACC = 'custom-acc';
136
+ const app = {
137
+ name: 'frontend',
138
+ provider: 'cloudflare',
139
+ projectName: 'my-app',
140
+ domain: ['already-linked.com', 'new-domain.com'],
141
+ } as AppConfig;
130
142
 
131
- await provider.provision(mockContext);
143
+ await provider.provision(mockContext, app);
132
144
 
133
- expect(execAsync).toHaveBeenCalledWith(
145
+ expect(mockFetch).toHaveBeenCalledTimes(2);
146
+ expect(mockFetch).toHaveBeenNthCalledWith(
147
+ 1,
148
+ expect.stringContaining('/pages/projects/my-app/domains'),
134
149
  expect.anything(),
150
+ );
151
+ expect(mockFetch).toHaveBeenNthCalledWith(
152
+ 2,
153
+ expect.stringContaining('/pages/projects/my-app/domains'),
135
154
  expect.objectContaining({
136
- env: expect.objectContaining({
137
- CLOUDFLARE_API_TOKEN: 'custom-tok',
138
- CLOUDFLARE_ACCOUNT_ID: 'custom-acc',
139
- }),
155
+ method: 'POST',
156
+ body: JSON.stringify({ name: 'new-domain.com' }),
140
157
  }),
141
158
  );
159
+ expect(logger.success).toHaveBeenCalledWith(
160
+ expect.stringContaining('Linked domain new-domain.com'),
161
+ );
162
+
163
+ vi.unstubAllGlobals();
142
164
  });
143
165
  });
144
166
 
145
167
  describe('getSecrets', () => {
146
- it('should resolve secrets from default env vars', async () => {
168
+ it('should return default secrets', async () => {
147
169
  process.env.CLOUDFLARE_API_TOKEN = 'tok';
148
170
  process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
149
- // options undefined by default in beforeEach
150
- const secrets = await provider.getSecrets(mockContext);
171
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
172
+
173
+ const secrets = await provider.getSecrets(mockContext, app);
151
174
  expect(secrets['CLOUDFLARE_API_TOKEN']).toBe('tok');
152
175
  expect(secrets['CLOUDFLARE_ACCOUNT_ID']).toBe('acc');
153
176
  });
154
-
155
- it('should resolve secrets from configured env vars', async () => {
156
- mockContext.config.deploy.frontend.options = {
157
- apiTokenEnvVar: 'CUSTOM_CF_TOKEN',
158
- accountIdEnvVar: 'CUSTOM_CF_ACC',
159
- };
160
- process.env.CUSTOM_CF_TOKEN = 'custom-tok';
161
- process.env.CUSTOM_CF_ACC = 'custom-acc';
162
-
163
- const secrets = await provider.getSecrets(mockContext);
164
- expect(secrets['CLOUDFLARE_API_TOKEN']).toBe('custom-tok');
165
- expect(secrets['CLOUDFLARE_ACCOUNT_ID']).toBe('custom-acc');
166
- });
167
-
168
- it('should error if API Token missing', async () => {
169
- // No env vars
170
- await expect(provider.getSecrets(mockContext)).rejects.toThrow(
171
- 'Cloudflare API Token not found',
172
- );
173
- });
174
-
175
- it('should error if Account ID missing', async () => {
176
- process.env.CLOUDFLARE_API_TOKEN = 'tok';
177
- // No account ID
178
- await expect(provider.getSecrets(mockContext)).rejects.toThrow(
179
- 'Cloudflare Account ID not found',
180
- );
181
- });
182
177
  });
183
178
 
184
179
  describe('getVariables', () => {
185
- it('should return project name for production', async () => {
186
- const vars = await provider.getVariables(mockContext);
187
- expect(vars['CLOUDFLARE_PROJECT_NAME']).toBe('my-app');
180
+ it('should return project variable', async () => {
181
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
182
+ const vars = await provider.getVariables(mockContext, app);
183
+ expect(vars['CLOUDFLARE_PROJECT_NAME_FRONTEND']).toBe('my-app');
188
184
  });
185
+ });
189
186
 
190
- it('should return project name for staging', async () => {
191
- mockContext.options.env = 'staging';
192
- const vars = await provider.getVariables(mockContext);
193
- expect(vars['CLOUDFLARE_PROJECT_NAME']).toBe('my-app-staging');
194
- });
187
+ describe('deploy', () => {
188
+ it('should run wrangler deploy', async () => {
189
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
190
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
191
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
195
192
 
196
- it('should error if project name missing', async () => {
197
- mockContext.config.deploy.frontend.projectName = undefined;
198
- await expect(provider.getVariables(mockContext)).rejects.toThrow(
199
- 'Cloudflare project name not found',
193
+ await provider.deploy(mockContext, app);
194
+
195
+ expect(execAsync).toHaveBeenCalledWith(
196
+ expect.stringContaining('wrangler pages deploy dist --project-name=my-app'),
197
+ expect.anything(),
200
198
  );
201
199
  });
202
- });
203
200
 
204
- describe('getCIConfig', () => {
205
- it('should return config', () => {
206
- const config = provider.getCIConfig();
207
- expect(config.githubActionStep?.uses).toBe('cloudflare/wrangler-action@v3');
201
+ it('should handle dry run', async () => {
202
+ mockContext.options.dryRun = true;
203
+ const app = { name: 'frontend', provider: 'cloudflare', projectName: 'my-app' } as AppConfig;
204
+ await provider.deploy(mockContext, app);
205
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
206
+ expect(execAsync).not.toHaveBeenCalled();
208
207
  });
209
208
  });
210
209
  });
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import CloudflareDnsProvider from '../../../../src/deploy/providers/dns-cloudflare';
3
+ import { DeploymentContext } from '../../../../src/deploy/types';
4
+
5
+ describe('CloudflareDnsProvider', () => {
6
+ let provider: CloudflareDnsProvider;
7
+ let mockContext: DeploymentContext;
8
+
9
+ beforeEach(() => {
10
+ provider = new CloudflareDnsProvider();
11
+ mockContext = {
12
+ cwd: '/test/cwd',
13
+ config: {
14
+ deploy: {
15
+ dns: {
16
+ provider: 'cloudflare',
17
+ cloudflare: {
18
+ token: 'test-token',
19
+ zone: 'test-zone-id',
20
+ },
21
+ },
22
+ },
23
+ },
24
+ options: {},
25
+ };
26
+
27
+ // Reset env vars and mocks
28
+ delete process.env.CLOUDFLARE_API_TOKEN;
29
+ delete process.env.CLOUDFLARE_ZONE_ID;
30
+ vi.stubGlobal('fetch', vi.fn());
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ it('should be named cloudflare and use type dns', () => {
38
+ expect(provider.name).toBe('cloudflare');
39
+ expect(provider.type).toBe('dns');
40
+ });
41
+
42
+ it('should throw if api token is missing', async () => {
43
+ (
44
+ mockContext.config.deploy!.dns as {
45
+ cloudflare?: { token?: string };
46
+ }
47
+ ).cloudflare!.token = undefined;
48
+ await expect(
49
+ provider.provision(mockContext, [{ type: 'A', name: 'ex.com', content: '1.2.3.4' }]),
50
+ ).rejects.toThrow(/Cloudflare API token not found/);
51
+ });
52
+
53
+ it('should throw if zone id is missing', async () => {
54
+ (
55
+ mockContext.config.deploy!.dns as {
56
+ cloudflare?: { zone?: string };
57
+ }
58
+ ).cloudflare!.zone = undefined;
59
+ await expect(
60
+ provider.provision(mockContext, [{ type: 'A', name: 'ex.com', content: '1.2.3.4' }]),
61
+ ).rejects.toThrow(/Cloudflare Zone ID not found/);
62
+ });
63
+
64
+ it('should skip provisioning if no records are provided', async () => {
65
+ await provider.provision(mockContext, []);
66
+ expect(fetch).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('should create new DNS record if it does not exist', async () => {
70
+ const mockFetch = vi.mocked(fetch);
71
+ mockFetch.mockResolvedValueOnce({
72
+ ok: true,
73
+ json: async () => ({
74
+ success: true,
75
+ result: [], // No existing records
76
+ }),
77
+ } as unknown as Response);
78
+
79
+ mockFetch.mockResolvedValueOnce({
80
+ ok: true,
81
+ } as unknown as Response);
82
+
83
+ await provider.provision(mockContext, [{ type: 'A', name: 'test.com', content: '1.2.3.4' }]);
84
+
85
+ expect(mockFetch).toHaveBeenCalledTimes(2);
86
+ expect(mockFetch).toHaveBeenNthCalledWith(
87
+ 1,
88
+ 'https://api.cloudflare.com/client/v4/zones/test-zone-id/dns_records',
89
+ expect.objectContaining({
90
+ headers: {
91
+ Authorization: 'Bearer test-token',
92
+ 'Content-Type': 'application/json',
93
+ },
94
+ }),
95
+ );
96
+ expect(mockFetch).toHaveBeenNthCalledWith(
97
+ 2,
98
+ 'https://api.cloudflare.com/client/v4/zones/test-zone-id/dns_records',
99
+ expect.objectContaining({
100
+ method: 'POST',
101
+ body: JSON.stringify({
102
+ type: 'A',
103
+ name: 'test.com',
104
+ content: '1.2.3.4',
105
+ proxied: true,
106
+ ttl: 1,
107
+ }),
108
+ }),
109
+ );
110
+ });
111
+
112
+ it('should update existing DNS record if content differs', async () => {
113
+ const mockFetch = vi.mocked(fetch);
114
+ mockFetch.mockResolvedValueOnce({
115
+ ok: true,
116
+ json: async () => ({
117
+ success: true,
118
+ result: [
119
+ { id: 'rec-1', type: 'CNAME', name: 'app.com', content: 'old.target.com', proxied: true },
120
+ ],
121
+ }),
122
+ } as unknown as Response);
123
+
124
+ mockFetch.mockResolvedValueOnce({
125
+ ok: true,
126
+ } as unknown as Response);
127
+
128
+ await provider.provision(mockContext, [
129
+ { type: 'CNAME', name: 'app.com', content: 'new.target.com', proxied: false },
130
+ ]);
131
+
132
+ expect(mockFetch).toHaveBeenCalledTimes(2);
133
+ expect(mockFetch).toHaveBeenNthCalledWith(
134
+ 2,
135
+ 'https://api.cloudflare.com/client/v4/zones/test-zone-id/dns_records/rec-1',
136
+ expect.objectContaining({
137
+ method: 'PUT',
138
+ body: JSON.stringify({
139
+ type: 'CNAME',
140
+ name: 'app.com',
141
+ content: 'new.target.com',
142
+ proxied: false,
143
+ ttl: 1,
144
+ }),
145
+ }),
146
+ );
147
+ });
148
+ });
@@ -1,20 +1,23 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
2
2
  import { GitHubProvider } from '../../../../src/deploy/providers/github.js';
3
3
  import { execAsync } from '../../../../src/deploy/utils.js';
4
4
  import { logger } from '@nexical/cli-core';
5
5
  import fs from 'node:fs/promises';
6
+ import { DeploymentContext } from '../../../../src/deploy/types.js';
6
7
 
7
8
  vi.mock('node:fs/promises');
8
9
  vi.mock('../../../../src/deploy/utils.js');
9
10
  vi.mock('@nexical/cli-core', () => ({
10
11
  logger: {
11
12
  info: vi.fn(),
13
+ error: vi.fn(),
14
+ success: vi.fn(),
12
15
  },
13
16
  }));
14
17
 
15
18
  describe('GitHubProvider', () => {
16
19
  let provider: GitHubProvider;
17
- let mockContext: any;
20
+ let mockContext: DeploymentContext;
18
21
 
19
22
  beforeEach(() => {
20
23
  vi.resetAllMocks();
@@ -22,12 +25,17 @@ describe('GitHubProvider', () => {
22
25
  mockContext = {
23
26
  cwd: '/mock',
24
27
  options: {},
25
- config: { deploy: { backend: {}, frontend: {} } },
26
- } as unknown as any;
27
- (execAsync as unknown as { mockResolvedValue: (val: unknown) => void }).mockResolvedValue({
28
+ config: { deploy: { repository: { provider: 'github' }, apps: {} } },
29
+ } as unknown as DeploymentContext;
30
+ (execAsync as Mock).mockResolvedValue({
28
31
  stdout: '',
29
32
  stderr: '',
30
33
  });
34
+ (fs.readFile as Mock).mockResolvedValue(
35
+ 'name: ${APP_NAME}\non:\n push:\n branches: [main]\njobs:\n deploy:\n steps: []',
36
+ );
37
+ (fs.mkdir as Mock).mockResolvedValue(undefined);
38
+ (fs.writeFile as Mock).mockResolvedValue(undefined);
31
39
  });
32
40
 
33
41
  afterEach(() => {
@@ -76,64 +84,52 @@ describe('GitHubProvider', () => {
76
84
  it('should generate workflow file', async () => {
77
85
  const targets = [
78
86
  {
79
- type: 'frontend',
80
- name: 'cf',
81
- getCIConfig: () => ({
82
- installSteps: ['run install'],
83
- deploySteps: ['run deploy'],
84
- secrets: ['SEC'],
85
- githubActionStep: { name: 'Action' },
86
- }),
87
- },
88
- {
89
- type: 'backend',
90
- name: 'rw',
91
- getCIConfig: () => ({
92
- deploySteps: ['run backend'],
93
- }),
87
+ provider: {
88
+ name: 'railway',
89
+ getCIConfig: () => ({
90
+ installSteps: ['run install'],
91
+ deploySteps: ['run deploy'],
92
+ secrets: ['SEC'],
93
+ githubActionStep: { name: 'Action' },
94
+ }),
95
+ },
96
+ app: { name: 'rw', provider: 'railway' },
94
97
  },
95
98
  ] as unknown as any;
96
99
 
97
100
  await provider.generateWorkflow(mockContext, targets);
98
101
 
99
- expect(fs.mkdir).toHaveBeenCalled();
100
- expect(fs.writeFile).toHaveBeenCalledTimes(2);
102
+ expect(fs.writeFile).toHaveBeenCalled();
103
+ const content = (fs.writeFile as Mock).mock.calls[0][1];
104
+ expect(content).toContain('name: Deploy rw to railway');
101
105
  });
102
106
 
103
- it('should skip if no config', async () => {
107
+ it('should support paths trigger', async () => {
104
108
  const targets = [
105
109
  {
106
- type: 'frontend',
107
- getCIConfig: () => null,
110
+ provider: {
111
+ name: 'frontend',
112
+ getCIConfig: () => ({}),
113
+ },
114
+ app: {
115
+ name: 'fe',
116
+ provider: 'cloudflare',
117
+ paths: ['apps/frontend/**'],
118
+ },
108
119
  },
109
120
  ] as unknown as any;
121
+
110
122
  await provider.generateWorkflow(mockContext, targets);
111
- expect(fs.writeFile).not.toHaveBeenCalled();
123
+
124
+ expect(fs.writeFile).toHaveBeenCalled();
125
+ const content = (fs.writeFile as Mock).mock.calls[0][1];
126
+ expect(content).toContain('paths:');
127
+ expect(content).toContain('- apps/frontend/**');
112
128
  });
113
129
 
114
- it('should handle no targets', async () => {
130
+ it('should skip if no targets provided', async () => {
115
131
  await provider.generateWorkflow(mockContext, []);
116
132
  expect(fs.writeFile).not.toHaveBeenCalled();
117
133
  });
118
-
119
- it('should handle target with no deploy steps', async () => {
120
- const targets = [
121
- {
122
- type: 'backend',
123
- name: 'test',
124
- getCIConfig: () => ({
125
- // explicit undefined deploySteps
126
- deploySteps: undefined,
127
- secrets: [],
128
- }),
129
- },
130
- ] as unknown as any;
131
-
132
- await provider.generateWorkflow(mockContext, targets);
133
- expect(fs.writeFile).toHaveBeenCalled();
134
- // Verify content doesn't crash
135
- const content = (fs.writeFile as unknown as { mock: { calls: any[][] } }).mock.calls[0][1];
136
- expect(content).toContain('Deploy Backend to test');
137
- });
138
134
  });
139
135
  });