@capawesome/cli 3.11.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/commands/apps/builds/cancel.js +1 -1
  3. package/dist/commands/apps/builds/create.js +58 -50
  4. package/dist/commands/apps/builds/download.js +27 -3
  5. package/dist/commands/apps/bundles/create.js +5 -449
  6. package/dist/commands/apps/bundles/delete.js +3 -68
  7. package/dist/commands/apps/bundles/update.js +3 -66
  8. package/dist/commands/apps/channels/create.js +5 -8
  9. package/dist/commands/apps/channels/create.test.js +6 -9
  10. package/dist/commands/apps/channels/delete.js +3 -2
  11. package/dist/commands/apps/channels/get.js +2 -12
  12. package/dist/commands/apps/channels/get.test.js +1 -2
  13. package/dist/commands/apps/channels/list.js +2 -10
  14. package/dist/commands/apps/channels/list.test.js +2 -3
  15. package/dist/commands/apps/channels/pause.js +85 -0
  16. package/dist/commands/apps/channels/resume.js +85 -0
  17. package/dist/commands/apps/channels/update.js +4 -7
  18. package/dist/commands/apps/channels/update.test.js +2 -4
  19. package/dist/commands/apps/create.js +1 -1
  20. package/dist/commands/apps/delete.js +3 -2
  21. package/dist/commands/apps/deployments/cancel.js +1 -1
  22. package/dist/commands/apps/deployments/create.js +82 -31
  23. package/dist/commands/apps/devices/delete.js +3 -2
  24. package/dist/commands/apps/environments/create.js +1 -1
  25. package/dist/commands/apps/environments/delete.js +3 -2
  26. package/dist/commands/apps/liveupdates/bundle.js +117 -0
  27. package/dist/commands/apps/liveupdates/generate-manifest.js +39 -0
  28. package/dist/commands/{manifests/generate.test.js → apps/liveupdates/generate-manifest.test.js} +6 -6
  29. package/dist/commands/apps/liveupdates/register.js +291 -0
  30. package/dist/commands/apps/{bundles/create.test.js → liveupdates/register.test.js} +123 -111
  31. package/dist/commands/apps/liveupdates/rollback.js +171 -0
  32. package/dist/commands/apps/liveupdates/rollout.js +147 -0
  33. package/dist/commands/apps/liveupdates/upload.js +420 -0
  34. package/dist/commands/apps/liveupdates/upload.test.js +325 -0
  35. package/dist/commands/manifests/generate.js +2 -27
  36. package/dist/commands/organizations/create.js +1 -1
  37. package/dist/index.js +8 -0
  38. package/dist/services/app-builds.js +9 -2
  39. package/dist/services/app-channels.js +19 -0
  40. package/dist/services/app-deployments.js +24 -14
  41. package/dist/services/config.js +2 -0
  42. package/dist/utils/app-environments.js +2 -1
  43. package/dist/utils/time-format.js +26 -0
  44. package/package.json +3 -3
  45. package/dist/commands/apps/bundles/delete.test.js +0 -142
  46. package/dist/commands/apps/bundles/update.test.js +0 -144
  47. package/dist/utils/capacitor-config.js +0 -96
  48. 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 { findCapacitorConfigPath } from '../../../utils/capacitor-config.js';
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 createBundleCommand from './create.js';
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-bundles-create', () => {
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 options = { appId, path: './dist', artifactType: 'zip', rollout: 1 };
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(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
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 create bundle with self-hosted URL', async () => {
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
- path: bundlePath,
66
- artifactType: 'zip',
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: 1,
65
+ rolloutPercentage: 0.01,
89
66
  })
90
67
  .matchHeader('Authorization', `Bearer ${testToken}`)
91
- .reply(201, { id: bundleId });
92
- await createBundleCommand.action(options, undefined);
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.success).toHaveBeenCalledWith('Bundle successfully created.');
96
- expect(mockConsola.info).toHaveBeenCalledWith(`Bundle ID: ${bundleId}`);
72
+ expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
73
+ expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
97
74
  });
98
- it('should handle path validation errors', async () => {
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
- artifactType: 'manifest',
130
- rollout: 1,
84
+ rolloutPercentage: 1,
85
+ gitRef,
86
+ yes: true,
131
87
  };
132
- await expect(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
133
- expect(mockConsola.error).toHaveBeenCalledWith('It is not yet possible to provide a URL when creating a bundle with an artifact type of `manifest`.');
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 handle API error during creation', async () => {
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
- artifactType: 'zip',
143
- rollout: 1,
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(400, { message: 'Invalid bundle data' });
153
- await expect(createBundleCommand.action(options, undefined)).rejects.toThrow();
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 handle private key file path', async () => {
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
- artifactType: 'zip',
173
- rollout: 1,
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: 1,
200
+ rolloutPercentage: 0.01,
208
201
  })
209
202
  .matchHeader('Authorization', `Bearer ${testToken}`)
210
- .reply(201, { id: bundleId });
211
- await createBundleCommand.action(options, undefined);
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.success).toHaveBeenCalledWith('Bundle successfully created.');
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
- artifactType: 'zip',
232
- rollout: 1,
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: 1,
253
+ rolloutPercentage: 0.01,
261
254
  })
262
255
  .matchHeader('Authorization', `Bearer ${testToken}`)
263
- .reply(201, { id: bundleId });
264
- await createBundleCommand.action(options, undefined);
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.success).toHaveBeenCalledWith('Bundle successfully created.');
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
- path: './dist',
270
+ url: bundleUrl,
271
+ path: bundlePath,
275
272
  privateKey: privateKeyPath,
276
- artifactType: 'zip',
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(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
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 handle invalid private key format', async () => {
288
+ it('should validate path must be a zip file', async () => {
295
289
  const appId = 'app-123';
296
- const invalidPrivateKey = 'not-a-valid-key';
290
+ const bundleUrl = 'https://example.com/bundle.zip';
291
+ const bundlePath = './dist';
297
292
  const options = {
298
293
  appId,
299
- path: './dist',
300
- privateKey: invalidPrivateKey,
301
- artifactType: 'zip',
302
- rollout: 1,
294
+ url: bundleUrl,
295
+ path: bundlePath,
296
+ rolloutPercentage: 1,
303
297
  };
304
298
  mockFileExistsAtPath.mockResolvedValue(true);
305
- mockIsDirectory.mockResolvedValue(false);
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(true);
309
- // Mock utility functions
310
- const mockBuffer = await import('../../../utils/buffer.js');
311
- vi.mocked(mockBuffer.isPrivateKeyContent).mockReturnValue(false);
312
- await expect(createBundleCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
313
- expect(mockConsola.error).toHaveBeenCalledWith('Private key must be either a path to a .pem file or the private key content as plain text.');
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
+ });