@capawesome/cli 4.6.0 → 4.8.0-dev.efa0850.1775645973
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 +35 -0
- package/dist/commands/apps/builds/create.js +160 -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/create.js +23 -4
- package/dist/commands/apps/deployments/create.js +5 -77
- package/dist/commands/apps/devices/forcechannel.js +9 -7
- package/dist/commands/apps/devices/unforcechannel.js +9 -7
- package/dist/commands/apps/link.js +34 -0
- package/dist/commands/apps/link.test.js +94 -0
- 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/apps/transfer.js +47 -0
- package/dist/commands/apps/transfer.test.js +123 -0
- package/dist/commands/apps/unlink.js +35 -0
- package/dist/commands/apps/unlink.test.js +99 -0
- package/dist/commands/manifests/generate.js +1 -1
- package/dist/index.js +13 -5
- package/dist/services/app-build-sources.js +120 -0
- package/dist/services/app-certificates.js +0 -1
- package/dist/services/app-devices.js +8 -0
- package/dist/services/apps.js +25 -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/error.js +6 -0
- package/dist/utils/file.js +12 -1
- package/dist/utils/git.js +92 -0
- package/dist/utils/git.test.js +130 -0
- package/dist/utils/job.js +77 -0
- package/dist/utils/prompt.js +1 -1
- package/dist/utils/zip.js +19 -2
- package/package.json +2 -1
|
@@ -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
|
-
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import appsService from '../../services/apps.js';
|
|
2
|
+
import { withAuth } from '../../utils/auth.js';
|
|
3
|
+
import { isInteractive } from '../../utils/environment.js';
|
|
4
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
5
|
+
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
6
|
+
import consola from 'consola';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
export default defineCommand({
|
|
9
|
+
description: 'Transfer an app to another organization.',
|
|
10
|
+
options: defineOptions(z.object({
|
|
11
|
+
appId: z.string().optional().describe('ID of the app.'),
|
|
12
|
+
organizationId: z.string().optional().describe('ID of the target organization.'),
|
|
13
|
+
yes: z.boolean().optional().describe('Skip confirmation prompt.'),
|
|
14
|
+
}), { y: 'yes' }),
|
|
15
|
+
action: withAuth(async (options, args) => {
|
|
16
|
+
let { appId, organizationId } = options;
|
|
17
|
+
if (!appId) {
|
|
18
|
+
if (!isInteractive()) {
|
|
19
|
+
consola.error('You must provide the app ID when running in non-interactive environment.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const sourceOrganizationId = await promptOrganizationSelection({
|
|
23
|
+
message: 'Which organization does the app belong to?',
|
|
24
|
+
});
|
|
25
|
+
appId = await promptAppSelection(sourceOrganizationId);
|
|
26
|
+
}
|
|
27
|
+
if (!organizationId) {
|
|
28
|
+
if (!isInteractive()) {
|
|
29
|
+
consola.error('You must provide the organization ID when running in non-interactive environment.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
organizationId = await promptOrganizationSelection({
|
|
33
|
+
message: 'Which organization do you want to transfer the app to?',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (!options.yes && isInteractive()) {
|
|
37
|
+
const confirmed = await prompt('Are you sure you want to transfer this app?', {
|
|
38
|
+
type: 'confirm',
|
|
39
|
+
});
|
|
40
|
+
if (!confirmed) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await appsService.transfer({ appId, organizationId });
|
|
45
|
+
consola.success('App transferred successfully.');
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL } from '../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../services/authorization-service.js';
|
|
3
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
4
|
+
import userConfig from '../../utils/user-config.js';
|
|
5
|
+
import consola from 'consola';
|
|
6
|
+
import nock from 'nock';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import transferAppCommand from './transfer.js';
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('consola');
|
|
13
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
14
|
+
isInteractive: () => true,
|
|
15
|
+
}));
|
|
16
|
+
describe('apps-transfer', () => {
|
|
17
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
18
|
+
const mockPrompt = vi.mocked(prompt);
|
|
19
|
+
const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection);
|
|
20
|
+
const mockPromptAppSelection = vi.mocked(promptAppSelection);
|
|
21
|
+
const mockConsola = vi.mocked(consola);
|
|
22
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
mockUserConfig.read.mockReturnValue({ token: 'test-token' });
|
|
26
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
|
|
27
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
28
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
29
|
+
throw new Error(`Process exited with code ${code}`);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
nock.cleanAll();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
it('should transfer app with provided options after confirmation', async () => {
|
|
37
|
+
const appId = 'app-123';
|
|
38
|
+
const organizationId = 'org-456';
|
|
39
|
+
const testToken = 'test-token';
|
|
40
|
+
const options = { appId, organizationId };
|
|
41
|
+
mockPrompt.mockResolvedValueOnce(true);
|
|
42
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
43
|
+
.post(`/v1/apps/${appId}/transfer`, { organizationId })
|
|
44
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
45
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
46
|
+
await transferAppCommand.action(options, undefined);
|
|
47
|
+
expect(scope.isDone()).toBe(true);
|
|
48
|
+
expect(mockPrompt).toHaveBeenCalledWith('Are you sure you want to transfer this app?', {
|
|
49
|
+
type: 'confirm',
|
|
50
|
+
});
|
|
51
|
+
expect(mockConsola.success).toHaveBeenCalledWith('App transferred successfully.');
|
|
52
|
+
});
|
|
53
|
+
it('should skip confirmation prompt when --yes is provided', async () => {
|
|
54
|
+
const appId = 'app-123';
|
|
55
|
+
const organizationId = 'org-456';
|
|
56
|
+
const testToken = 'test-token';
|
|
57
|
+
const options = { appId, organizationId, yes: true };
|
|
58
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
59
|
+
.post(`/v1/apps/${appId}/transfer`, { organizationId })
|
|
60
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
61
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
62
|
+
await transferAppCommand.action(options, undefined);
|
|
63
|
+
expect(scope.isDone()).toBe(true);
|
|
64
|
+
expect(mockPrompt).not.toHaveBeenCalled();
|
|
65
|
+
expect(mockConsola.success).toHaveBeenCalledWith('App transferred successfully.');
|
|
66
|
+
});
|
|
67
|
+
it('should not transfer app when confirmation is declined', async () => {
|
|
68
|
+
const appId = 'app-123';
|
|
69
|
+
const organizationId = 'org-456';
|
|
70
|
+
const options = { appId, organizationId };
|
|
71
|
+
mockPrompt.mockResolvedValueOnce(false);
|
|
72
|
+
await transferAppCommand.action(options, undefined);
|
|
73
|
+
expect(mockPrompt).toHaveBeenCalledWith('Are you sure you want to transfer this app?', {
|
|
74
|
+
type: 'confirm',
|
|
75
|
+
});
|
|
76
|
+
expect(mockConsola.success).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it('should prompt for app selection when appId not provided', async () => {
|
|
79
|
+
const orgId = 'org-1';
|
|
80
|
+
const appId = 'app-1';
|
|
81
|
+
const targetOrgId = 'org-2';
|
|
82
|
+
const testToken = 'test-token';
|
|
83
|
+
const options = { organizationId: targetOrgId };
|
|
84
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
85
|
+
.post(`/v1/apps/${appId}/transfer`, { organizationId: targetOrgId })
|
|
86
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
87
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
88
|
+
mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
|
|
89
|
+
mockPromptAppSelection.mockResolvedValueOnce(appId);
|
|
90
|
+
mockPrompt.mockResolvedValueOnce(true);
|
|
91
|
+
await transferAppCommand.action(options, undefined);
|
|
92
|
+
expect(scope.isDone()).toBe(true);
|
|
93
|
+
expect(mockConsola.success).toHaveBeenCalledWith('App transferred successfully.');
|
|
94
|
+
});
|
|
95
|
+
it('should prompt for organization selection when organizationId not provided', async () => {
|
|
96
|
+
const appId = 'app-123';
|
|
97
|
+
const targetOrgId = 'org-456';
|
|
98
|
+
const testToken = 'test-token';
|
|
99
|
+
const options = { appId };
|
|
100
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
101
|
+
.post(`/v1/apps/${appId}/transfer`, { organizationId: targetOrgId })
|
|
102
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
103
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
104
|
+
mockPromptOrganizationSelection.mockResolvedValueOnce(targetOrgId);
|
|
105
|
+
mockPrompt.mockResolvedValueOnce(true);
|
|
106
|
+
await transferAppCommand.action(options, undefined);
|
|
107
|
+
expect(scope.isDone()).toBe(true);
|
|
108
|
+
expect(mockConsola.success).toHaveBeenCalledWith('App transferred successfully.');
|
|
109
|
+
});
|
|
110
|
+
it('should handle API error during transfer', async () => {
|
|
111
|
+
const appId = 'app-123';
|
|
112
|
+
const organizationId = 'org-456';
|
|
113
|
+
const testToken = 'test-token';
|
|
114
|
+
const options = { appId, organizationId };
|
|
115
|
+
mockPrompt.mockResolvedValueOnce(true);
|
|
116
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
117
|
+
.post(`/v1/apps/${appId}/transfer`, { organizationId })
|
|
118
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
119
|
+
.reply(400, { message: 'Target organization has reached its app limit.' });
|
|
120
|
+
await expect(transferAppCommand.action(options, undefined)).rejects.toThrow();
|
|
121
|
+
expect(scope.isDone()).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import appsService from '../../services/apps.js';
|
|
2
|
+
import { withAuth } from '../../utils/auth.js';
|
|
3
|
+
import { isInteractive } from '../../utils/environment.js';
|
|
4
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
5
|
+
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
6
|
+
import consola from 'consola';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
export default defineCommand({
|
|
9
|
+
description: 'Disconnect a git repository from an app.',
|
|
10
|
+
options: defineOptions(z.object({
|
|
11
|
+
appId: z.string().optional().describe('ID of the app.'),
|
|
12
|
+
yes: z.boolean().optional().describe('Skip confirmation prompt.'),
|
|
13
|
+
}), { y: 'yes' }),
|
|
14
|
+
action: withAuth(async (options, args) => {
|
|
15
|
+
let { appId } = options;
|
|
16
|
+
if (!appId) {
|
|
17
|
+
if (!isInteractive()) {
|
|
18
|
+
consola.error('You must provide the app ID when running in non-interactive environment.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const organizationId = await promptOrganizationSelection();
|
|
22
|
+
appId = await promptAppSelection(organizationId);
|
|
23
|
+
}
|
|
24
|
+
if (!options.yes && isInteractive()) {
|
|
25
|
+
const confirmed = await prompt('Are you sure you want to disconnect the repository?', {
|
|
26
|
+
type: 'confirm',
|
|
27
|
+
});
|
|
28
|
+
if (!confirmed) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await appsService.unlinkRepository({ appId });
|
|
33
|
+
consola.success('Repository disconnected successfully.');
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL } from '../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../services/authorization-service.js';
|
|
3
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
4
|
+
import userConfig from '../../utils/user-config.js';
|
|
5
|
+
import consola from 'consola';
|
|
6
|
+
import nock from 'nock';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import unlinkCommand from './unlink.js';
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('consola');
|
|
13
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
14
|
+
isInteractive: () => true,
|
|
15
|
+
}));
|
|
16
|
+
describe('apps-unlink', () => {
|
|
17
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
18
|
+
const mockPrompt = vi.mocked(prompt);
|
|
19
|
+
const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection);
|
|
20
|
+
const mockPromptAppSelection = vi.mocked(promptAppSelection);
|
|
21
|
+
const mockConsola = vi.mocked(consola);
|
|
22
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
mockUserConfig.read.mockReturnValue({ token: 'test-token' });
|
|
26
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
|
|
27
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
28
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
29
|
+
throw new Error(`Process exited with code ${code}`);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
nock.cleanAll();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
it('should unlink repository with provided app ID and --yes flag', async () => {
|
|
37
|
+
const appId = 'app-123';
|
|
38
|
+
const testToken = 'test-token';
|
|
39
|
+
const options = { appId, yes: true };
|
|
40
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
41
|
+
.delete(`/v1/apps/${appId}/repository`)
|
|
42
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
43
|
+
.reply(204);
|
|
44
|
+
await unlinkCommand.action(options, undefined);
|
|
45
|
+
expect(scope.isDone()).toBe(true);
|
|
46
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Repository disconnected successfully.');
|
|
47
|
+
});
|
|
48
|
+
it('should prompt for confirmation when --yes is not provided', async () => {
|
|
49
|
+
const appId = 'app-123';
|
|
50
|
+
const testToken = 'test-token';
|
|
51
|
+
const options = { appId };
|
|
52
|
+
mockPrompt.mockResolvedValueOnce(true);
|
|
53
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
54
|
+
.delete(`/v1/apps/${appId}/repository`)
|
|
55
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
56
|
+
.reply(204);
|
|
57
|
+
await unlinkCommand.action(options, undefined);
|
|
58
|
+
expect(scope.isDone()).toBe(true);
|
|
59
|
+
expect(mockPrompt).toHaveBeenCalledWith('Are you sure you want to disconnect the repository?', {
|
|
60
|
+
type: 'confirm',
|
|
61
|
+
});
|
|
62
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Repository disconnected successfully.');
|
|
63
|
+
});
|
|
64
|
+
it('should abort when user declines confirmation', async () => {
|
|
65
|
+
const appId = 'app-123';
|
|
66
|
+
const options = { appId };
|
|
67
|
+
mockPrompt.mockResolvedValueOnce(false);
|
|
68
|
+
await unlinkCommand.action(options, undefined);
|
|
69
|
+
expect(mockConsola.success).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it('should prompt for organization and app when app ID is not provided', async () => {
|
|
72
|
+
const appId = 'app-123';
|
|
73
|
+
const orgId = 'org-1';
|
|
74
|
+
const testToken = 'test-token';
|
|
75
|
+
const options = { yes: true };
|
|
76
|
+
mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
|
|
77
|
+
mockPromptAppSelection.mockResolvedValueOnce(appId);
|
|
78
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
79
|
+
.delete(`/v1/apps/${appId}/repository`)
|
|
80
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
81
|
+
.reply(204);
|
|
82
|
+
await unlinkCommand.action(options, undefined);
|
|
83
|
+
expect(scope.isDone()).toBe(true);
|
|
84
|
+
expect(mockPromptOrganizationSelection).toHaveBeenCalled();
|
|
85
|
+
expect(mockPromptAppSelection).toHaveBeenCalledWith(orgId);
|
|
86
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Repository disconnected successfully.');
|
|
87
|
+
});
|
|
88
|
+
it('should handle API error', async () => {
|
|
89
|
+
const appId = 'app-123';
|
|
90
|
+
const testToken = 'test-token';
|
|
91
|
+
const options = { appId, yes: true };
|
|
92
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
93
|
+
.delete(`/v1/apps/${appId}/repository`)
|
|
94
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
95
|
+
.reply(400, { message: 'No repository linked to this app' });
|
|
96
|
+
await expect(unlinkCommand.action(options, undefined)).rejects.toThrow();
|
|
97
|
+
expect(scope.isDone()).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import configService from './services/config.js';
|
|
3
3
|
import updateService from './services/update.js';
|
|
4
|
-
import { getMessageFromUnknownError } from './utils/error.js';
|
|
4
|
+
import { getMessageFromUnknownError, UserError } from './utils/error.js';
|
|
5
5
|
import { defineConfig, processConfig, ZliError } from '@robingenz/zli';
|
|
6
6
|
import * as Sentry from '@sentry/node';
|
|
7
7
|
import { AxiosError } from 'axios';
|
|
@@ -23,6 +23,9 @@ const config = defineConfig({
|
|
|
23
23
|
doctor: await import('./commands/doctor.js').then((mod) => mod.default),
|
|
24
24
|
'apps:create': await import('./commands/apps/create.js').then((mod) => mod.default),
|
|
25
25
|
'apps:delete': await import('./commands/apps/delete.js').then((mod) => mod.default),
|
|
26
|
+
'apps:link': await import('./commands/apps/link.js').then((mod) => mod.default),
|
|
27
|
+
'apps:transfer': await import('./commands/apps/transfer.js').then((mod) => mod.default),
|
|
28
|
+
'apps:unlink': await import('./commands/apps/unlink.js').then((mod) => mod.default),
|
|
26
29
|
'apps:builds:cancel': await import('./commands/apps/builds/cancel.js').then((mod) => mod.default),
|
|
27
30
|
'apps:builds:create': await import('./commands/apps/builds/create.js').then((mod) => mod.default),
|
|
28
31
|
'apps:builds:logs': await import('./commands/apps/builds/logs.js').then((mod) => mod.default),
|
|
@@ -60,6 +63,7 @@ const config = defineConfig({
|
|
|
60
63
|
'apps:environments:set': await import('./commands/apps/environments/set.js').then((mod) => mod.default),
|
|
61
64
|
'apps:environments:unset': await import('./commands/apps/environments/unset.js').then((mod) => mod.default),
|
|
62
65
|
'apps:liveupdates:bundle': await import('./commands/apps/liveupdates/bundle.js').then((mod) => mod.default),
|
|
66
|
+
'apps:liveupdates:create': await import('./commands/apps/liveupdates/create.js').then((mod) => mod.default),
|
|
63
67
|
'apps:liveupdates:generatesigningkey': await import('./commands/apps/liveupdates/generate-signing-key.js').then((mod) => mod.default),
|
|
64
68
|
'apps:liveupdates:rollback': await import('./commands/apps/liveupdates/rollback.js').then((mod) => mod.default),
|
|
65
69
|
'apps:liveupdates:rollout': await import('./commands/apps/liveupdates/rollout.js').then((mod) => mod.default),
|
|
@@ -72,6 +76,14 @@ const config = defineConfig({
|
|
|
72
76
|
},
|
|
73
77
|
});
|
|
74
78
|
const captureException = async (error) => {
|
|
79
|
+
// Ignore failed HTTP requests
|
|
80
|
+
if (error instanceof AxiosError) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Ignore expected user errors
|
|
84
|
+
if (error instanceof UserError) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
75
87
|
// Ignore errors from the CLI itself (e.g. "No command found.")
|
|
76
88
|
if (error instanceof ZliError) {
|
|
77
89
|
return;
|
|
@@ -80,10 +92,6 @@ const captureException = async (error) => {
|
|
|
80
92
|
if (error instanceof ZodError) {
|
|
81
93
|
return;
|
|
82
94
|
}
|
|
83
|
-
// Ignore failed HTTP requests
|
|
84
|
-
if (error instanceof AxiosError) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
95
|
const environment = await configService.getValueForKey('ENVIRONMENT');
|
|
88
96
|
if (environment !== 'production') {
|
|
89
97
|
return;
|
|
@@ -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;
|
|
@@ -11,7 +11,6 @@ class AppCertificatesServiceImpl {
|
|
|
11
11
|
formData.append('file', dto.buffer, { filename: dto.fileName });
|
|
12
12
|
formData.append('name', dto.name);
|
|
13
13
|
formData.append('platform', dto.platform);
|
|
14
|
-
formData.append('type', dto.type);
|
|
15
14
|
if (dto.password) {
|
|
16
15
|
formData.append('password', dto.password);
|
|
17
16
|
}
|
|
@@ -55,6 +55,14 @@ class AppDevicesServiceImpl {
|
|
|
55
55
|
},
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
async updateMany(data) {
|
|
59
|
+
const ids = data.deviceIds.join(',');
|
|
60
|
+
await this.httpClient.patch(`/v1/apps/${data.appId}/devices?ids=${ids}`, { forcedAppChannelId: data.forcedAppChannelId }, {
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
const appDevicesService = new AppDevicesServiceImpl(httpClient);
|
|
60
68
|
export default appDevicesService;
|