@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.
- package/README.md +90 -235
- package/dist/{chunk-OYFWMYPG.js → chunk-6DE5Q66O.js} +6 -1
- package/dist/{chunk-OYFWMYPG.js.map → chunk-6DE5Q66O.js.map} +1 -1
- package/dist/chunk-G66GMEFE.js +31 -0
- package/dist/chunk-G66GMEFE.js.map +1 -0
- package/dist/{chunk-2FKDEDDE.js → chunk-HOVS7SCD.js} +16 -3
- package/dist/chunk-HOVS7SCD.js.map +1 -0
- package/dist/{chunk-GUUPSHWC.js → chunk-JEMIKBGX.js} +3 -3
- package/dist/chunk-JGAMEJTL.js +4101 -0
- package/dist/chunk-JGAMEJTL.js.map +1 -0
- package/dist/{chunk-OUGA4CB4.js → chunk-JS6WL5NS.js} +2 -2
- package/dist/{chunk-GEESHGE4.js → chunk-L2RUXOL4.js} +2 -2
- package/dist/{chunk-54HY52LH.js → chunk-QTJIGPQ3.js} +2 -2
- package/dist/{chunk-EKCOW7FM.js → chunk-USP2MI63.js} +41 -23
- package/dist/chunk-USP2MI63.js.map +1 -0
- package/dist/{chunk-2JW5BYZW.js → chunk-VKE7R2EZ.js} +2 -2
- package/dist/{chunk-AC4B3HPJ.js → chunk-XONR27KC.js} +2 -2
- package/dist/{chunk-PJIOCW2A.js → chunk-ZWNIZB3Q.js} +2 -2
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/src/commands/deploy.d.ts +3 -3
- package/dist/src/commands/deploy.js +134 -78
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/init.js +5 -5
- package/dist/src/commands/module/add.js +4 -4
- package/dist/src/commands/module/list.js +2 -2
- package/dist/src/commands/module/remove.js +2 -2
- package/dist/src/commands/module/update.js +2 -2
- package/dist/src/commands/prompt.js +2 -2
- package/dist/src/commands/run.js +2 -2
- package/dist/src/commands/setup.js +3 -3
- package/dist/src/deploy/config-manager.js +3 -2
- package/dist/src/deploy/providers/cloudflare.d.ts +13 -8
- package/dist/src/deploy/providers/cloudflare.js +161 -52
- package/dist/src/deploy/providers/cloudflare.js.map +1 -1
- package/dist/src/deploy/providers/dns-cloudflare.d.ts +9 -0
- package/dist/src/deploy/providers/dns-cloudflare.js +123 -0
- package/dist/src/deploy/providers/dns-cloudflare.js.map +1 -0
- package/dist/src/deploy/providers/github.d.ts +6 -2
- package/dist/src/deploy/providers/github.js +37 -45
- package/dist/src/deploy/providers/github.js.map +1 -1
- package/dist/src/deploy/providers/railway.d.ts +17 -8
- package/dist/src/deploy/providers/railway.js +106 -45
- package/dist/src/deploy/providers/railway.js.map +1 -1
- package/dist/src/deploy/registry.d.ts +7 -4
- package/dist/src/deploy/registry.js +2 -2
- package/dist/src/deploy/schema.d.ts +188 -0
- package/dist/src/deploy/schema.js +11 -0
- package/dist/src/deploy/schema.js.map +1 -0
- package/dist/src/deploy/template-manager.d.ts +12 -0
- package/dist/src/deploy/template-manager.js +9 -0
- package/dist/src/deploy/template-manager.js.map +1 -0
- package/dist/src/deploy/types.d.ts +42 -17
- package/dist/src/deploy/types.js +1 -1
- package/dist/src/deploy/types.js.map +1 -1
- package/dist/src/deploy/utils.js +2 -2
- package/dist/src/utils/discovery.js +2 -2
- package/dist/src/utils/filter.js +2 -2
- package/dist/src/utils/git.js +2 -2
- package/dist/src/utils/url-resolver.js +2 -2
- package/dist/templates/github-workflow.yaml +23 -0
- package/package.json +2 -2
- package/src/commands/deploy.ts +157 -93
- package/src/deploy/config-manager.ts +14 -1
- package/src/deploy/providers/cloudflare.ts +203 -80
- package/src/deploy/providers/dns-cloudflare.ts +134 -0
- package/src/deploy/providers/github.ts +44 -47
- package/src/deploy/providers/railway.ts +135 -55
- package/src/deploy/registry.ts +49 -28
- package/src/deploy/schema.ts +39 -0
- package/src/deploy/template-manager.ts +32 -0
- package/src/deploy/templates/github-workflow.yaml +23 -0
- package/src/deploy/types.ts +48 -16
- package/test/integration/commands/deploy.integration.test.ts +79 -3
- package/test/unit/commands/deploy.test.ts +96 -198
- package/test/unit/deploy/config-manager.test.ts +9 -5
- package/test/unit/deploy/providers/cloudflare.test.ts +95 -96
- package/test/unit/deploy/providers/dns-cloudflare.test.ts +148 -0
- package/test/unit/deploy/providers/github.test.ts +43 -47
- package/test/unit/deploy/providers/railway.test.ts +50 -261
- package/test/unit/deploy/registry.test.ts +20 -17
- package/tsconfig.json +1 -1
- package/tsup.config.ts +3 -0
- package/dist/chunk-2FKDEDDE.js.map +0 -1
- package/dist/chunk-EKCOW7FM.js.map +0 -1
- /package/dist/{chunk-GUUPSHWC.js.map → chunk-JEMIKBGX.js.map} +0 -0
- /package/dist/{chunk-OUGA4CB4.js.map → chunk-JS6WL5NS.js.map} +0 -0
- /package/dist/{chunk-GEESHGE4.js.map → chunk-L2RUXOL4.js.map} +0 -0
- /package/dist/{chunk-54HY52LH.js.map → chunk-QTJIGPQ3.js.map} +0 -0
- /package/dist/{chunk-2JW5BYZW.js.map → chunk-VKE7R2EZ.js.map} +0 -0
- /package/dist/{chunk-AC4B3HPJ.js.map → chunk-XONR27KC.js.map} +0 -0
- /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:
|
|
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: {},
|
|
26
|
+
options: {},
|
|
25
27
|
config: {
|
|
26
28
|
deploy: {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
new Error('
|
|
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
|
|
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
|
-
(
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
115
|
+
const mockFetch = vi.fn();
|
|
116
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
168
|
+
it('should return default secrets', async () => {
|
|
147
169
|
process.env.CLOUDFLARE_API_TOKEN = 'tok';
|
|
148
170
|
process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
186
|
-
const
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
'
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
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:
|
|
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: {
|
|
26
|
-
} as unknown as
|
|
27
|
-
(execAsync as
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
100
|
-
|
|
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
|
|
107
|
+
it('should support paths trigger', async () => {
|
|
104
108
|
const targets = [
|
|
105
109
|
{
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|