@capawesome/cli 3.10.2 → 4.0.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/CHANGELOG.md +34 -0
- package/dist/commands/apps/builds/cancel.js +1 -1
- package/dist/commands/apps/builds/create.js +58 -50
- package/dist/commands/apps/builds/download.js +27 -3
- package/dist/commands/apps/bundles/create.js +5 -449
- package/dist/commands/apps/bundles/delete.js +3 -68
- package/dist/commands/apps/bundles/update.js +3 -66
- package/dist/commands/apps/channels/create.js +5 -8
- package/dist/commands/apps/channels/create.test.js +6 -9
- package/dist/commands/apps/channels/delete.js +13 -4
- package/dist/commands/apps/channels/delete.test.js +22 -7
- package/dist/commands/apps/channels/get.js +2 -12
- package/dist/commands/apps/channels/get.test.js +1 -2
- package/dist/commands/apps/channels/list.js +36 -12
- package/dist/commands/apps/channels/list.test.js +3 -4
- package/dist/commands/apps/channels/pause.js +85 -0
- package/dist/commands/apps/channels/resume.js +85 -0
- package/dist/commands/apps/channels/update.js +4 -7
- package/dist/commands/apps/channels/update.test.js +2 -4
- package/dist/commands/apps/create.js +1 -1
- package/dist/commands/apps/delete.js +3 -2
- package/dist/commands/apps/deployments/cancel.js +1 -1
- package/dist/commands/apps/deployments/create.js +82 -31
- package/dist/commands/apps/devices/delete.js +3 -2
- package/dist/commands/apps/environments/create.js +68 -0
- package/dist/commands/apps/environments/delete.js +88 -0
- package/dist/commands/apps/environments/list.js +69 -0
- package/dist/commands/apps/environments/set.js +126 -0
- package/dist/commands/apps/environments/unset.js +98 -0
- package/dist/commands/apps/liveupdates/bundle.js +117 -0
- package/dist/commands/apps/liveupdates/generate-manifest.js +39 -0
- package/dist/commands/{manifests/generate.test.js → apps/liveupdates/generate-manifest.test.js} +6 -6
- package/dist/commands/apps/liveupdates/register.js +291 -0
- package/dist/commands/apps/{bundles/create.test.js → liveupdates/register.test.js} +123 -111
- package/dist/commands/apps/liveupdates/rollback.js +171 -0
- package/dist/commands/apps/liveupdates/rollout.js +147 -0
- package/dist/commands/apps/liveupdates/upload.js +420 -0
- package/dist/commands/apps/liveupdates/upload.test.js +325 -0
- package/dist/commands/manifests/generate.js +2 -27
- package/dist/commands/organizations/create.js +1 -1
- package/dist/index.js +13 -0
- package/dist/services/app-builds.js +9 -2
- package/dist/services/app-channels.js +19 -0
- package/dist/services/app-deployments.js +24 -14
- package/dist/services/app-environments.js +67 -2
- package/dist/services/config.js +2 -0
- package/dist/utils/app-environments.js +35 -0
- package/dist/utils/app-environments.ts.test.js +76 -0
- package/dist/utils/time-format.js +26 -0
- package/package.json +3 -3
- package/dist/commands/apps/bundles/delete.test.js +0 -142
- package/dist/commands/apps/bundles/update.test.js +0 -144
- package/dist/utils/capacitor-config.js +0 -96
- package/dist/utils/package-json.js +0 -58
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
|
|
2
2
|
import authorizationService from '../../../services/authorization-service.js';
|
|
3
|
-
import {
|
|
4
|
-
import { fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file.js';
|
|
5
|
-
import { findPackageJsonPath } from '../../../utils/package-json.js';
|
|
3
|
+
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
6
4
|
import userConfig from '../../../utils/user-config.js';
|
|
7
5
|
import consola from 'consola';
|
|
8
6
|
import nock from 'nock';
|
|
9
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import
|
|
8
|
+
import registerCommand from './register.js';
|
|
11
9
|
// Mock dependencies
|
|
12
10
|
vi.mock('@/utils/user-config.js');
|
|
13
11
|
vi.mock('@/services/authorization-service.js');
|
|
@@ -17,25 +15,17 @@ vi.mock('@/utils/buffer.js');
|
|
|
17
15
|
vi.mock('@/utils/private-key.js');
|
|
18
16
|
vi.mock('@/utils/hash.js');
|
|
19
17
|
vi.mock('@/utils/signature.js');
|
|
20
|
-
vi.mock('@/utils/capacitor-config.js');
|
|
21
|
-
vi.mock('@/utils/package-json.js');
|
|
22
18
|
vi.mock('consola');
|
|
23
|
-
describe('apps-
|
|
19
|
+
describe('apps-liveupdates-register', () => {
|
|
24
20
|
const mockUserConfig = vi.mocked(userConfig);
|
|
25
21
|
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
26
22
|
const mockFileExistsAtPath = vi.mocked(fileExistsAtPath);
|
|
27
|
-
const mockGetFilesInDirectoryAndSubdirectories = vi.mocked(getFilesInDirectoryAndSubdirectories);
|
|
28
|
-
const mockIsDirectory = vi.mocked(isDirectory);
|
|
29
|
-
const mockFindCapacitorConfigPath = vi.mocked(findCapacitorConfigPath);
|
|
30
|
-
const mockFindPackageJsonPath = vi.mocked(findPackageJsonPath);
|
|
31
23
|
const mockConsola = vi.mocked(consola);
|
|
32
24
|
beforeEach(() => {
|
|
33
25
|
vi.clearAllMocks();
|
|
34
26
|
mockUserConfig.read.mockReturnValue({ token: 'test-token' });
|
|
35
27
|
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
36
28
|
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
|
|
37
|
-
mockFindCapacitorConfigPath.mockResolvedValue(undefined);
|
|
38
|
-
mockFindPackageJsonPath.mockResolvedValue(undefined);
|
|
39
29
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
40
30
|
throw new Error(`Process exited with code ${code}`);
|
|
41
31
|
});
|
|
@@ -46,35 +36,23 @@ describe('apps-bundles-create', () => {
|
|
|
46
36
|
});
|
|
47
37
|
it('should require authentication', async () => {
|
|
48
38
|
const appId = 'app-123';
|
|
49
|
-
const
|
|
39
|
+
const bundleUrl = 'https://example.com/bundle.zip';
|
|
40
|
+
const options = { appId, url: bundleUrl, rolloutPercentage: 1 };
|
|
50
41
|
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(false);
|
|
51
|
-
await expect(
|
|
42
|
+
await expect(registerCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
52
43
|
expect(mockConsola.error).toHaveBeenCalledWith('You must be logged in to run this command. Please run the `login` command first.');
|
|
53
44
|
});
|
|
54
|
-
it('should
|
|
45
|
+
it('should register bundle with self-hosted URL', async () => {
|
|
55
46
|
const appId = 'app-123';
|
|
56
47
|
const bundleUrl = 'https://example.com/bundle.zip';
|
|
57
|
-
const bundlePath = './bundle.zip';
|
|
58
|
-
const testHash = 'test-hash';
|
|
59
48
|
const bundleId = 'bundle-456';
|
|
60
49
|
const testToken = 'test-token';
|
|
61
|
-
const testBuffer = Buffer.from('test');
|
|
62
50
|
const options = {
|
|
63
51
|
appId,
|
|
64
52
|
url: bundleUrl,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
rollout: 1,
|
|
53
|
+
rolloutPercentage: 1,
|
|
54
|
+
yes: true,
|
|
68
55
|
};
|
|
69
|
-
mockFileExistsAtPath.mockResolvedValue(true);
|
|
70
|
-
mockIsDirectory.mockResolvedValue(false);
|
|
71
|
-
// Mock utility functions
|
|
72
|
-
const mockZip = await import('../../../utils/zip.js');
|
|
73
|
-
const mockBuffer = await import('../../../utils/buffer.js');
|
|
74
|
-
const mockHash = await import('../../../utils/hash.js');
|
|
75
|
-
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
|
|
76
|
-
vi.mocked(mockBuffer.createBufferFromPath).mockResolvedValue(testBuffer);
|
|
77
|
-
vi.mocked(mockHash.createHash).mockResolvedValue(testHash);
|
|
78
56
|
const appScope = nock(DEFAULT_API_BASE_URL)
|
|
79
57
|
.get(`/v1/apps/${appId}`)
|
|
80
58
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
@@ -83,78 +61,94 @@ describe('apps-bundles-create', () => {
|
|
|
83
61
|
.post(`/v1/apps/${appId}/bundles`, {
|
|
84
62
|
appId,
|
|
85
63
|
url: bundleUrl,
|
|
86
|
-
checksum: testHash,
|
|
87
64
|
artifactType: 'zip',
|
|
88
|
-
rolloutPercentage:
|
|
65
|
+
rolloutPercentage: 0.01,
|
|
89
66
|
})
|
|
90
67
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
91
|
-
.reply(201, { id: bundleId });
|
|
92
|
-
await
|
|
68
|
+
.reply(201, { id: bundleId, appBuildId: 'build-789' });
|
|
69
|
+
await registerCommand.action(options, undefined);
|
|
93
70
|
expect(appScope.isDone()).toBe(true);
|
|
94
71
|
expect(bundleScope.isDone()).toBe(true);
|
|
95
|
-
expect(mockConsola.
|
|
96
|
-
expect(mockConsola.
|
|
72
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
|
|
73
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
|
|
97
74
|
});
|
|
98
|
-
it('should
|
|
99
|
-
const appId = 'app-123';
|
|
100
|
-
const nonexistentPath = './nonexistent';
|
|
101
|
-
const options = { appId, path: nonexistentPath, artifactType: 'zip', rollout: 1 };
|
|
102
|
-
mockFileExistsAtPath.mockResolvedValue(false);
|
|
103
|
-
await expect(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
104
|
-
expect(mockConsola.error).toHaveBeenCalledWith('The path does not exist.');
|
|
105
|
-
});
|
|
106
|
-
it('should validate manifest artifact type requires directory', async () => {
|
|
107
|
-
const appId = 'app-123';
|
|
108
|
-
const bundlePath = './bundle.zip';
|
|
109
|
-
const options = {
|
|
110
|
-
appId,
|
|
111
|
-
path: bundlePath,
|
|
112
|
-
artifactType: 'manifest',
|
|
113
|
-
rollout: 1,
|
|
114
|
-
};
|
|
115
|
-
mockFileExistsAtPath.mockResolvedValue(true);
|
|
116
|
-
mockIsDirectory.mockResolvedValue(false);
|
|
117
|
-
// Mock zip utility to return true so path validation passes
|
|
118
|
-
const mockZip = await import('../../../utils/zip.js');
|
|
119
|
-
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
|
|
120
|
-
await expect(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
121
|
-
expect(mockConsola.error).toHaveBeenCalledWith('The path must be a folder when creating a bundle with an artifact type of `manifest`.');
|
|
122
|
-
});
|
|
123
|
-
it('should validate manifest artifact type cannot use URL', async () => {
|
|
75
|
+
it('should pass gitRef to API when provided', async () => {
|
|
124
76
|
const appId = 'app-123';
|
|
125
77
|
const bundleUrl = 'https://example.com/bundle.zip';
|
|
78
|
+
const bundleId = 'bundle-456';
|
|
79
|
+
const testToken = 'test-token';
|
|
80
|
+
const gitRef = 'v1.0.0';
|
|
126
81
|
const options = {
|
|
127
82
|
appId,
|
|
128
83
|
url: bundleUrl,
|
|
129
|
-
|
|
130
|
-
|
|
84
|
+
rolloutPercentage: 1,
|
|
85
|
+
gitRef,
|
|
86
|
+
yes: true,
|
|
131
87
|
};
|
|
132
|
-
|
|
133
|
-
|
|
88
|
+
const appScope = nock(DEFAULT_API_BASE_URL)
|
|
89
|
+
.get(`/v1/apps/${appId}`)
|
|
90
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
91
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
92
|
+
const bundleScope = nock(DEFAULT_API_BASE_URL)
|
|
93
|
+
.post(`/v1/apps/${appId}/bundles`, {
|
|
94
|
+
appId,
|
|
95
|
+
url: bundleUrl,
|
|
96
|
+
artifactType: 'zip',
|
|
97
|
+
gitRef,
|
|
98
|
+
rolloutPercentage: 0.01,
|
|
99
|
+
})
|
|
100
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
101
|
+
.reply(201, { id: bundleId, appBuildId: 'build-789' });
|
|
102
|
+
await registerCommand.action(options, undefined);
|
|
103
|
+
expect(appScope.isDone()).toBe(true);
|
|
104
|
+
expect(bundleScope.isDone()).toBe(true);
|
|
105
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
|
|
106
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
|
|
134
107
|
});
|
|
135
|
-
it('should
|
|
108
|
+
it('should register bundle with checksum when path is provided', async () => {
|
|
136
109
|
const appId = 'app-123';
|
|
137
110
|
const bundleUrl = 'https://example.com/bundle.zip';
|
|
111
|
+
const bundlePath = './bundle.zip';
|
|
112
|
+
const testHash = 'test-hash';
|
|
113
|
+
const bundleId = 'bundle-456';
|
|
138
114
|
const testToken = 'test-token';
|
|
115
|
+
const testBuffer = Buffer.from('test');
|
|
139
116
|
const options = {
|
|
140
117
|
appId,
|
|
141
118
|
url: bundleUrl,
|
|
142
|
-
|
|
143
|
-
|
|
119
|
+
path: bundlePath,
|
|
120
|
+
rolloutPercentage: 1,
|
|
121
|
+
yes: true,
|
|
144
122
|
};
|
|
123
|
+
mockFileExistsAtPath.mockResolvedValue(true);
|
|
124
|
+
// Mock utility functions
|
|
125
|
+
const mockZip = await import('../../../utils/zip.js');
|
|
126
|
+
const mockBuffer = await import('../../../utils/buffer.js');
|
|
127
|
+
const mockHash = await import('../../../utils/hash.js');
|
|
128
|
+
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
|
|
129
|
+
vi.mocked(mockBuffer.createBufferFromPath).mockResolvedValue(testBuffer);
|
|
130
|
+
vi.mocked(mockHash.createHash).mockResolvedValue(testHash);
|
|
145
131
|
const appScope = nock(DEFAULT_API_BASE_URL)
|
|
146
132
|
.get(`/v1/apps/${appId}`)
|
|
147
133
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
148
134
|
.reply(200, { id: appId, name: 'Test App' });
|
|
149
135
|
const bundleScope = nock(DEFAULT_API_BASE_URL)
|
|
150
|
-
.post(`/v1/apps/${appId}/bundles
|
|
136
|
+
.post(`/v1/apps/${appId}/bundles`, {
|
|
137
|
+
appId,
|
|
138
|
+
url: bundleUrl,
|
|
139
|
+
checksum: testHash,
|
|
140
|
+
artifactType: 'zip',
|
|
141
|
+
rolloutPercentage: 0.01,
|
|
142
|
+
})
|
|
151
143
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
152
|
-
.reply(
|
|
153
|
-
await
|
|
144
|
+
.reply(201, { id: bundleId, appBuildId: 'build-789' });
|
|
145
|
+
await registerCommand.action(options, undefined);
|
|
154
146
|
expect(appScope.isDone()).toBe(true);
|
|
155
147
|
expect(bundleScope.isDone()).toBe(true);
|
|
148
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
|
|
149
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
|
|
156
150
|
});
|
|
157
|
-
it('should
|
|
151
|
+
it('should register bundle with signature when private key is provided', async () => {
|
|
158
152
|
const appId = 'app-123';
|
|
159
153
|
const bundleUrl = 'https://example.com/bundle.zip';
|
|
160
154
|
const bundlePath = './bundle.zip';
|
|
@@ -169,8 +163,8 @@ describe('apps-bundles-create', () => {
|
|
|
169
163
|
url: bundleUrl,
|
|
170
164
|
path: bundlePath,
|
|
171
165
|
privateKey: privateKeyPath,
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
rolloutPercentage: 1,
|
|
167
|
+
yes: true,
|
|
174
168
|
};
|
|
175
169
|
mockFileExistsAtPath.mockImplementation((path) => {
|
|
176
170
|
if (path === privateKeyPath)
|
|
@@ -179,7 +173,6 @@ describe('apps-bundles-create', () => {
|
|
|
179
173
|
return Promise.resolve(true);
|
|
180
174
|
return Promise.resolve(false);
|
|
181
175
|
});
|
|
182
|
-
mockIsDirectory.mockResolvedValue(false);
|
|
183
176
|
// Mock utility functions
|
|
184
177
|
const mockZip = await import('../../../utils/zip.js');
|
|
185
178
|
const mockBuffer = await import('../../../utils/buffer.js');
|
|
@@ -204,16 +197,17 @@ describe('apps-bundles-create', () => {
|
|
|
204
197
|
checksum: testHash,
|
|
205
198
|
signature: testSignature,
|
|
206
199
|
artifactType: 'zip',
|
|
207
|
-
rolloutPercentage:
|
|
200
|
+
rolloutPercentage: 0.01,
|
|
208
201
|
})
|
|
209
202
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
210
|
-
.reply(201, { id: bundleId });
|
|
211
|
-
await
|
|
203
|
+
.reply(201, { id: bundleId, appBuildId: 'build-789' });
|
|
204
|
+
await registerCommand.action(options, undefined);
|
|
212
205
|
expect(appScope.isDone()).toBe(true);
|
|
213
206
|
expect(bundleScope.isDone()).toBe(true);
|
|
214
|
-
expect(mockConsola.
|
|
207
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
|
|
208
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
|
|
215
209
|
});
|
|
216
|
-
it('should handle private key plain text content', async () => {
|
|
210
|
+
it('should handle private key with plain text content', async () => {
|
|
217
211
|
const appId = 'app-123';
|
|
218
212
|
const bundleUrl = 'https://example.com/bundle.zip';
|
|
219
213
|
const bundlePath = './bundle.zip';
|
|
@@ -228,11 +222,10 @@ describe('apps-bundles-create', () => {
|
|
|
228
222
|
url: bundleUrl,
|
|
229
223
|
path: bundlePath,
|
|
230
224
|
privateKey: privateKeyContent,
|
|
231
|
-
|
|
232
|
-
|
|
225
|
+
rolloutPercentage: 1,
|
|
226
|
+
yes: true,
|
|
233
227
|
};
|
|
234
228
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
235
|
-
mockIsDirectory.mockResolvedValue(false);
|
|
236
229
|
// Mock utility functions
|
|
237
230
|
const mockZip = await import('../../../utils/zip.js');
|
|
238
231
|
const mockBuffer = await import('../../../utils/buffer.js');
|
|
@@ -257,59 +250,78 @@ describe('apps-bundles-create', () => {
|
|
|
257
250
|
checksum: testHash,
|
|
258
251
|
signature: testSignature,
|
|
259
252
|
artifactType: 'zip',
|
|
260
|
-
rolloutPercentage:
|
|
253
|
+
rolloutPercentage: 0.01,
|
|
261
254
|
})
|
|
262
255
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
263
|
-
.reply(201, { id: bundleId });
|
|
264
|
-
await
|
|
256
|
+
.reply(201, { id: bundleId, appBuildId: 'build-789' });
|
|
257
|
+
await registerCommand.action(options, undefined);
|
|
265
258
|
expect(appScope.isDone()).toBe(true);
|
|
266
259
|
expect(bundleScope.isDone()).toBe(true);
|
|
267
|
-
expect(mockConsola.
|
|
260
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
|
|
261
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
|
|
268
262
|
});
|
|
269
263
|
it('should handle private key file not found', async () => {
|
|
270
264
|
const appId = 'app-123';
|
|
265
|
+
const bundleUrl = 'https://example.com/bundle.zip';
|
|
266
|
+
const bundlePath = './bundle.zip';
|
|
271
267
|
const privateKeyPath = 'nonexistent-key.pem';
|
|
272
268
|
const options = {
|
|
273
269
|
appId,
|
|
274
|
-
|
|
270
|
+
url: bundleUrl,
|
|
271
|
+
path: bundlePath,
|
|
275
272
|
privateKey: privateKeyPath,
|
|
276
|
-
|
|
277
|
-
rollout: 1,
|
|
273
|
+
rolloutPercentage: 1,
|
|
278
274
|
};
|
|
279
275
|
mockFileExistsAtPath.mockImplementation((path) => {
|
|
280
276
|
if (path === privateKeyPath)
|
|
281
277
|
return Promise.resolve(false);
|
|
282
278
|
return Promise.resolve(true);
|
|
283
279
|
});
|
|
284
|
-
mockIsDirectory.mockResolvedValue(true);
|
|
285
|
-
mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
|
|
286
|
-
{ href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
|
|
287
|
-
]);
|
|
288
280
|
// Mock utility functions
|
|
281
|
+
const mockZip = await import('../../../utils/zip.js');
|
|
289
282
|
const mockBuffer = await import('../../../utils/buffer.js');
|
|
283
|
+
vi.mocked(mockZip.default.isZipped).mockReturnValue(true);
|
|
290
284
|
vi.mocked(mockBuffer.isPrivateKeyContent).mockReturnValue(false);
|
|
291
|
-
await expect(
|
|
285
|
+
await expect(registerCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
292
286
|
expect(mockConsola.error).toHaveBeenCalledWith('Private key file not found.');
|
|
293
287
|
});
|
|
294
|
-
it('should
|
|
288
|
+
it('should validate path must be a zip file', async () => {
|
|
295
289
|
const appId = 'app-123';
|
|
296
|
-
const
|
|
290
|
+
const bundleUrl = 'https://example.com/bundle.zip';
|
|
291
|
+
const bundlePath = './dist';
|
|
297
292
|
const options = {
|
|
298
293
|
appId,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
rollout: 1,
|
|
294
|
+
url: bundleUrl,
|
|
295
|
+
path: bundlePath,
|
|
296
|
+
rolloutPercentage: 1,
|
|
303
297
|
};
|
|
304
298
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
305
|
-
|
|
306
|
-
// Mock zip utility to pass path validation
|
|
299
|
+
// Mock zip utility to return false
|
|
307
300
|
const mockZip = await import('../../../utils/zip.js');
|
|
308
|
-
vi.mocked(mockZip.default.isZipped).mockReturnValue(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
301
|
+
vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
|
|
302
|
+
await expect(registerCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
303
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The path must be a zip file when providing a URL.');
|
|
304
|
+
});
|
|
305
|
+
it('should handle API error during registration', async () => {
|
|
306
|
+
const appId = 'app-123';
|
|
307
|
+
const bundleUrl = 'https://example.com/bundle.zip';
|
|
308
|
+
const testToken = 'test-token';
|
|
309
|
+
const options = {
|
|
310
|
+
appId,
|
|
311
|
+
url: bundleUrl,
|
|
312
|
+
rolloutPercentage: 1,
|
|
313
|
+
yes: true,
|
|
314
|
+
};
|
|
315
|
+
const appScope = nock(DEFAULT_API_BASE_URL)
|
|
316
|
+
.get(`/v1/apps/${appId}`)
|
|
317
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
318
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
319
|
+
const bundleScope = nock(DEFAULT_API_BASE_URL)
|
|
320
|
+
.post(`/v1/apps/${appId}/bundles`)
|
|
321
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
322
|
+
.reply(400, { message: 'Invalid bundle data' });
|
|
323
|
+
await expect(registerCommand.action(options, undefined)).rejects.toThrow();
|
|
324
|
+
expect(appScope.isDone()).toBe(true);
|
|
325
|
+
expect(bundleScope.isDone()).toBe(true);
|
|
314
326
|
});
|
|
315
327
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import appChannelsService from '../../../services/app-channels.js';
|
|
3
|
+
import appDeploymentsService from '../../../services/app-deployments.js';
|
|
4
|
+
import appsService from '../../../services/apps.js';
|
|
5
|
+
import authorizationService from '../../../services/authorization-service.js';
|
|
6
|
+
import organizationsService from '../../../services/organizations.js';
|
|
7
|
+
import { isInteractive } from '../../../utils/environment.js';
|
|
8
|
+
import { prompt } from '../../../utils/prompt.js';
|
|
9
|
+
import { formatTimeAgo } from '../../../utils/time-format.js';
|
|
10
|
+
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
11
|
+
import consola from 'consola';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
export default defineCommand({
|
|
14
|
+
description: 'Rollback the active build in a channel to a previous build.',
|
|
15
|
+
options: defineOptions(z.object({
|
|
16
|
+
appId: z
|
|
17
|
+
.uuid({
|
|
18
|
+
message: 'App ID must be a UUID.',
|
|
19
|
+
})
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('App ID of the channel.'),
|
|
22
|
+
channel: z.string().optional().describe('Name of the channel to rollback.'),
|
|
23
|
+
steps: z.coerce
|
|
24
|
+
.number()
|
|
25
|
+
.int({
|
|
26
|
+
message: 'Steps must be an integer.',
|
|
27
|
+
})
|
|
28
|
+
.min(1, {
|
|
29
|
+
message: 'Steps must be at least 1.',
|
|
30
|
+
})
|
|
31
|
+
.max(5, {
|
|
32
|
+
message: 'Steps cannot be more than 5.',
|
|
33
|
+
})
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Number of deployments to go back (1-5).'),
|
|
36
|
+
})),
|
|
37
|
+
action: async (options) => {
|
|
38
|
+
let { appId, channel, steps } = options;
|
|
39
|
+
// Check if the user is logged in
|
|
40
|
+
if (!authorizationService.hasAuthorizationToken()) {
|
|
41
|
+
consola.error('You must be logged in to run this command. Please run the `login` command first.');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
// Prompt for app ID if not provided
|
|
45
|
+
if (!appId) {
|
|
46
|
+
if (!isInteractive()) {
|
|
47
|
+
consola.error('You must provide an app ID when running in non-interactive environment.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const organizations = await organizationsService.findAll();
|
|
51
|
+
if (organizations.length === 0) {
|
|
52
|
+
consola.error('You must create an organization before rolling back a deployment.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
56
|
+
const organizationId = await prompt('Select the organization of the app for which you want to rollback a deployment.', {
|
|
57
|
+
type: 'select',
|
|
58
|
+
options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
|
|
59
|
+
});
|
|
60
|
+
if (!organizationId) {
|
|
61
|
+
consola.error('You must select the organization of an app for which you want to rollback a deployment.');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const apps = await appsService.findAll({
|
|
65
|
+
organizationId,
|
|
66
|
+
});
|
|
67
|
+
if (apps.length === 0) {
|
|
68
|
+
consola.error('You must create an app before rolling back a deployment.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
72
|
+
appId = await prompt('Which app do you want to rollback a deployment for:', {
|
|
73
|
+
type: 'select',
|
|
74
|
+
options: apps.map((app) => ({ label: app.name, value: app.id })),
|
|
75
|
+
});
|
|
76
|
+
if (!appId) {
|
|
77
|
+
consola.error('You must select an app to rollback a deployment for.');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Prompt for channel name if not provided
|
|
82
|
+
if (!channel) {
|
|
83
|
+
if (!isInteractive()) {
|
|
84
|
+
consola.error('You must provide a channel name when running in non-interactive environment.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
channel = await prompt('Enter the channel name to rollback:', {
|
|
88
|
+
type: 'text',
|
|
89
|
+
});
|
|
90
|
+
if (!channel) {
|
|
91
|
+
consola.error('You must enter a channel name to rollback.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Fetch channel by name
|
|
96
|
+
const appChannels = await appChannelsService.findAll({ appId, name: channel });
|
|
97
|
+
if (appChannels.length === 0) {
|
|
98
|
+
consola.error('Channel not found.');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const appChannelId = appChannels[0]?.id;
|
|
102
|
+
// Fetch deployments for the channel
|
|
103
|
+
const appDeployments = await appDeploymentsService.findAll({
|
|
104
|
+
appId,
|
|
105
|
+
appChannelId,
|
|
106
|
+
limit: 5,
|
|
107
|
+
relations: 'appBuild',
|
|
108
|
+
});
|
|
109
|
+
// Validate that we have at least 2 app deployments (current + previous)
|
|
110
|
+
if (appDeployments.length < 2) {
|
|
111
|
+
consola.error('Channel has no previous deployments to rollback to.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
// Select deployment to rollback to
|
|
115
|
+
let selectedIndex;
|
|
116
|
+
if (steps === undefined) {
|
|
117
|
+
// Interactive selection (exclude index 0 - current deployment)
|
|
118
|
+
if (!isInteractive()) {
|
|
119
|
+
consola.error('You must provide --steps when running in non-interactive environment.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
// Build options for select prompt (skip index 0)
|
|
123
|
+
const options = appDeployments.slice(1).map((deployment, index) => {
|
|
124
|
+
const appBuild = appDeployments[index + 1]?.appBuild;
|
|
125
|
+
if (!appBuild) {
|
|
126
|
+
consola.error('Deployment is missing associated build.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const deployedTime = deployment.job?.createdAt ? formatTimeAgo(deployment.job.createdAt) : 'unknown';
|
|
130
|
+
return {
|
|
131
|
+
label: `Build #${appBuild.numberAsString} - Deployed ${deployedTime}`,
|
|
132
|
+
value: (index + 1).toString(),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
136
|
+
const selectedValue = await prompt('Which deployment do you want to rollback to:', {
|
|
137
|
+
type: 'select',
|
|
138
|
+
options,
|
|
139
|
+
});
|
|
140
|
+
if (!selectedValue) {
|
|
141
|
+
consola.error('You must select a deployment to rollback to.');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
selectedIndex = parseInt(selectedValue, 10);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Validate steps value
|
|
148
|
+
if (steps >= appDeployments.length) {
|
|
149
|
+
consola.error(`Cannot rollback ${steps} step${steps === 1 ? '' : 's'}, only ${appDeployments.length - 1} previous deployment${appDeployments.length - 1 === 1 ? '' : 's'} available.`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
selectedIndex = steps;
|
|
153
|
+
}
|
|
154
|
+
// Get the selected deployment and build
|
|
155
|
+
const selectedAppDeployment = appDeployments[selectedIndex];
|
|
156
|
+
if (!selectedAppDeployment) {
|
|
157
|
+
consola.error('Selected deployment not found.');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
// Create new deployment with the selected build
|
|
161
|
+
consola.start('Creating rollback deployment...');
|
|
162
|
+
const response = await appDeploymentsService.create({
|
|
163
|
+
appId,
|
|
164
|
+
appBuildId: selectedAppDeployment.appBuildId,
|
|
165
|
+
appChannelName: channel,
|
|
166
|
+
});
|
|
167
|
+
consola.info(`Deployment ID: ${response.id}`);
|
|
168
|
+
consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
|
|
169
|
+
consola.success(`Rolled back to Build #${selectedAppDeployment.appBuild?.numberAsString} (${selectedIndex} step${selectedIndex === 1 ? '' : 's'} back).`);
|
|
170
|
+
},
|
|
171
|
+
});
|