@capawesome/cli 4.5.0 → 4.6.0-dev.12df3ca.1775037612
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 +7 -0
- package/dist/commands/apps/builds/create.js +145 -128
- package/dist/commands/apps/bundles/create.js +4 -2
- package/dist/commands/apps/bundles/delete.js +2 -3
- package/dist/commands/apps/bundles/update.js +2 -3
- package/dist/commands/apps/certificates/create.js +3 -19
- package/dist/commands/apps/certificates/delete.js +28 -5
- package/dist/commands/apps/certificates/get.js +28 -5
- package/dist/commands/apps/certificates/update.js +3 -1
- package/dist/commands/apps/deployments/create.js +5 -77
- package/dist/commands/apps/devices/forcechannel.js +9 -7
- package/dist/commands/apps/devices/probe.js +70 -0
- package/dist/commands/apps/devices/unforcechannel.js +9 -7
- package/dist/commands/apps/liveupdates/bundle.js +12 -2
- package/dist/commands/apps/liveupdates/create.js +293 -0
- package/dist/commands/apps/liveupdates/create.test.js +300 -0
- package/dist/commands/apps/liveupdates/generate-manifest.js +17 -1
- package/dist/commands/apps/liveupdates/generate-manifest.test.js +21 -1
- package/dist/commands/apps/liveupdates/register.js +10 -15
- package/dist/commands/apps/liveupdates/upload.js +25 -16
- package/dist/commands/manifests/generate.js +1 -1
- package/dist/index.js +2 -0
- package/dist/services/app-build-sources.js +120 -0
- package/dist/services/app-certificates.js +0 -1
- package/dist/services/app-devices.js +44 -0
- package/dist/services/authorization-service.js +5 -1
- package/dist/services/jobs.js +13 -0
- package/dist/types/app-build-source.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/custom-properties.js +22 -0
- package/dist/utils/file.js +12 -1
- package/dist/utils/job.js +77 -0
- package/dist/utils/zip.js +19 -2
- package/package.json +2 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../../services/authorization-service.js';
|
|
3
|
+
import userConfig from '../../../utils/user-config.js';
|
|
4
|
+
import consola from 'consola';
|
|
5
|
+
import nock from 'nock';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import createCommand from './create.js';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('@/utils/job.js');
|
|
13
|
+
vi.mock('consola');
|
|
14
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
15
|
+
isInteractive: () => false,
|
|
16
|
+
}));
|
|
17
|
+
describe('apps-liveupdates-create', () => {
|
|
18
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
19
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
20
|
+
const mockConsola = vi.mocked(consola);
|
|
21
|
+
const testToken = 'test-token';
|
|
22
|
+
const appId = '00000000-0000-0000-0000-000000000001';
|
|
23
|
+
const buildId = '00000000-0000-0000-0000-000000000002';
|
|
24
|
+
const deploymentId = '00000000-0000-0000-0000-000000000003';
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
28
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
29
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
30
|
+
// Mock waitForJobCompletion to resolve immediately
|
|
31
|
+
const jobUtils = await import('../../../utils/job.js');
|
|
32
|
+
vi.mocked(jobUtils.waitForJobCompletion).mockResolvedValue({
|
|
33
|
+
id: 'job-1',
|
|
34
|
+
status: 'succeeded',
|
|
35
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
36
|
+
});
|
|
37
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
38
|
+
throw new Error(`Process exited with code ${code}`);
|
|
39
|
+
});
|
|
40
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
nock.cleanAll();
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
it('should require authentication', async () => {
|
|
47
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(false);
|
|
48
|
+
const options = { appId, gitRef: 'main', channel: ['production'] };
|
|
49
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
50
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must be logged in to run this command. Set the `CAPAWESOME_TOKEN` environment variable or use the `--token` option.');
|
|
51
|
+
});
|
|
52
|
+
it('should create a live update with build and deployment', async () => {
|
|
53
|
+
const options = {
|
|
54
|
+
appId,
|
|
55
|
+
gitRef: 'main',
|
|
56
|
+
channel: ['production'],
|
|
57
|
+
yes: true,
|
|
58
|
+
};
|
|
59
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
60
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
61
|
+
gitRef: 'main',
|
|
62
|
+
platform: 'web',
|
|
63
|
+
})
|
|
64
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
65
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
66
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
67
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
68
|
+
appId,
|
|
69
|
+
appBuildId: buildId,
|
|
70
|
+
appChannelName: 'production',
|
|
71
|
+
rolloutPercentage: 1,
|
|
72
|
+
})
|
|
73
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
74
|
+
.reply(201, { id: deploymentId });
|
|
75
|
+
await createCommand.action(options, undefined);
|
|
76
|
+
expect(buildScope.isDone()).toBe(true);
|
|
77
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
78
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build created successfully.');
|
|
79
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build completed successfully.');
|
|
80
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
|
|
81
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Build ID: ${buildId}`);
|
|
82
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId}`);
|
|
83
|
+
});
|
|
84
|
+
it('should pass environment and certificate to build', async () => {
|
|
85
|
+
const options = {
|
|
86
|
+
appId,
|
|
87
|
+
gitRef: 'v1.0.0',
|
|
88
|
+
channel: ['production'],
|
|
89
|
+
environment: 'staging',
|
|
90
|
+
certificate: 'my-cert',
|
|
91
|
+
yes: true,
|
|
92
|
+
};
|
|
93
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
94
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
95
|
+
gitRef: 'v1.0.0',
|
|
96
|
+
platform: 'web',
|
|
97
|
+
appEnvironmentName: 'staging',
|
|
98
|
+
appCertificateName: 'my-cert',
|
|
99
|
+
})
|
|
100
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
101
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
102
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
103
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
104
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
105
|
+
.reply(201, { id: deploymentId });
|
|
106
|
+
await createCommand.action(options, undefined);
|
|
107
|
+
expect(buildScope.isDone()).toBe(true);
|
|
108
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it('should pass stack to build', async () => {
|
|
111
|
+
const options = {
|
|
112
|
+
appId,
|
|
113
|
+
gitRef: 'main',
|
|
114
|
+
channel: ['production'],
|
|
115
|
+
stack: 'macos-tahoe',
|
|
116
|
+
yes: true,
|
|
117
|
+
};
|
|
118
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
119
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
120
|
+
gitRef: 'main',
|
|
121
|
+
platform: 'web',
|
|
122
|
+
stack: 'macos-tahoe',
|
|
123
|
+
})
|
|
124
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
125
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
126
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
127
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
128
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
129
|
+
.reply(201, { id: deploymentId });
|
|
130
|
+
await createCommand.action(options, undefined);
|
|
131
|
+
expect(buildScope.isDone()).toBe(true);
|
|
132
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
it('should update version constraints when provided', async () => {
|
|
135
|
+
const options = {
|
|
136
|
+
appId,
|
|
137
|
+
gitRef: 'main',
|
|
138
|
+
channel: ['production'],
|
|
139
|
+
androidMin: '10',
|
|
140
|
+
androidMax: '50',
|
|
141
|
+
iosEq: '42',
|
|
142
|
+
yes: true,
|
|
143
|
+
};
|
|
144
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
145
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
146
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
147
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
148
|
+
const updateScope = nock(DEFAULT_API_BASE_URL)
|
|
149
|
+
.patch(`/v1/apps/${appId}/builds/${buildId}`, {
|
|
150
|
+
minAndroidAppVersionCode: '10',
|
|
151
|
+
maxAndroidAppVersionCode: '50',
|
|
152
|
+
eqIosAppVersionCode: '42',
|
|
153
|
+
})
|
|
154
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
155
|
+
.reply(200, { id: buildId });
|
|
156
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
157
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
158
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
159
|
+
.reply(201, { id: deploymentId });
|
|
160
|
+
await createCommand.action(options, undefined);
|
|
161
|
+
expect(buildScope.isDone()).toBe(true);
|
|
162
|
+
expect(updateScope.isDone()).toBe(true);
|
|
163
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
164
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build updated successfully.');
|
|
165
|
+
});
|
|
166
|
+
it('should convert rollout percentage to decimal', async () => {
|
|
167
|
+
const options = {
|
|
168
|
+
appId,
|
|
169
|
+
gitRef: 'main',
|
|
170
|
+
channel: ['production'],
|
|
171
|
+
rolloutPercentage: 50,
|
|
172
|
+
yes: true,
|
|
173
|
+
};
|
|
174
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
175
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
176
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
177
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
178
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
179
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
180
|
+
appId,
|
|
181
|
+
appBuildId: buildId,
|
|
182
|
+
appChannelName: 'production',
|
|
183
|
+
rolloutPercentage: 0.5,
|
|
184
|
+
})
|
|
185
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
186
|
+
.reply(201, { id: deploymentId });
|
|
187
|
+
await createCommand.action(options, undefined);
|
|
188
|
+
expect(buildScope.isDone()).toBe(true);
|
|
189
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
it('should create multiple deployments for multiple channels', async () => {
|
|
192
|
+
const deploymentId2 = '00000000-0000-0000-0000-000000000004';
|
|
193
|
+
const options = {
|
|
194
|
+
appId,
|
|
195
|
+
gitRef: 'main',
|
|
196
|
+
channel: ['production', 'staging'],
|
|
197
|
+
yes: true,
|
|
198
|
+
};
|
|
199
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
200
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
201
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
202
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
203
|
+
const deploymentScope1 = nock(DEFAULT_API_BASE_URL)
|
|
204
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
205
|
+
appId,
|
|
206
|
+
appBuildId: buildId,
|
|
207
|
+
appChannelName: 'production',
|
|
208
|
+
rolloutPercentage: 1,
|
|
209
|
+
})
|
|
210
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
211
|
+
.reply(201, { id: deploymentId });
|
|
212
|
+
const deploymentScope2 = nock(DEFAULT_API_BASE_URL)
|
|
213
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
214
|
+
appId,
|
|
215
|
+
appBuildId: buildId,
|
|
216
|
+
appChannelName: 'staging',
|
|
217
|
+
rolloutPercentage: 1,
|
|
218
|
+
})
|
|
219
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
220
|
+
.reply(201, { id: deploymentId2 });
|
|
221
|
+
await createCommand.action(options, undefined);
|
|
222
|
+
expect(buildScope.isDone()).toBe(true);
|
|
223
|
+
expect(deploymentScope1.isDone()).toBe(true);
|
|
224
|
+
expect(deploymentScope2.isDone()).toBe(true);
|
|
225
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
|
|
226
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId}`);
|
|
227
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId2}`);
|
|
228
|
+
});
|
|
229
|
+
it('should output JSON when json flag is set', async () => {
|
|
230
|
+
const options = {
|
|
231
|
+
appId,
|
|
232
|
+
gitRef: 'main',
|
|
233
|
+
channel: ['production'],
|
|
234
|
+
json: true,
|
|
235
|
+
yes: true,
|
|
236
|
+
};
|
|
237
|
+
nock(DEFAULT_API_BASE_URL)
|
|
238
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
239
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
240
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
|
|
241
|
+
nock(DEFAULT_API_BASE_URL)
|
|
242
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
243
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
244
|
+
.reply(201, { id: deploymentId });
|
|
245
|
+
await createCommand.action(options, undefined);
|
|
246
|
+
expect(console.log).toHaveBeenCalledWith(JSON.stringify({
|
|
247
|
+
buildId,
|
|
248
|
+
buildNumberAsString: '42',
|
|
249
|
+
deploymentIds: [deploymentId],
|
|
250
|
+
}, null, 2));
|
|
251
|
+
});
|
|
252
|
+
it('should require app ID in non-interactive mode', async () => {
|
|
253
|
+
const options = { gitRef: 'main', channel: ['production'] };
|
|
254
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
255
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide an app ID when running in non-interactive environment.');
|
|
256
|
+
});
|
|
257
|
+
it('should require git ref in non-interactive mode', async () => {
|
|
258
|
+
const options = { appId, channel: ['production'] };
|
|
259
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
260
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide a git ref, path, or url when running in non-interactive environment.');
|
|
261
|
+
});
|
|
262
|
+
it('should require channel in non-interactive mode', async () => {
|
|
263
|
+
const options = { appId, gitRef: 'main' };
|
|
264
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
265
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide at least one channel when running in non-interactive environment.');
|
|
266
|
+
});
|
|
267
|
+
it('should handle build creation API error', async () => {
|
|
268
|
+
const options = {
|
|
269
|
+
appId,
|
|
270
|
+
gitRef: 'main',
|
|
271
|
+
channel: ['production'],
|
|
272
|
+
yes: true,
|
|
273
|
+
};
|
|
274
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
275
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
276
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
277
|
+
.reply(400, { message: 'Invalid build data' });
|
|
278
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow();
|
|
279
|
+
expect(buildScope.isDone()).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
it('should include build URL in output', async () => {
|
|
282
|
+
const options = {
|
|
283
|
+
appId,
|
|
284
|
+
gitRef: 'main',
|
|
285
|
+
channel: ['production'],
|
|
286
|
+
yes: true,
|
|
287
|
+
};
|
|
288
|
+
nock(DEFAULT_API_BASE_URL)
|
|
289
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
290
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
291
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
292
|
+
nock(DEFAULT_API_BASE_URL)
|
|
293
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
294
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
295
|
+
.reply(201, { id: deploymentId });
|
|
296
|
+
await createCommand.action(options, undefined);
|
|
297
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${buildId}`);
|
|
298
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deploymentId}`);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isInteractive } from '../../../utils/environment.js';
|
|
2
|
-
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
2
|
+
import { directoryContainsSourceMaps, directoryContainsSymlinks, fileExistsAtPath, isDirectory } from '../../../utils/file.js';
|
|
3
3
|
import { generateManifestJson } from '../../../utils/manifest.js';
|
|
4
4
|
import { prompt } from '../../../utils/prompt.js';
|
|
5
5
|
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
@@ -32,6 +32,22 @@ export default defineCommand({
|
|
|
32
32
|
consola.error(`The path does not exist.`);
|
|
33
33
|
process.exit(1);
|
|
34
34
|
}
|
|
35
|
+
// Check if the path is a directory
|
|
36
|
+
const pathIsDirectory = await isDirectory(path);
|
|
37
|
+
if (!pathIsDirectory) {
|
|
38
|
+
consola.error(`The path is not a directory.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
// Check for source maps
|
|
42
|
+
const containsSourceMaps = await directoryContainsSourceMaps(path);
|
|
43
|
+
if (containsSourceMaps) {
|
|
44
|
+
consola.warn('Source map files were detected in the specified path. Source maps should not be distributed to end users as they expose your original source code and increase the download size. Consider excluding source map files from your build output.');
|
|
45
|
+
}
|
|
46
|
+
// Check for symlinks
|
|
47
|
+
const containsSymlinks = await directoryContainsSymlinks(path);
|
|
48
|
+
if (containsSymlinks) {
|
|
49
|
+
consola.warn('Symbolic links were detected in the specified path. Symbolic links are skipped during manifest generation.');
|
|
50
|
+
}
|
|
35
51
|
// Generate the manifest file
|
|
36
52
|
await generateManifestJson(path);
|
|
37
53
|
consola.success('Manifest file generated.');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
1
|
+
import { directoryContainsSymlinks, fileExistsAtPath, isDirectory } from '../../../utils/file.js';
|
|
2
2
|
import { generateManifestJson } from '../../../utils/manifest.js';
|
|
3
3
|
import { prompt } from '../../../utils/prompt.js';
|
|
4
4
|
import consola from 'consola';
|
|
@@ -14,6 +14,8 @@ vi.mock('@/utils/environment.js', () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
describe('apps-liveupdates-generatemanifest', () => {
|
|
16
16
|
const mockFileExistsAtPath = vi.mocked(fileExistsAtPath);
|
|
17
|
+
const mockIsDirectory = vi.mocked(isDirectory);
|
|
18
|
+
const mockDirectoryContainsSymlinks = vi.mocked(directoryContainsSymlinks);
|
|
17
19
|
const mockGenerateManifestJson = vi.mocked(generateManifestJson);
|
|
18
20
|
const mockPrompt = vi.mocked(prompt);
|
|
19
21
|
const mockConsola = vi.mocked(consola);
|
|
@@ -29,6 +31,7 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
29
31
|
it('should generate manifest with provided path', async () => {
|
|
30
32
|
const options = { path: './dist' };
|
|
31
33
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
34
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
32
35
|
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
33
36
|
await generateManifestCommand.action(options, undefined);
|
|
34
37
|
expect(mockFileExistsAtPath).toHaveBeenCalledWith('./dist');
|
|
@@ -39,6 +42,7 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
39
42
|
const options = {};
|
|
40
43
|
mockPrompt.mockResolvedValueOnce('./www');
|
|
41
44
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
45
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
42
46
|
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
43
47
|
await generateManifestCommand.action(options, undefined);
|
|
44
48
|
expect(mockPrompt).toHaveBeenCalledWith('Enter the path to the web assets folder (e.g., `dist` or `www`):', {
|
|
@@ -60,4 +64,20 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
60
64
|
await expect(generateManifestCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
61
65
|
expect(mockConsola.error).toHaveBeenCalledWith('The path does not exist.');
|
|
62
66
|
});
|
|
67
|
+
it('should handle non-directory path', async () => {
|
|
68
|
+
const options = { path: './file.txt' };
|
|
69
|
+
mockFileExistsAtPath.mockResolvedValue(true);
|
|
70
|
+
mockIsDirectory.mockResolvedValue(false);
|
|
71
|
+
await expect(generateManifestCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
72
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The path is not a directory.');
|
|
73
|
+
});
|
|
74
|
+
it('should warn when symlinks are detected', async () => {
|
|
75
|
+
const options = { path: './dist' };
|
|
76
|
+
mockFileExistsAtPath.mockResolvedValue(true);
|
|
77
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
78
|
+
mockDirectoryContainsSymlinks.mockResolvedValue(true);
|
|
79
|
+
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
80
|
+
await generateManifestCommand.action(options, undefined);
|
|
81
|
+
expect(mockConsola.warn).toHaveBeenCalledWith('Symbolic links were detected in the specified path. Symbolic links are skipped during manifest generation.');
|
|
82
|
+
});
|
|
63
83
|
});
|
|
@@ -2,6 +2,7 @@ import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
|
2
2
|
import appBundlesService from '../../../services/app-bundles.js';
|
|
3
3
|
import appsService from '../../../services/apps.js';
|
|
4
4
|
import { withAuth } from '../../../utils/auth.js';
|
|
5
|
+
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
5
6
|
import { createBufferFromPath, createBufferFromString, isPrivateKeyContent } from '../../../utils/buffer.js';
|
|
6
7
|
import { isInteractive } from '../../../utils/environment.js';
|
|
7
8
|
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
@@ -14,7 +15,7 @@ import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
|
14
15
|
import consola from 'consola';
|
|
15
16
|
import { z } from 'zod';
|
|
16
17
|
export default defineCommand({
|
|
17
|
-
description: 'Register a self-hosted bundle URL.',
|
|
18
|
+
description: 'Register a self-hosted bundle URL for serving artifacts from your own infrastructure.',
|
|
18
19
|
options: defineOptions(z.object({
|
|
19
20
|
androidMax: z.coerce
|
|
20
21
|
.string()
|
|
@@ -52,6 +53,7 @@ export default defineCommand({
|
|
|
52
53
|
.describe('The commit sha related to the bundle. Deprecated, use `--git-ref` instead.'),
|
|
53
54
|
customProperty: z
|
|
54
55
|
.array(z.string().min(1).max(100))
|
|
56
|
+
.max(10)
|
|
55
57
|
.optional()
|
|
56
58
|
.describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
|
|
57
59
|
expiresInDays: z.coerce
|
|
@@ -192,7 +194,13 @@ export default defineCommand({
|
|
|
192
194
|
process.exit(1);
|
|
193
195
|
}
|
|
194
196
|
// Sign the bundle
|
|
195
|
-
|
|
197
|
+
try {
|
|
198
|
+
signature = await createSignature(privateKeyBuffer, fileBuffer);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
consola.error('Failed to parse the private key. Make sure the private key is a valid PEM-formatted key and is not encrypted.');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
206
|
// Get app details for confirmation
|
|
@@ -237,16 +245,3 @@ export default defineCommand({
|
|
|
237
245
|
consola.success('Live Update successfully registered.');
|
|
238
246
|
}),
|
|
239
247
|
});
|
|
240
|
-
const parseCustomProperties = (customProperty) => {
|
|
241
|
-
let customProperties;
|
|
242
|
-
if (customProperty) {
|
|
243
|
-
customProperties = {};
|
|
244
|
-
for (const property of customProperty) {
|
|
245
|
-
const [key, value] = property.split('=');
|
|
246
|
-
if (key && value) {
|
|
247
|
-
customProperties[key] = value;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return customProperties;
|
|
252
|
-
};
|
|
@@ -3,9 +3,10 @@ import appBundleFilesService from '../../../services/app-bundle-files.js';
|
|
|
3
3
|
import appBundlesService from '../../../services/app-bundles.js';
|
|
4
4
|
import appsService from '../../../services/apps.js';
|
|
5
5
|
import { withAuth } from '../../../utils/auth.js';
|
|
6
|
+
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
6
7
|
import { createBufferFromPath, createBufferFromReadStream, createBufferFromString, isPrivateKeyContent, } from '../../../utils/buffer.js';
|
|
7
8
|
import { isInteractive } from '../../../utils/environment.js';
|
|
8
|
-
import { fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file.js';
|
|
9
|
+
import { directoryContainsSourceMaps, directoryContainsSymlinks, fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory, } from '../../../utils/file.js';
|
|
9
10
|
import { createHash } from '../../../utils/hash.js';
|
|
10
11
|
import { generateManifestJson } from '../../../utils/manifest.js';
|
|
11
12
|
import { formatPrivateKey } from '../../../utils/private-key.js';
|
|
@@ -18,7 +19,7 @@ import { createReadStream } from 'fs';
|
|
|
18
19
|
import pathModule from 'path';
|
|
19
20
|
import { z } from 'zod';
|
|
20
21
|
export default defineCommand({
|
|
21
|
-
description: 'Upload a bundle to Capawesome Cloud.',
|
|
22
|
+
description: 'Upload a locally built bundle to Capawesome Cloud.',
|
|
22
23
|
options: defineOptions(z.object({
|
|
23
24
|
androidMax: z.coerce
|
|
24
25
|
.string()
|
|
@@ -63,6 +64,7 @@ export default defineCommand({
|
|
|
63
64
|
.describe('The commit sha related to the bundle. Deprecated, use `--git-ref` instead.'),
|
|
64
65
|
customProperty: z
|
|
65
66
|
.array(z.string().min(1).max(100))
|
|
67
|
+
.max(10)
|
|
66
68
|
.optional()
|
|
67
69
|
.describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
|
|
68
70
|
expiresInDays: z.coerce
|
|
@@ -156,6 +158,20 @@ export default defineCommand({
|
|
|
156
158
|
consola.error('The path must be either a folder or a zip file.');
|
|
157
159
|
process.exit(1);
|
|
158
160
|
}
|
|
161
|
+
// Check for symlinks
|
|
162
|
+
if (pathIsDirectory) {
|
|
163
|
+
const containsSymlinks = await directoryContainsSymlinks(path);
|
|
164
|
+
if (containsSymlinks) {
|
|
165
|
+
consola.warn('Symbolic links were detected in the specified path. Symbolic links are skipped during upload.');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Check for source maps
|
|
169
|
+
if (pathIsDirectory) {
|
|
170
|
+
const containsSourceMaps = await directoryContainsSourceMaps(path);
|
|
171
|
+
if (containsSourceMaps) {
|
|
172
|
+
consola.warn('Source map files were detected in the specified path. Source maps should not be distributed to end users as they expose your original source code and increase the download size. Consider excluding source map files from your build output.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
159
175
|
// Check that the path is a directory when creating a bundle with an artifact type of manifest
|
|
160
176
|
if (artifactType === 'manifest') {
|
|
161
177
|
const pathIsDirectory = await isDirectory(path);
|
|
@@ -283,7 +299,13 @@ const uploadFile = async (options) => {
|
|
|
283
299
|
// Sign the bundle
|
|
284
300
|
let signature;
|
|
285
301
|
if (privateKeyBuffer) {
|
|
286
|
-
|
|
302
|
+
try {
|
|
303
|
+
signature = await createSignature(privateKeyBuffer, buffer);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
consola.error('Failed to parse the private key. Make sure the private key is a valid PEM-formatted key and is not encrypted.');
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
287
309
|
}
|
|
288
310
|
// Create the multipart upload
|
|
289
311
|
return await appBundleFilesService.create({
|
|
@@ -370,16 +392,3 @@ const uploadZip = async (options) => {
|
|
|
370
392
|
appBundleFileId: result.id,
|
|
371
393
|
};
|
|
372
394
|
};
|
|
373
|
-
const parseCustomProperties = (customProperty) => {
|
|
374
|
-
let customProperties;
|
|
375
|
-
if (customProperty) {
|
|
376
|
-
customProperties = {};
|
|
377
|
-
for (const property of customProperty) {
|
|
378
|
-
const [key, value] = property.split('=');
|
|
379
|
-
if (key && value) {
|
|
380
|
-
customProperties[key] = value;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
return customProperties;
|
|
385
|
-
};
|
|
@@ -2,7 +2,7 @@ import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
|
2
2
|
import consola from 'consola';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
export default defineCommand({
|
|
5
|
-
description: 'Generate a manifest file.',
|
|
5
|
+
description: 'Generate a manifest file. Deprecated, use `apps:liveupdates:generate-manifest` instead.',
|
|
6
6
|
options: defineOptions(z.object({
|
|
7
7
|
path: z.string().optional().describe('Path to the web assets folder (e.g. `www` or `dist`).'),
|
|
8
8
|
})),
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const config = defineConfig({
|
|
|
52
52
|
'apps:destinations:update': await import('./commands/apps/destinations/update.js').then((mod) => mod.default),
|
|
53
53
|
'apps:devices:delete': await import('./commands/apps/devices/delete.js').then((mod) => mod.default),
|
|
54
54
|
'apps:devices:forcechannel': await import('./commands/apps/devices/forcechannel.js').then((mod) => mod.default),
|
|
55
|
+
'apps:devices:probe': await import('./commands/apps/devices/probe.js').then((mod) => mod.default),
|
|
55
56
|
'apps:devices:unforcechannel': await import('./commands/apps/devices/unforcechannel.js').then((mod) => mod.default),
|
|
56
57
|
'apps:environments:create': await import('./commands/apps/environments/create.js').then((mod) => mod.default),
|
|
57
58
|
'apps:environments:delete': await import('./commands/apps/environments/delete.js').then((mod) => mod.default),
|
|
@@ -59,6 +60,7 @@ const config = defineConfig({
|
|
|
59
60
|
'apps:environments:set': await import('./commands/apps/environments/set.js').then((mod) => mod.default),
|
|
60
61
|
'apps:environments:unset': await import('./commands/apps/environments/unset.js').then((mod) => mod.default),
|
|
61
62
|
'apps:liveupdates:bundle': await import('./commands/apps/liveupdates/bundle.js').then((mod) => mod.default),
|
|
63
|
+
'apps:liveupdates:create': await import('./commands/apps/liveupdates/create.js').then((mod) => mod.default),
|
|
62
64
|
'apps:liveupdates:generatesigningkey': await import('./commands/apps/liveupdates/generate-signing-key.js').then((mod) => mod.default),
|
|
63
65
|
'apps:liveupdates:rollback': await import('./commands/apps/liveupdates/rollback.js').then((mod) => mod.default),
|
|
64
66
|
'apps:liveupdates:rollout': await import('./commands/apps/liveupdates/rollout.js').then((mod) => mod.default),
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { MAX_CONCURRENT_PART_UPLOADS } from '../config/index.js';
|
|
2
|
+
import authorizationService from '../services/authorization-service.js';
|
|
3
|
+
import httpClient from '../utils/http-client.js';
|
|
4
|
+
import FormData from 'form-data';
|
|
5
|
+
class AppBuildSourcesServiceImpl {
|
|
6
|
+
httpClient;
|
|
7
|
+
constructor(httpClient) {
|
|
8
|
+
this.httpClient = httpClient;
|
|
9
|
+
}
|
|
10
|
+
async createFromFile(dto, onProgress) {
|
|
11
|
+
const response = await this.httpClient.post(`/v1/apps/${dto.appId}/build-sources`, { fileSizeInBytes: dto.fileSizeInBytes }, {
|
|
12
|
+
headers: {
|
|
13
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
await this.upload({
|
|
17
|
+
appBuildSourceId: response.data.id,
|
|
18
|
+
appId: dto.appId,
|
|
19
|
+
buffer: dto.buffer,
|
|
20
|
+
name: dto.name,
|
|
21
|
+
}, onProgress);
|
|
22
|
+
return response.data;
|
|
23
|
+
}
|
|
24
|
+
async createFromUrl(dto) {
|
|
25
|
+
const response = await this.httpClient.post(`/v1/apps/${dto.appId}/build-sources`, { fileUrl: dto.fileUrl }, {
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
return response.data;
|
|
31
|
+
}
|
|
32
|
+
async completeUpload(dto) {
|
|
33
|
+
return this.httpClient
|
|
34
|
+
.post(`/v1/apps/${dto.appId}/build-sources/${dto.appBuildSourceId}/upload?action=mpu-complete&uploadId=${dto.uploadId}`, {
|
|
35
|
+
parts: dto.parts,
|
|
36
|
+
}, {
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.then((response) => response.data);
|
|
42
|
+
}
|
|
43
|
+
async createUpload(dto) {
|
|
44
|
+
const response = await this.httpClient.post(`/v1/apps/${dto.appId}/build-sources/${dto.appBuildSourceId}/upload?action=mpu-create`, {}, {
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return response.data;
|
|
50
|
+
}
|
|
51
|
+
async createUploadPart(dto) {
|
|
52
|
+
const formData = new FormData();
|
|
53
|
+
formData.append('blob', dto.buffer, { filename: dto.name });
|
|
54
|
+
formData.append('partNumber', dto.partNumber.toString());
|
|
55
|
+
return this.httpClient
|
|
56
|
+
.put(`/v1/apps/${dto.appId}/build-sources/${dto.appBuildSourceId}/upload?action=mpu-uploadpart&uploadId=${dto.uploadId}`, formData, {
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
59
|
+
...formData.getHeaders(),
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
.then((response) => response.data);
|
|
63
|
+
}
|
|
64
|
+
async createUploadParts(dto, onProgress) {
|
|
65
|
+
const uploadedParts = [];
|
|
66
|
+
const partSize = 10 * 1024 * 1024; // 10 MB
|
|
67
|
+
const totalParts = Math.ceil(dto.buffer.byteLength / partSize);
|
|
68
|
+
let partNumber = 0;
|
|
69
|
+
const uploadNextPart = async () => {
|
|
70
|
+
if (partNumber >= totalParts) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
partNumber++;
|
|
74
|
+
onProgress?.(partNumber, totalParts);
|
|
75
|
+
const start = (partNumber - 1) * partSize;
|
|
76
|
+
const end = Math.min(start + partSize, dto.buffer.byteLength);
|
|
77
|
+
const partBuffer = dto.buffer.subarray(start, end);
|
|
78
|
+
const uploadedPart = await this.createUploadPart({
|
|
79
|
+
appBuildSourceId: dto.appBuildSourceId,
|
|
80
|
+
appId: dto.appId,
|
|
81
|
+
buffer: partBuffer,
|
|
82
|
+
name: dto.name,
|
|
83
|
+
partNumber,
|
|
84
|
+
uploadId: dto.uploadId,
|
|
85
|
+
});
|
|
86
|
+
uploadedParts.push(uploadedPart);
|
|
87
|
+
await uploadNextPart();
|
|
88
|
+
};
|
|
89
|
+
const uploadPartPromises = Array.from({ length: MAX_CONCURRENT_PART_UPLOADS });
|
|
90
|
+
for (let i = 0; i < MAX_CONCURRENT_PART_UPLOADS; i++) {
|
|
91
|
+
uploadPartPromises[i] = uploadNextPart();
|
|
92
|
+
}
|
|
93
|
+
await Promise.all(uploadPartPromises);
|
|
94
|
+
return uploadedParts.sort((a, b) => a.partNumber - b.partNumber);
|
|
95
|
+
}
|
|
96
|
+
async upload(dto, onProgress) {
|
|
97
|
+
// 1. Create a multipart upload
|
|
98
|
+
const { uploadId } = await this.createUpload({
|
|
99
|
+
appBuildSourceId: dto.appBuildSourceId,
|
|
100
|
+
appId: dto.appId,
|
|
101
|
+
});
|
|
102
|
+
// 2. Upload the file in parts
|
|
103
|
+
const parts = await this.createUploadParts({
|
|
104
|
+
appBuildSourceId: dto.appBuildSourceId,
|
|
105
|
+
appId: dto.appId,
|
|
106
|
+
buffer: dto.buffer,
|
|
107
|
+
name: dto.name,
|
|
108
|
+
uploadId,
|
|
109
|
+
}, onProgress);
|
|
110
|
+
// 3. Complete the upload
|
|
111
|
+
await this.completeUpload({
|
|
112
|
+
appBuildSourceId: dto.appBuildSourceId,
|
|
113
|
+
appId: dto.appId,
|
|
114
|
+
parts,
|
|
115
|
+
uploadId,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const appBuildSourcesService = new AppBuildSourcesServiceImpl(httpClient);
|
|
120
|
+
export default appBuildSourcesService;
|