quicklify 0.1.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/.claude/settings.local.json +25 -0
- package/.github/workflows/ci.yml +42 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +82 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +2 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/digitalocean.d.ts +14 -0
- package/dist/providers/digitalocean.d.ts.map +1 -0
- package/dist/providers/digitalocean.js +29 -0
- package/dist/providers/digitalocean.js.map +1 -0
- package/dist/providers/hetzner.d.ts +15 -0
- package/dist/providers/hetzner.d.ts.map +1 -0
- package/dist/providers/hetzner.js +73 -0
- package/dist/providers/hetzner.js.map +1 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cloudInit.d.ts +2 -0
- package/dist/utils/cloudInit.d.ts.map +1 -0
- package/dist/utils/cloudInit.js +30 -0
- package/dist/utils/cloudInit.js.map +1 -0
- package/dist/utils/logger.d.ts +11 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +31 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/prompts.d.ts +5 -0
- package/dist/utils/prompts.d.ts.map +1 -0
- package/dist/utils/prompts.js +77 -0
- package/dist/utils/prompts.js.map +1 -0
- package/jest.config.cjs +30 -0
- package/package.json +41 -0
- package/src/commands/init.ts +101 -0
- package/src/index.ts +18 -0
- package/src/providers/base.ts +11 -0
- package/src/providers/digitalocean.ts +37 -0
- package/src/providers/hetzner.ts +85 -0
- package/src/types/index.ts +36 -0
- package/src/utils/cloudInit.ts +29 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/prompts.ts +84 -0
- package/tests/__mocks__/axios.ts +14 -0
- package/tests/__mocks__/chalk.ts +15 -0
- package/tests/__mocks__/inquirer.ts +5 -0
- package/tests/__mocks__/ora.ts +16 -0
- package/tests/e2e/init.test.ts +190 -0
- package/tests/integration/hetzner.test.ts +274 -0
- package/tests/unit/cloudInit.test.ts +53 -0
- package/tests/unit/logger.test.ts +76 -0
- package/tests/unit/prompts.test.ts +220 -0
- package/tests/unit/validators.test.ts +63 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +10 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { HetznerProvider } from '../../src/providers/hetzner';
|
|
3
|
+
|
|
4
|
+
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
5
|
+
|
|
6
|
+
describe('HetznerProvider', () => {
|
|
7
|
+
let provider: HetznerProvider;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
provider = new HetznerProvider('test-api-token');
|
|
11
|
+
jest.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('properties', () => {
|
|
15
|
+
it('should have name "hetzner"', () => {
|
|
16
|
+
expect(provider.name).toBe('hetzner');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should have displayName "Hetzner Cloud"', () => {
|
|
20
|
+
expect(provider.displayName).toBe('Hetzner Cloud');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getRegions', () => {
|
|
25
|
+
it('should return an array of regions', () => {
|
|
26
|
+
const regions = provider.getRegions();
|
|
27
|
+
expect(Array.isArray(regions)).toBe(true);
|
|
28
|
+
expect(regions.length).toBeGreaterThan(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should include Nuremberg (nbg1)', () => {
|
|
32
|
+
const regions = provider.getRegions();
|
|
33
|
+
const nbg = regions.find(r => r.id === 'nbg1');
|
|
34
|
+
expect(nbg).toBeDefined();
|
|
35
|
+
expect(nbg!.name).toBe('Nuremberg');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should include Falkenstein (fsn1)', () => {
|
|
39
|
+
const regions = provider.getRegions();
|
|
40
|
+
const fsn = regions.find(r => r.id === 'fsn1');
|
|
41
|
+
expect(fsn).toBeDefined();
|
|
42
|
+
expect(fsn!.name).toBe('Falkenstein');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should include Helsinki (hel1)', () => {
|
|
46
|
+
const regions = provider.getRegions();
|
|
47
|
+
const hel = regions.find(r => r.id === 'hel1');
|
|
48
|
+
expect(hel).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should include Ashburn (ash)', () => {
|
|
52
|
+
const regions = provider.getRegions();
|
|
53
|
+
const ash = regions.find(r => r.id === 'ash');
|
|
54
|
+
expect(ash).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should have id, name, and location for every region', () => {
|
|
58
|
+
const regions = provider.getRegions();
|
|
59
|
+
regions.forEach(region => {
|
|
60
|
+
expect(region.id).toBeTruthy();
|
|
61
|
+
expect(region.name).toBeTruthy();
|
|
62
|
+
expect(region.location).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('getServerSizes', () => {
|
|
68
|
+
it('should return an array of server sizes', () => {
|
|
69
|
+
const sizes = provider.getServerSizes();
|
|
70
|
+
expect(Array.isArray(sizes)).toBe(true);
|
|
71
|
+
expect(sizes.length).toBeGreaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should have exactly one recommended option', () => {
|
|
75
|
+
const sizes = provider.getServerSizes();
|
|
76
|
+
const recommended = sizes.filter(s => s.recommended);
|
|
77
|
+
expect(recommended).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should have CAX11 as the recommended option', () => {
|
|
81
|
+
const sizes = provider.getServerSizes();
|
|
82
|
+
const cax11 = sizes.find(s => s.id === 'cax11');
|
|
83
|
+
expect(cax11).toBeDefined();
|
|
84
|
+
expect(cax11!.recommended).toBe(true);
|
|
85
|
+
expect(cax11!.vcpu).toBe(2);
|
|
86
|
+
expect(cax11!.ram).toBe(4);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should have valid specs for every size', () => {
|
|
90
|
+
const sizes = provider.getServerSizes();
|
|
91
|
+
sizes.forEach(size => {
|
|
92
|
+
expect(size.id).toBeTruthy();
|
|
93
|
+
expect(size.name).toBeTruthy();
|
|
94
|
+
expect(size.vcpu).toBeGreaterThan(0);
|
|
95
|
+
expect(size.ram).toBeGreaterThan(0);
|
|
96
|
+
expect(size.disk).toBeGreaterThan(0);
|
|
97
|
+
expect(size.price).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should include both ARM64 (CAX) and x86 (CPX) options', () => {
|
|
102
|
+
const sizes = provider.getServerSizes();
|
|
103
|
+
const arm = sizes.filter(s => s.id.startsWith('cax'));
|
|
104
|
+
const x86 = sizes.filter(s => s.id.startsWith('cpx'));
|
|
105
|
+
expect(arm.length).toBeGreaterThan(0);
|
|
106
|
+
expect(x86.length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('validateToken', () => {
|
|
111
|
+
it('should return true for a valid token', async () => {
|
|
112
|
+
mockedAxios.get.mockResolvedValueOnce({ data: { servers: [] } });
|
|
113
|
+
|
|
114
|
+
const result = await provider.validateToken('valid-token');
|
|
115
|
+
|
|
116
|
+
expect(result).toBe(true);
|
|
117
|
+
expect(mockedAxios.get).toHaveBeenCalledWith(
|
|
118
|
+
'https://api.hetzner.cloud/v1/servers',
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
headers: { Authorization: 'Bearer valid-token' },
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return false for an invalid token', async () => {
|
|
126
|
+
mockedAxios.get.mockRejectedValueOnce(new Error('Unauthorized'));
|
|
127
|
+
|
|
128
|
+
const result = await provider.validateToken('bad-token');
|
|
129
|
+
|
|
130
|
+
expect(result).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return false on network error', async () => {
|
|
134
|
+
mockedAxios.get.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
135
|
+
|
|
136
|
+
const result = await provider.validateToken('any-token');
|
|
137
|
+
|
|
138
|
+
expect(result).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('createServer', () => {
|
|
143
|
+
const serverConfig = {
|
|
144
|
+
name: 'test-server',
|
|
145
|
+
size: 'cax11',
|
|
146
|
+
region: 'nbg1',
|
|
147
|
+
cloudInit: '#!/bin/bash\necho hello',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it('should create a server and return result', async () => {
|
|
151
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
152
|
+
data: {
|
|
153
|
+
server: {
|
|
154
|
+
id: 12345,
|
|
155
|
+
public_net: { ipv4: { ip: '1.2.3.4' } },
|
|
156
|
+
status: 'initializing',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await provider.createServer(serverConfig);
|
|
162
|
+
|
|
163
|
+
expect(result.id).toBe('12345');
|
|
164
|
+
expect(result.ip).toBe('1.2.3.4');
|
|
165
|
+
expect(result.status).toBe('initializing');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should send correct request payload to Hetzner API', async () => {
|
|
169
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
170
|
+
data: {
|
|
171
|
+
server: {
|
|
172
|
+
id: 1,
|
|
173
|
+
public_net: { ipv4: { ip: '10.0.0.1' } },
|
|
174
|
+
status: 'initializing',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await provider.createServer(serverConfig);
|
|
180
|
+
|
|
181
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
182
|
+
'https://api.hetzner.cloud/v1/servers',
|
|
183
|
+
{
|
|
184
|
+
name: 'test-server',
|
|
185
|
+
server_type: 'cax11',
|
|
186
|
+
location: 'nbg1',
|
|
187
|
+
image: 'ubuntu-24.04',
|
|
188
|
+
user_data: serverConfig.cloudInit,
|
|
189
|
+
},
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
headers: expect.objectContaining({
|
|
192
|
+
Authorization: 'Bearer test-api-token',
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
}),
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should throw with API error message on failure', async () => {
|
|
200
|
+
mockedAxios.post.mockRejectedValueOnce({
|
|
201
|
+
response: {
|
|
202
|
+
data: {
|
|
203
|
+
error: { message: 'server_limit_exceeded' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await expect(provider.createServer(serverConfig)).rejects.toThrow(
|
|
209
|
+
'Failed to create server: server_limit_exceeded'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should throw with generic message on network error', async () => {
|
|
214
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('Network Error'));
|
|
215
|
+
|
|
216
|
+
await expect(provider.createServer(serverConfig)).rejects.toThrow(
|
|
217
|
+
'Failed to create server: Network Error'
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should throw on timeout', async () => {
|
|
222
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('timeout of 30000ms exceeded'));
|
|
223
|
+
|
|
224
|
+
await expect(provider.createServer(serverConfig)).rejects.toThrow(
|
|
225
|
+
'Failed to create server: timeout of 30000ms exceeded'
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('getServerStatus', () => {
|
|
231
|
+
it('should return "running" for a running server', async () => {
|
|
232
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
233
|
+
data: { server: { status: 'running' } },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const status = await provider.getServerStatus('12345');
|
|
237
|
+
|
|
238
|
+
expect(status).toBe('running');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should return "initializing" for a new server', async () => {
|
|
242
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
243
|
+
data: { server: { status: 'initializing' } },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const status = await provider.getServerStatus('12345');
|
|
247
|
+
|
|
248
|
+
expect(status).toBe('initializing');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should call correct API endpoint with server ID', async () => {
|
|
252
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
253
|
+
data: { server: { status: 'running' } },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await provider.getServerStatus('99999');
|
|
257
|
+
|
|
258
|
+
expect(mockedAxios.get).toHaveBeenCalledWith(
|
|
259
|
+
'https://api.hetzner.cloud/v1/servers/99999',
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
headers: { Authorization: 'Bearer test-api-token' },
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should throw on error', async () => {
|
|
267
|
+
mockedAxios.get.mockRejectedValueOnce(new Error('Not Found'));
|
|
268
|
+
|
|
269
|
+
await expect(provider.getServerStatus('00000')).rejects.toThrow(
|
|
270
|
+
'Failed to get server status: Not Found'
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getCoolifyCloudInit } from '../../src/utils/cloudInit';
|
|
2
|
+
|
|
3
|
+
describe('getCoolifyCloudInit', () => {
|
|
4
|
+
it('should return a bash script starting with shebang', () => {
|
|
5
|
+
const script = getCoolifyCloudInit('test-server');
|
|
6
|
+
expect(script.startsWith('#!/bin/bash')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should include set -e for error handling', () => {
|
|
10
|
+
const script = getCoolifyCloudInit('test-server');
|
|
11
|
+
expect(script).toContain('set -e');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should include the server name in the output', () => {
|
|
15
|
+
const script = getCoolifyCloudInit('my-coolify');
|
|
16
|
+
expect(script).toContain('my-coolify');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should include Coolify install command', () => {
|
|
20
|
+
const script = getCoolifyCloudInit('test');
|
|
21
|
+
expect(script).toContain('curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should include system update step', () => {
|
|
25
|
+
const script = getCoolifyCloudInit('test');
|
|
26
|
+
expect(script).toContain('apt-get update -y');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should include service wait step', () => {
|
|
30
|
+
const script = getCoolifyCloudInit('test');
|
|
31
|
+
expect(script).toContain('sleep 30');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should handle different server names correctly', () => {
|
|
35
|
+
const script1 = getCoolifyCloudInit('server-alpha');
|
|
36
|
+
const script2 = getCoolifyCloudInit('production-01');
|
|
37
|
+
|
|
38
|
+
expect(script1).toContain('server-alpha');
|
|
39
|
+
expect(script1).not.toContain('production-01');
|
|
40
|
+
expect(script2).toContain('production-01');
|
|
41
|
+
expect(script2).not.toContain('server-alpha');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should include completion message', () => {
|
|
45
|
+
const script = getCoolifyCloudInit('test');
|
|
46
|
+
expect(script).toContain('Coolify installation completed');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should mention port 8000 for access', () => {
|
|
50
|
+
const script = getCoolifyCloudInit('test');
|
|
51
|
+
expect(script).toContain('8000');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { logger, createSpinner } from '../../src/utils/logger';
|
|
2
|
+
|
|
3
|
+
describe('logger', () => {
|
|
4
|
+
let consoleSpy: jest.SpyInstance;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
consoleSpy.mockRestore();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should log info messages', () => {
|
|
15
|
+
logger.info('test info');
|
|
16
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
17
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String), 'test info');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should log success messages', () => {
|
|
21
|
+
logger.success('task done');
|
|
22
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String), 'task done');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should log error messages', () => {
|
|
27
|
+
logger.error('something failed');
|
|
28
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String), 'something failed');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should log warning messages', () => {
|
|
33
|
+
logger.warning('be careful');
|
|
34
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String), 'be careful');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should log title with empty lines before and after', () => {
|
|
39
|
+
logger.title('My Title');
|
|
40
|
+
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should log step messages', () => {
|
|
44
|
+
logger.step('doing something');
|
|
45
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.any(String), 'doing something');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('createSpinner', () => {
|
|
51
|
+
it('should create a spinner with given text', () => {
|
|
52
|
+
const spinner = createSpinner('Loading...');
|
|
53
|
+
expect(spinner).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return spinner with start method', () => {
|
|
57
|
+
const spinner = createSpinner('Loading...');
|
|
58
|
+
expect(typeof spinner.start).toBe('function');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return spinner with succeed method', () => {
|
|
62
|
+
const spinner = createSpinner('Loading...');
|
|
63
|
+
expect(typeof spinner.succeed).toBe('function');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return spinner with fail method', () => {
|
|
67
|
+
const spinner = createSpinner('Loading...');
|
|
68
|
+
expect(typeof spinner.fail).toBe('function');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should allow chaining start', () => {
|
|
72
|
+
const spinner = createSpinner('Loading...');
|
|
73
|
+
const result = spinner.start();
|
|
74
|
+
expect(result).toBe(spinner);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { getDeploymentConfig, confirmDeployment } from '../../src/utils/prompts';
|
|
3
|
+
import type { CloudProvider } from '../../src/providers/base';
|
|
4
|
+
|
|
5
|
+
const mockedInquirer = inquirer as jest.Mocked<typeof inquirer>;
|
|
6
|
+
|
|
7
|
+
const mockProvider: CloudProvider = {
|
|
8
|
+
name: 'hetzner',
|
|
9
|
+
displayName: 'Hetzner Cloud',
|
|
10
|
+
validateToken: jest.fn(),
|
|
11
|
+
getRegions: () => [
|
|
12
|
+
{ id: 'nbg1', name: 'Nuremberg', location: 'Germany' },
|
|
13
|
+
{ id: 'fsn1', name: 'Falkenstein', location: 'Germany' },
|
|
14
|
+
],
|
|
15
|
+
getServerSizes: () => [
|
|
16
|
+
{ id: 'cax11', name: 'CAX11', vcpu: 2, ram: 4, disk: 40, price: '€3.85/mo', recommended: true },
|
|
17
|
+
{ id: 'cpx11', name: 'CPX11', vcpu: 2, ram: 2, disk: 40, price: '€4.15/mo' },
|
|
18
|
+
],
|
|
19
|
+
createServer: jest.fn(),
|
|
20
|
+
getServerStatus: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('getDeploymentConfig', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return deployment config from user input', async () => {
|
|
29
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
30
|
+
apiToken: 'my-token',
|
|
31
|
+
region: 'nbg1',
|
|
32
|
+
size: 'cax11',
|
|
33
|
+
serverName: 'my-server',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const config = await getDeploymentConfig(mockProvider);
|
|
37
|
+
|
|
38
|
+
expect(config.provider).toBe('hetzner');
|
|
39
|
+
expect(config.apiToken).toBe('my-token');
|
|
40
|
+
expect(config.region).toBe('nbg1');
|
|
41
|
+
expect(config.serverSize).toBe('cax11');
|
|
42
|
+
expect(config.serverName).toBe('my-server');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should trim apiToken whitespace', async () => {
|
|
46
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
47
|
+
apiToken: ' token-with-spaces ',
|
|
48
|
+
region: 'nbg1',
|
|
49
|
+
size: 'cax11',
|
|
50
|
+
serverName: 'server',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const config = await getDeploymentConfig(mockProvider);
|
|
54
|
+
expect(config.apiToken).toBe('token-with-spaces');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should trim serverName whitespace', async () => {
|
|
58
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
59
|
+
apiToken: 'token',
|
|
60
|
+
region: 'nbg1',
|
|
61
|
+
size: 'cax11',
|
|
62
|
+
serverName: ' my-server ',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const config = await getDeploymentConfig(mockProvider);
|
|
66
|
+
expect(config.serverName).toBe('my-server');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should pass correct prompt config with regions and sizes', async () => {
|
|
70
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
71
|
+
apiToken: 'token',
|
|
72
|
+
region: 'nbg1',
|
|
73
|
+
size: 'cax11',
|
|
74
|
+
serverName: 'server',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await getDeploymentConfig(mockProvider);
|
|
78
|
+
|
|
79
|
+
const promptConfig = mockedInquirer.prompt.mock.calls[0][0] as any[];
|
|
80
|
+
|
|
81
|
+
// Check password prompt for API token
|
|
82
|
+
expect(promptConfig[0].type).toBe('password');
|
|
83
|
+
expect(promptConfig[0].name).toBe('apiToken');
|
|
84
|
+
|
|
85
|
+
// Check region list has choices from provider
|
|
86
|
+
expect(promptConfig[1].type).toBe('list');
|
|
87
|
+
expect(promptConfig[1].choices).toHaveLength(2);
|
|
88
|
+
|
|
89
|
+
// Check size list has recommended marker
|
|
90
|
+
expect(promptConfig[2].type).toBe('list');
|
|
91
|
+
const sizeChoices = promptConfig[2].choices;
|
|
92
|
+
const recommendedChoice = sizeChoices.find((c: any) => c.name.includes('Recommended'));
|
|
93
|
+
expect(recommendedChoice).toBeDefined();
|
|
94
|
+
|
|
95
|
+
// Check server name input with default
|
|
96
|
+
expect(promptConfig[3].type).toBe('input');
|
|
97
|
+
expect(promptConfig[3].default).toBe('coolify-server');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('apiToken validator', () => {
|
|
101
|
+
let validateToken: (input: string) => string | true;
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
105
|
+
apiToken: 'x', region: 'nbg1', size: 'cax11', serverName: 's',
|
|
106
|
+
});
|
|
107
|
+
await getDeploymentConfig(mockProvider);
|
|
108
|
+
const promptConfig = mockedInquirer.prompt.mock.calls[0][0] as any[];
|
|
109
|
+
validateToken = promptConfig[0].validate;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should accept valid token', () => {
|
|
113
|
+
expect(validateToken('valid-api-token')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should reject empty string', () => {
|
|
117
|
+
expect(validateToken('')).toBe('API token is required');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should reject whitespace-only string', () => {
|
|
121
|
+
expect(validateToken(' ')).toBe('API token is required');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('serverName validator', () => {
|
|
126
|
+
let validateName: (input: string) => string | true;
|
|
127
|
+
|
|
128
|
+
beforeEach(async () => {
|
|
129
|
+
mockedInquirer.prompt.mockResolvedValueOnce({
|
|
130
|
+
apiToken: 'x', region: 'nbg1', size: 'cax11', serverName: 's',
|
|
131
|
+
});
|
|
132
|
+
await getDeploymentConfig(mockProvider);
|
|
133
|
+
const promptConfig = mockedInquirer.prompt.mock.calls[0][0] as any[];
|
|
134
|
+
validateName = promptConfig[3].validate;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should accept valid lowercase name', () => {
|
|
138
|
+
expect(validateName('coolify-server')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should accept name with numbers', () => {
|
|
142
|
+
expect(validateName('server-01')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should reject empty string', () => {
|
|
146
|
+
expect(validateName('')).toBe('Server name is required');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should reject whitespace-only string', () => {
|
|
150
|
+
expect(validateName(' ')).toBe('Server name is required');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should reject uppercase letters', () => {
|
|
154
|
+
expect(validateName('MyServer')).toBe('Server name must contain only lowercase letters, numbers, and hyphens');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should reject underscores', () => {
|
|
158
|
+
expect(validateName('my_server')).toBe('Server name must contain only lowercase letters, numbers, and hyphens');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should reject dots', () => {
|
|
162
|
+
expect(validateName('server.com')).toBe('Server name must contain only lowercase letters, numbers, and hyphens');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should reject spaces', () => {
|
|
166
|
+
expect(validateName('my server')).toBe('Server name must contain only lowercase letters, numbers, and hyphens');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('confirmDeployment', () => {
|
|
172
|
+
let consoleSpy: jest.SpyInstance;
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
176
|
+
jest.clearAllMocks();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
consoleSpy.mockRestore();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return true when user confirms', async () => {
|
|
184
|
+
mockedInquirer.prompt.mockResolvedValueOnce({ confirm: true });
|
|
185
|
+
|
|
186
|
+
const result = await confirmDeployment(
|
|
187
|
+
{ provider: 'hetzner', apiToken: 'x', region: 'nbg1', serverSize: 'cax11', serverName: 'server' },
|
|
188
|
+
mockProvider,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should return false when user declines', async () => {
|
|
195
|
+
mockedInquirer.prompt.mockResolvedValueOnce({ confirm: false });
|
|
196
|
+
|
|
197
|
+
const result = await confirmDeployment(
|
|
198
|
+
{ provider: 'hetzner', apiToken: 'x', region: 'nbg1', serverSize: 'cax11', serverName: 'server' },
|
|
199
|
+
mockProvider,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(result).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should print deployment summary with correct details', async () => {
|
|
206
|
+
mockedInquirer.prompt.mockResolvedValueOnce({ confirm: true });
|
|
207
|
+
|
|
208
|
+
await confirmDeployment(
|
|
209
|
+
{ provider: 'hetzner', apiToken: 'x', region: 'nbg1', serverSize: 'cax11', serverName: 'my-server' },
|
|
210
|
+
mockProvider,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const output = consoleSpy.mock.calls.map((c: any[]) => c.join(' ')).join('\n');
|
|
214
|
+
expect(output).toContain('Hetzner Cloud');
|
|
215
|
+
expect(output).toContain('Nuremberg');
|
|
216
|
+
expect(output).toContain('CAX11');
|
|
217
|
+
expect(output).toContain('€3.85/mo');
|
|
218
|
+
expect(output).toContain('my-server');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
describe('Server name validation', () => {
|
|
2
|
+
const serverNameRegex = /^[a-z0-9-]+$/;
|
|
3
|
+
|
|
4
|
+
it('should accept valid lowercase server names', () => {
|
|
5
|
+
expect(serverNameRegex.test('coolify-server')).toBe(true);
|
|
6
|
+
expect(serverNameRegex.test('my-server-1')).toBe(true);
|
|
7
|
+
expect(serverNameRegex.test('server')).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should accept numeric-only names', () => {
|
|
11
|
+
expect(serverNameRegex.test('123')).toBe(true);
|
|
12
|
+
expect(serverNameRegex.test('1-2-3')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should accept hyphenated names', () => {
|
|
16
|
+
expect(serverNameRegex.test('my-cool-server')).toBe(true);
|
|
17
|
+
expect(serverNameRegex.test('a-b-c')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should reject names with uppercase letters', () => {
|
|
21
|
+
expect(serverNameRegex.test('MyServer')).toBe(false);
|
|
22
|
+
expect(serverNameRegex.test('COOLIFY')).toBe(false);
|
|
23
|
+
expect(serverNameRegex.test('Server')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should reject names with spaces', () => {
|
|
27
|
+
expect(serverNameRegex.test('my server')).toBe(false);
|
|
28
|
+
expect(serverNameRegex.test(' server')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should reject names with special characters', () => {
|
|
32
|
+
expect(serverNameRegex.test('server!')).toBe(false);
|
|
33
|
+
expect(serverNameRegex.test('server@1')).toBe(false);
|
|
34
|
+
expect(serverNameRegex.test('server.com')).toBe(false);
|
|
35
|
+
expect(serverNameRegex.test('server_name')).toBe(false);
|
|
36
|
+
expect(serverNameRegex.test('server#1')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should reject empty strings', () => {
|
|
40
|
+
expect(serverNameRegex.test('')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('API token validation', () => {
|
|
45
|
+
const isTokenValid = (input: string) => {
|
|
46
|
+
if (!input || input.trim().length === 0) return false;
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it('should accept non-empty tokens', () => {
|
|
51
|
+
expect(isTokenValid('abc123')).toBe(true);
|
|
52
|
+
expect(isTokenValid('some-long-api-token-here')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should reject empty tokens', () => {
|
|
56
|
+
expect(isTokenValid('')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should reject whitespace-only tokens', () => {
|
|
60
|
+
expect(isTokenValid(' ')).toBe(false);
|
|
61
|
+
expect(isTokenValid('\t')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|