@capawesome/cli 4.20.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/commands/apps/automations/create.js +2 -2
- package/dist/commands/apps/automations/update.js +2 -2
- package/dist/commands/apps/builds/create.js +27 -81
- package/dist/commands/apps/builds/create.test.js +37 -0
- package/dist/commands/apps/builds/download.js +46 -133
- package/dist/commands/apps/builds/download.test.js +85 -0
- package/dist/commands/apps/builds/run.js +2 -1
- package/dist/commands/apps/import.js +32 -31
- package/dist/commands/apps/import.test.js +6 -12
- package/dist/commands/apps/link.js +24 -6
- package/dist/commands/apps/link.test.js +39 -41
- package/dist/commands/apps/liveupdates/create.js +2 -2
- package/dist/services/git-connections.js +3 -0
- package/dist/utils/app-build-artifacts.js +87 -0
- package/dist/utils/app-build-artifacts.test.js +51 -0
- package/dist/utils/git.js +6 -5
- package/dist/utils/git.test.js +3 -3
- package/dist/utils/http-client.js +10 -1
- package/dist/utils/http-client.test.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [4.22.0](https://github.com/capawesome-team/cli/compare/v4.21.0...v4.22.0) (2026-09-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **apps:** add `--app` flag to download the simulator app bundle ([#225](https://github.com/capawesome-team/cli/issues/225)) ([ee40c92](https://github.com/capawesome-team/cli/commit/ee40c92f2d73e878b7fa1f9089cdf9420daa2ae4))
|
|
11
|
+
* **apps:** add the `macos-golden-gate` build stack ([#226](https://github.com/capawesome-team/cli/issues/226)) ([23d4d39](https://github.com/capawesome-team/cli/commit/23d4d3961a752a6bf087f86aa9e93805a9026647))
|
|
12
|
+
* **apps:** download build artifacts by form factor ([#216](https://github.com/capawesome-team/cli/issues/216)) ([cd06192](https://github.com/capawesome-team/cli/commit/cd061920c69ac796e271a00141ec2284cc0ae526))
|
|
13
|
+
|
|
14
|
+
## [4.21.0](https://github.com/capawesome-team/cli/compare/v4.20.0...v4.21.0) (2026-09-22)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **apps:** link repositories via git connections ([#222](https://github.com/capawesome-team/cli/issues/222)) ([f567ea6](https://github.com/capawesome-team/cli/commit/f567ea6d0e836b4aea8e4d57ff10d934a6788fe2))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* retry rate limited requests ([#223](https://github.com/capawesome-team/cli/issues/223)) ([47d9733](https://github.com/capawesome-team/cli/commit/47d9733ec28bf0bb3235b8dccdd5b353ec24d0b5))
|
|
25
|
+
|
|
5
26
|
## [4.20.0](https://github.com/capawesome-team/cli/compare/v4.19.0...v4.20.0) (2026-09-22)
|
|
6
27
|
|
|
7
28
|
|
|
@@ -28,8 +28,8 @@ export default defineCommand({
|
|
|
28
28
|
.optional()
|
|
29
29
|
.describe('The platform for the build. Supported values are `android`, `ios`, and `web`.'),
|
|
30
30
|
stack: z
|
|
31
|
-
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
32
|
-
message: 'Build stack must be
|
|
31
|
+
.enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
|
|
32
|
+
message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
|
|
33
33
|
})
|
|
34
34
|
.optional()
|
|
35
35
|
.describe('The build stack to use for the build process.'),
|
|
@@ -45,8 +45,8 @@ export default defineCommand({
|
|
|
45
45
|
.optional()
|
|
46
46
|
.describe('The platform for the build. Supported values are `android`, `ios`, and `web`.'),
|
|
47
47
|
stack: z
|
|
48
|
-
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
49
|
-
message: 'Build stack must be
|
|
48
|
+
.enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
|
|
49
|
+
message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
|
|
50
50
|
})
|
|
51
51
|
.optional()
|
|
52
52
|
.describe('The build stack to use for the build process.'),
|
|
@@ -5,6 +5,7 @@ import appCertificatesService from '../../../services/app-certificates.js';
|
|
|
5
5
|
import appConfigurationsService from '../../../services/app-configurations.js';
|
|
6
6
|
import appEnvironmentsService from '../../../services/app-environments.js';
|
|
7
7
|
import appsService from '../../../services/apps.js';
|
|
8
|
+
import { APP_BUILD_ARTIFACT_FORM_FACTORS, APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM, handleAppBuildArtifactDownload, } from '../../../utils/app-build-artifacts.js';
|
|
8
9
|
import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
|
|
9
10
|
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
|
|
10
11
|
import { withAuth } from '../../../utils/auth.js';
|
|
@@ -33,6 +34,10 @@ export default defineCommand({
|
|
|
33
34
|
.union([z.boolean(), z.string()])
|
|
34
35
|
.optional()
|
|
35
36
|
.describe('Download the generated APK file (Android only). Optionally provide a file path.'),
|
|
37
|
+
app: z
|
|
38
|
+
.union([z.boolean(), z.string()])
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Download the generated APP file, a zipped `.app` bundle (iOS simulator builds only). Optionally provide a file path.'),
|
|
36
41
|
appId: z
|
|
37
42
|
.uuid({
|
|
38
43
|
message: 'App ID must be a UUID.',
|
|
@@ -52,6 +57,12 @@ export default defineCommand({
|
|
|
52
57
|
.boolean()
|
|
53
58
|
.optional()
|
|
54
59
|
.describe('Request an AI-powered failure summary (Capawesome Cloud Assist) if the build fails.'),
|
|
60
|
+
formFactor: z
|
|
61
|
+
.enum(APP_BUILD_ARTIFACT_FORM_FACTORS, {
|
|
62
|
+
message: 'Invalid form factor. Must be one of `mobile`, `watch`, `tv`, or `automotive`.',
|
|
63
|
+
})
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('The form factor of the Android artifact to download with `--apk` or `--aab`. Supported values are `mobile`, `watch`, `tv`, and `automotive`. Without it, the first artifact in the order `mobile`, `watch`, `tv`, `automotive` is downloaded. The build always includes every application module.'),
|
|
55
66
|
gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
|
|
56
67
|
ipa: z
|
|
57
68
|
.union([z.boolean(), z.string()])
|
|
@@ -79,8 +90,8 @@ export default defineCommand({
|
|
|
79
90
|
.optional()
|
|
80
91
|
.describe('Number of days until the share link expires. Requires --share.'),
|
|
81
92
|
stack: z
|
|
82
|
-
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
83
|
-
message: 'Build stack must be
|
|
93
|
+
.enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
|
|
94
|
+
message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
|
|
84
95
|
})
|
|
85
96
|
.optional()
|
|
86
97
|
.describe('The build stack to use for the build process.'),
|
|
@@ -106,8 +117,8 @@ export default defineCommand({
|
|
|
106
117
|
action: withAuth(async (options) => {
|
|
107
118
|
let { appId, platform, type, gitRef, environment, certificate, configuration, json, stack, path: sourcePath, url, } = options;
|
|
108
119
|
// Validate that detached flag cannot be used with artifact flags
|
|
109
|
-
if (options.detached && (options.apk || options.aab || options.ipa || options.zip)) {
|
|
110
|
-
consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, or --zip flags.');
|
|
120
|
+
if (options.detached && (options.apk || options.aab || options.ipa || options.app || options.zip)) {
|
|
121
|
+
consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, --app, or --zip flags.');
|
|
111
122
|
process.exit(1);
|
|
112
123
|
}
|
|
113
124
|
// Validate that detached flag cannot be used with channel or destination
|
|
@@ -411,41 +422,18 @@ export default defineCommand({
|
|
|
411
422
|
consola.success('Build completed successfully.');
|
|
412
423
|
console.log();
|
|
413
424
|
// Download artifacts if flags are set
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
buildId: response.id,
|
|
427
|
-
buildArtifacts: appBuild.appBuildArtifacts,
|
|
428
|
-
artifactType: 'aab',
|
|
429
|
-
filePath: typeof options.aab === 'string' ? options.aab : undefined,
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
if (options.ipa && platform === 'ios') {
|
|
433
|
-
await handleArtifactDownload({
|
|
434
|
-
appId,
|
|
435
|
-
buildId: response.id,
|
|
436
|
-
buildArtifacts: appBuild.appBuildArtifacts,
|
|
437
|
-
artifactType: 'ipa',
|
|
438
|
-
filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
if (options.zip && platform === 'web') {
|
|
442
|
-
await handleArtifactDownload({
|
|
443
|
-
appId,
|
|
444
|
-
buildId: response.id,
|
|
445
|
-
buildArtifacts: appBuild.appBuildArtifacts,
|
|
446
|
-
artifactType: 'zip',
|
|
447
|
-
filePath: typeof options.zip === 'string' ? options.zip : undefined,
|
|
448
|
-
});
|
|
425
|
+
for (const artifactType of APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM[platform]) {
|
|
426
|
+
const option = options[artifactType];
|
|
427
|
+
if (option) {
|
|
428
|
+
await handleAppBuildArtifactDownload({
|
|
429
|
+
appId,
|
|
430
|
+
buildId: response.id,
|
|
431
|
+
buildArtifacts: appBuild.appBuildArtifacts,
|
|
432
|
+
artifactType,
|
|
433
|
+
formFactor: options.formFactor,
|
|
434
|
+
filePath: typeof option === 'string' ? option : undefined,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
449
437
|
}
|
|
450
438
|
// Create a public share link if requested
|
|
451
439
|
let appBuildShare;
|
|
@@ -500,45 +488,3 @@ export default defineCommand({
|
|
|
500
488
|
}
|
|
501
489
|
}),
|
|
502
490
|
});
|
|
503
|
-
/**
|
|
504
|
-
* Download a build artifact (APK, AAB, IPA, or ZIP).
|
|
505
|
-
*/
|
|
506
|
-
const handleArtifactDownload = async (options) => {
|
|
507
|
-
const { appId, buildId, buildArtifacts, artifactType, filePath } = options;
|
|
508
|
-
try {
|
|
509
|
-
const artifactTypeUpper = artifactType.toUpperCase();
|
|
510
|
-
consola.start(`Downloading ${artifactTypeUpper}...`);
|
|
511
|
-
// Find the artifact
|
|
512
|
-
const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
|
|
513
|
-
if (!artifact) {
|
|
514
|
-
consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
if (artifact.status !== 'ready') {
|
|
518
|
-
consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
// Download the artifact
|
|
522
|
-
const artifactData = await appBuildsService.downloadArtifact({
|
|
523
|
-
appId,
|
|
524
|
-
appBuildId: buildId,
|
|
525
|
-
artifactId: artifact.id,
|
|
526
|
-
});
|
|
527
|
-
// Determine the file path
|
|
528
|
-
let outputPath;
|
|
529
|
-
if (filePath) {
|
|
530
|
-
// Use provided path (can be relative or absolute)
|
|
531
|
-
outputPath = path.resolve(filePath);
|
|
532
|
-
}
|
|
533
|
-
else {
|
|
534
|
-
// Default to current working directory with build ID as filename
|
|
535
|
-
outputPath = path.resolve(`${buildId}.${artifactType}`);
|
|
536
|
-
}
|
|
537
|
-
// Save the file
|
|
538
|
-
await fs.writeFile(outputPath, Buffer.from(artifactData));
|
|
539
|
-
consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
|
|
540
|
-
}
|
|
541
|
-
catch (error) {
|
|
542
|
-
consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
|
|
543
|
-
}
|
|
544
|
-
};
|
|
@@ -2,7 +2,10 @@ import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../../../config/
|
|
|
2
2
|
import authorizationService from '../../../services/authorization-service.js';
|
|
3
3
|
import userConfig from '../../../utils/user-config.js';
|
|
4
4
|
import consola from 'consola';
|
|
5
|
+
import fs from 'fs/promises';
|
|
5
6
|
import nock from 'nock';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
6
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
10
|
import createCommand from './create.js';
|
|
8
11
|
// Mock dependencies
|
|
@@ -77,6 +80,40 @@ describe('apps-builds-create', () => {
|
|
|
77
80
|
appBuildShare: { id: shareId, qrCodeUrl, webUrl, expiresAt: null },
|
|
78
81
|
}, null, 2));
|
|
79
82
|
});
|
|
83
|
+
it('should download the app artifact of an iOS simulator build with --app', async () => {
|
|
84
|
+
const outputDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-create-'));
|
|
85
|
+
const outputPath = path.join(outputDirectory, 'simulator.app.zip');
|
|
86
|
+
const artifactId = 'artifact-1';
|
|
87
|
+
const artifactContent = 'zipped app bundle';
|
|
88
|
+
const options = { appId, platform: 'ios', type: 'simulator', gitRef: 'main', app: outputPath };
|
|
89
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
90
|
+
.post(`/v1/apps/${appId}/builds`, (body) => body.platform === 'ios' && body.type === 'simulator')
|
|
91
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
|
|
92
|
+
const findScope = nock(DEFAULT_API_BASE_URL)
|
|
93
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
94
|
+
.query({ relations: 'appBuildArtifacts' })
|
|
95
|
+
.reply(200, {
|
|
96
|
+
id: buildId,
|
|
97
|
+
appBuildArtifacts: [
|
|
98
|
+
{ id: 'artifact-0', status: 'ready', type: 'xcarchive' },
|
|
99
|
+
{ formFactor: 'mobile', id: artifactId, status: 'ready', type: 'app' },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
const downloadScope = nock(DEFAULT_API_BASE_URL)
|
|
103
|
+
.get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
|
|
104
|
+
.reply(200, artifactContent);
|
|
105
|
+
try {
|
|
106
|
+
await createCommand.action(options, undefined);
|
|
107
|
+
expect(buildScope.isDone()).toBe(true);
|
|
108
|
+
expect(findScope.isDone()).toBe(true);
|
|
109
|
+
expect(downloadScope.isDone()).toBe(true);
|
|
110
|
+
expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
|
|
111
|
+
expect(mockConsola.success).toHaveBeenCalledWith(`APP downloaded successfully: ${outputPath}`);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await fs.rm(outputDirectory, { force: true, recursive: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
80
117
|
it('should reject --share combined with --detached', async () => {
|
|
81
118
|
const options = { appId, platform: 'web', gitRef: 'main', share: true, detached: true };
|
|
82
119
|
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import appBuildsService from '../../../services/app-builds.js';
|
|
2
|
+
import { APP_BUILD_ARTIFACT_FORM_FACTORS, APP_BUILD_ARTIFACT_LABELS, APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM, handleAppBuildArtifactDownload, } from '../../../utils/app-build-artifacts.js';
|
|
2
3
|
import { withAuth } from '../../../utils/auth.js';
|
|
4
|
+
import { isInteractive } from '../../../utils/environment.js';
|
|
3
5
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
4
6
|
import consola from 'consola';
|
|
5
|
-
import fs from 'fs/promises';
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import { isInteractive } from '../../../utils/environment.js';
|
|
8
7
|
import { z } from 'zod';
|
|
9
8
|
import { defineCommand, defineOptions } from 'zodline';
|
|
9
|
+
const ARTIFACT_TYPES = ['apk', 'aab', 'ipa', 'app', 'zip'];
|
|
10
|
+
const PLATFORM_LABELS = {
|
|
11
|
+
android: 'Android',
|
|
12
|
+
ios: 'iOS',
|
|
13
|
+
web: 'Web',
|
|
14
|
+
};
|
|
10
15
|
export default defineCommand({
|
|
11
16
|
description: 'Download an app build.',
|
|
12
17
|
options: defineOptions(z.object({
|
|
@@ -22,22 +27,32 @@ export default defineCommand({
|
|
|
22
27
|
})
|
|
23
28
|
.optional()
|
|
24
29
|
.describe('Build ID to download.'),
|
|
30
|
+
formFactor: z
|
|
31
|
+
.enum(APP_BUILD_ARTIFACT_FORM_FACTORS, {
|
|
32
|
+
message: 'Invalid form factor. Must be one of `mobile`, `watch`, `tv`, or `automotive`.',
|
|
33
|
+
})
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('The form factor of the Android artifact to download. Supported values are `mobile`, `watch`, `tv`, and `automotive`. Without it, the first artifact in the order `mobile`, `watch`, `tv`, `automotive` is downloaded.'),
|
|
25
36
|
apk: z
|
|
26
37
|
.union([z.boolean(), z.string()])
|
|
27
38
|
.optional()
|
|
28
|
-
.describe('Download the APK artifact. Optionally provide a file path.'),
|
|
39
|
+
.describe('Download the APK artifact (Android only). Optionally provide a file path.'),
|
|
29
40
|
aab: z
|
|
30
41
|
.union([z.boolean(), z.string()])
|
|
31
42
|
.optional()
|
|
32
|
-
.describe('Download the AAB artifact. Optionally provide a file path.'),
|
|
43
|
+
.describe('Download the AAB artifact (Android only). Optionally provide a file path.'),
|
|
33
44
|
ipa: z
|
|
34
45
|
.union([z.boolean(), z.string()])
|
|
35
46
|
.optional()
|
|
36
|
-
.describe('Download the IPA artifact. Optionally provide a file path.'),
|
|
47
|
+
.describe('Download the IPA artifact (iOS only). Optionally provide a file path.'),
|
|
48
|
+
app: z
|
|
49
|
+
.union([z.boolean(), z.string()])
|
|
50
|
+
.optional()
|
|
51
|
+
.describe('Download the APP artifact, a zipped `.app` bundle (iOS simulator builds only). Optionally provide a file path.'),
|
|
37
52
|
zip: z
|
|
38
53
|
.union([z.boolean(), z.string()])
|
|
39
54
|
.optional()
|
|
40
|
-
.describe('Download the ZIP artifact. Optionally provide a file path.'),
|
|
55
|
+
.describe('Download the ZIP artifact (Web only). Optionally provide a file path.'),
|
|
41
56
|
})),
|
|
42
57
|
action: withAuth(async (options) => {
|
|
43
58
|
let { appId, buildId } = options;
|
|
@@ -74,156 +89,54 @@ export default defineCommand({
|
|
|
74
89
|
process.exit(1);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
|
-
// Fetch the build details to get the job ID
|
|
78
92
|
const build = await appBuildsService.findOne({ appId, appBuildId: buildId, relations: 'appBuildArtifacts,job' });
|
|
79
93
|
if (build.job?.status !== 'succeeded') {
|
|
80
94
|
consola.error('The build has not succeeded yet. Cannot download artifacts for incomplete builds.');
|
|
81
95
|
process.exit(1);
|
|
82
96
|
}
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
consola.error('Cannot download APK or AAB artifacts for an iOS build.');
|
|
90
|
-
process.exit(1);
|
|
91
|
-
}
|
|
92
|
-
if (build.platform === 'web' && (options.apk || options.aab || options.ipa)) {
|
|
93
|
-
consola.error('Cannot download APK, AAB, or IPA artifacts for a Web build.');
|
|
97
|
+
// Reject artifact flags that do not exist for the build's platform
|
|
98
|
+
const supportedArtifactTypes = APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM[build.platform];
|
|
99
|
+
const requestedArtifactTypes = ARTIFACT_TYPES.filter((artifactType) => options[artifactType]);
|
|
100
|
+
const unsupportedArtifactType = requestedArtifactTypes.find((artifactType) => !supportedArtifactTypes.includes(artifactType));
|
|
101
|
+
if (unsupportedArtifactType) {
|
|
102
|
+
consola.error(`The ${APP_BUILD_ARTIFACT_LABELS[unsupportedArtifactType]} artifact is not available for ${PLATFORM_LABELS[build.platform]} builds.`);
|
|
94
103
|
process.exit(1);
|
|
95
104
|
}
|
|
96
|
-
// Determine which artifacts to download
|
|
97
|
-
let downloadApk = options.apk;
|
|
98
|
-
let downloadAab = options.aab;
|
|
99
|
-
let downloadIpa = options.ipa;
|
|
100
|
-
let downloadZip = options.zip;
|
|
101
105
|
// Prompt for artifact types if none were provided
|
|
102
|
-
|
|
106
|
+
let artifactTypesToDownload = requestedArtifactTypes;
|
|
107
|
+
if (artifactTypesToDownload.length === 0) {
|
|
103
108
|
if (!isInteractive()) {
|
|
104
|
-
consola.error('You must specify at least one artifact type (--apk, --aab, --ipa, or --zip) when running in non-interactive environment.');
|
|
109
|
+
consola.error('You must specify at least one artifact type (--apk, --aab, --ipa, --app, or --zip) when running in non-interactive environment.');
|
|
105
110
|
process.exit(1);
|
|
106
111
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
[];
|
|
110
|
-
if (availableArtifacts.length === 0) {
|
|
112
|
+
const availableArtifactTypes = supportedArtifactTypes.filter((artifactType) => build.appBuildArtifacts?.some((artifact) => artifact.type === artifactType && artifact.status === 'ready'));
|
|
113
|
+
if (availableArtifactTypes.length === 0) {
|
|
111
114
|
consola.error('No artifacts available for download.');
|
|
112
115
|
process.exit(1);
|
|
113
116
|
}
|
|
114
|
-
// Create options based on available artifacts and platform
|
|
115
|
-
const artifactOptions = [];
|
|
116
|
-
if (build.platform === 'android') {
|
|
117
|
-
if (availableArtifacts.includes('apk')) {
|
|
118
|
-
artifactOptions.push({ label: 'APK', value: 'apk' });
|
|
119
|
-
}
|
|
120
|
-
if (availableArtifacts.includes('aab')) {
|
|
121
|
-
artifactOptions.push({ label: 'AAB', value: 'aab' });
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
else if (build.platform === 'ios') {
|
|
125
|
-
if (availableArtifacts.includes('ipa')) {
|
|
126
|
-
artifactOptions.push({ label: 'IPA', value: 'ipa' });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
else if (build.platform === 'web') {
|
|
130
|
-
if (availableArtifacts.includes('zip')) {
|
|
131
|
-
artifactOptions.push({ label: 'ZIP', value: 'zip' });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
117
|
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
135
|
-
|
|
118
|
+
artifactTypesToDownload = await prompt('Which artifact type(s) do you want to download:', {
|
|
136
119
|
type: 'multiselect',
|
|
137
|
-
options:
|
|
120
|
+
options: availableArtifactTypes.map((artifactType) => ({
|
|
121
|
+
label: APP_BUILD_ARTIFACT_LABELS[artifactType],
|
|
122
|
+
value: artifactType,
|
|
123
|
+
})),
|
|
138
124
|
});
|
|
139
|
-
if (!
|
|
125
|
+
if (!artifactTypesToDownload || artifactTypesToDownload.length === 0) {
|
|
140
126
|
consola.error('You must select at least one artifact type to download.');
|
|
141
127
|
process.exit(1);
|
|
142
128
|
}
|
|
143
|
-
// Set flags based on selection
|
|
144
|
-
downloadApk = selectedArtifacts.includes('apk');
|
|
145
|
-
downloadAab = selectedArtifacts.includes('aab');
|
|
146
|
-
downloadIpa = selectedArtifacts.includes('ipa');
|
|
147
|
-
downloadZip = selectedArtifacts.includes('zip');
|
|
148
|
-
}
|
|
149
|
-
// Download artifacts if flags are set
|
|
150
|
-
if (downloadApk) {
|
|
151
|
-
await handleArtifactDownload({
|
|
152
|
-
appId,
|
|
153
|
-
buildId: buildId,
|
|
154
|
-
buildArtifacts: build.appBuildArtifacts,
|
|
155
|
-
artifactType: 'apk',
|
|
156
|
-
filePath: typeof options.apk === 'string' ? options.apk : undefined,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
if (downloadAab) {
|
|
160
|
-
await handleArtifactDownload({
|
|
161
|
-
appId,
|
|
162
|
-
buildId: buildId,
|
|
163
|
-
buildArtifacts: build.appBuildArtifacts,
|
|
164
|
-
artifactType: 'aab',
|
|
165
|
-
filePath: typeof options.aab === 'string' ? options.aab : undefined,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
if (downloadIpa) {
|
|
169
|
-
await handleArtifactDownload({
|
|
170
|
-
appId,
|
|
171
|
-
buildId: buildId,
|
|
172
|
-
buildArtifacts: build.appBuildArtifacts,
|
|
173
|
-
artifactType: 'ipa',
|
|
174
|
-
filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
|
|
175
|
-
});
|
|
176
129
|
}
|
|
177
|
-
|
|
178
|
-
|
|
130
|
+
for (const artifactType of artifactTypesToDownload) {
|
|
131
|
+
const option = options[artifactType];
|
|
132
|
+
await handleAppBuildArtifactDownload({
|
|
179
133
|
appId,
|
|
180
|
-
buildId
|
|
134
|
+
buildId,
|
|
181
135
|
buildArtifacts: build.appBuildArtifacts,
|
|
182
|
-
artifactType
|
|
183
|
-
|
|
136
|
+
artifactType,
|
|
137
|
+
formFactor: options.formFactor,
|
|
138
|
+
filePath: typeof option === 'string' ? option : undefined,
|
|
184
139
|
});
|
|
185
140
|
}
|
|
186
141
|
}),
|
|
187
142
|
});
|
|
188
|
-
/**
|
|
189
|
-
* Download a build artifact (APK, AAB, IPA, or ZIP).
|
|
190
|
-
*/
|
|
191
|
-
const handleArtifactDownload = async (options) => {
|
|
192
|
-
const { appId, buildId, buildArtifacts, artifactType, filePath } = options;
|
|
193
|
-
try {
|
|
194
|
-
const artifactTypeUpper = artifactType.toUpperCase();
|
|
195
|
-
consola.start(`Downloading ${artifactTypeUpper}...`);
|
|
196
|
-
// Find the artifact
|
|
197
|
-
const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
|
|
198
|
-
if (!artifact) {
|
|
199
|
-
consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (artifact.status !== 'ready') {
|
|
203
|
-
consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
// Download the artifact
|
|
207
|
-
const artifactData = await appBuildsService.downloadArtifact({
|
|
208
|
-
appId,
|
|
209
|
-
appBuildId: buildId,
|
|
210
|
-
artifactId: artifact.id,
|
|
211
|
-
});
|
|
212
|
-
// Determine the file path
|
|
213
|
-
let outputPath;
|
|
214
|
-
if (filePath) {
|
|
215
|
-
// Use provided path (can be relative or absolute)
|
|
216
|
-
outputPath = path.resolve(filePath);
|
|
217
|
-
}
|
|
218
|
-
else {
|
|
219
|
-
// Default to current working directory with build ID as filename
|
|
220
|
-
outputPath = path.resolve(`${buildId}.${artifactType}`);
|
|
221
|
-
}
|
|
222
|
-
// Save the file
|
|
223
|
-
await fs.writeFile(outputPath, Buffer.from(artifactData));
|
|
224
|
-
consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
|
|
228
|
-
}
|
|
229
|
-
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DEFAULT_API_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 fs from 'fs/promises';
|
|
6
|
+
import nock from 'nock';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import downloadCommand from './download.js';
|
|
11
|
+
vi.mock('@/utils/user-config.js');
|
|
12
|
+
vi.mock('@/utils/prompt.js');
|
|
13
|
+
vi.mock('@/services/authorization-service.js');
|
|
14
|
+
vi.mock('consola');
|
|
15
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
16
|
+
isInteractive: () => false,
|
|
17
|
+
}));
|
|
18
|
+
describe('apps-builds-download', () => {
|
|
19
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
20
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
21
|
+
const mockConsola = vi.mocked(consola);
|
|
22
|
+
const testToken = 'test-token';
|
|
23
|
+
const appId = '00000000-0000-0000-0000-000000000001';
|
|
24
|
+
const buildId = '00000000-0000-0000-0000-000000000002';
|
|
25
|
+
const artifactId = 'artifact-1';
|
|
26
|
+
const artifactContent = 'zipped app bundle';
|
|
27
|
+
let outputDirectory;
|
|
28
|
+
const mockSimulatorBuild = () => nock(DEFAULT_API_BASE_URL)
|
|
29
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
30
|
+
.query({ relations: 'appBuildArtifacts,job' })
|
|
31
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
32
|
+
.reply(200, {
|
|
33
|
+
id: buildId,
|
|
34
|
+
platform: 'ios',
|
|
35
|
+
type: 'simulator',
|
|
36
|
+
job: { status: 'succeeded' },
|
|
37
|
+
appBuildArtifacts: [
|
|
38
|
+
{ id: 'artifact-0', status: 'ready', type: 'xcarchive' },
|
|
39
|
+
{ formFactor: 'mobile', id: artifactId, status: 'ready', type: 'app' },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
45
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
46
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
47
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
48
|
+
throw new Error(`Process exited with code ${code}`);
|
|
49
|
+
});
|
|
50
|
+
outputDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-download-'));
|
|
51
|
+
});
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
nock.cleanAll();
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
await fs.rm(outputDirectory, { force: true, recursive: true });
|
|
56
|
+
});
|
|
57
|
+
it('should download the app artifact of an iOS simulator build with --app', async () => {
|
|
58
|
+
const outputPath = path.join(outputDirectory, 'simulator.app.zip');
|
|
59
|
+
const buildScope = mockSimulatorBuild();
|
|
60
|
+
const downloadScope = nock(DEFAULT_API_BASE_URL)
|
|
61
|
+
.get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
|
|
62
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
63
|
+
.reply(200, artifactContent);
|
|
64
|
+
await downloadCommand.action({ appId, buildId, app: outputPath }, undefined);
|
|
65
|
+
expect(buildScope.isDone()).toBe(true);
|
|
66
|
+
expect(downloadScope.isDone()).toBe(true);
|
|
67
|
+
expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
|
|
68
|
+
expect(mockConsola.success).toHaveBeenCalledWith(`APP downloaded successfully: ${outputPath}`);
|
|
69
|
+
});
|
|
70
|
+
it('should write the app artifact to `<build-id>.app.zip` by default', async () => {
|
|
71
|
+
vi.spyOn(process, 'cwd').mockReturnValue(outputDirectory);
|
|
72
|
+
mockSimulatorBuild();
|
|
73
|
+
nock(DEFAULT_API_BASE_URL)
|
|
74
|
+
.get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
|
|
75
|
+
.reply(200, artifactContent);
|
|
76
|
+
await downloadCommand.action({ appId, buildId, app: true }, undefined);
|
|
77
|
+
const outputPath = path.join(outputDirectory, `${buildId}.app.zip`);
|
|
78
|
+
expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
|
|
79
|
+
});
|
|
80
|
+
it('should reject --zip for an iOS build', async () => {
|
|
81
|
+
mockSimulatorBuild();
|
|
82
|
+
await expect(downloadCommand.action({ appId, buildId, zip: true }, undefined)).rejects.toThrow('Process exited with code 1');
|
|
83
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The ZIP artifact is not available for iOS builds.');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import appBuildsService from '../../../services/app-builds.js';
|
|
2
2
|
import { defineCommand, defineOptions } from 'zodline';
|
|
3
3
|
import { bootAndroidEmulator, findAllAndroidEmulators, installAndroidApp, launchAndroidApp, } from '../../../utils/android-emulator.js';
|
|
4
|
+
import { findAppBuildArtifact } from '../../../utils/app-build-artifacts.js';
|
|
4
5
|
import { withAuth } from '../../../utils/auth.js';
|
|
5
6
|
import { isInteractive } from '../../../utils/environment.js';
|
|
6
7
|
import { bootIosSimulator, findAllIosSimulators, installIosApp, launchIosApp, } from '../../../utils/ios-simulator.js';
|
|
@@ -99,7 +100,7 @@ export default defineCommand({
|
|
|
99
100
|
process.exit(1);
|
|
100
101
|
}
|
|
101
102
|
const artifactType = isAndroid ? 'apk' : 'app';
|
|
102
|
-
const artifact = build.appBuildArtifacts?.
|
|
103
|
+
const artifact = findAppBuildArtifact(build.appBuildArtifacts?.filter((artifact) => artifact.status === 'ready'), { type: artifactType });
|
|
103
104
|
if (!artifact) {
|
|
104
105
|
consola.error(`No ${artifactType.toUpperCase()} artifact is available for this build.`);
|
|
105
106
|
process.exit(1);
|
|
@@ -15,6 +15,7 @@ import { withAuth } from '../../utils/auth.js';
|
|
|
15
15
|
import { isInteractive } from '../../utils/environment.js';
|
|
16
16
|
import { getMessageFromUnknownError, UserError } from '../../utils/error.js';
|
|
17
17
|
import { isReadable } from '../../utils/file.js';
|
|
18
|
+
import { getGitRepositoryPath } from '../../utils/git.js';
|
|
18
19
|
import { prompt, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
19
20
|
import zip from '../../utils/zip.js';
|
|
20
21
|
import { defineCommand, defineOptions } from 'zodline';
|
|
@@ -24,8 +25,6 @@ import os from 'os';
|
|
|
24
25
|
import path from 'path';
|
|
25
26
|
import { z } from 'zod';
|
|
26
27
|
const SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
|
27
|
-
// Maps the providers of `parseGitRemoteUrl` to the providers of git connections.
|
|
28
|
-
const GIT_CONNECTION_PROVIDERS = { azure: 'azure_devops' };
|
|
29
28
|
export default defineCommand({
|
|
30
29
|
description: 'Import apps from an Ionic Appflow export.',
|
|
31
30
|
options: defineOptions(z.object({
|
|
@@ -102,8 +101,11 @@ export default defineCommand({
|
|
|
102
101
|
const { resolvedApps, skippedApps: unresolvedApps } = await resolveIonicAppTypes(selectedApps, ionicAppType);
|
|
103
102
|
selectedSkippedApps.push(...unresolvedApps);
|
|
104
103
|
await assignUniqueAppNames(resolvedApps, organizationId);
|
|
105
|
-
const
|
|
106
|
-
for (const provider of
|
|
104
|
+
const gitConnectionIds = await findGitConnectionIds(resolvedApps, organizationId);
|
|
105
|
+
for (const provider of getGitProviders(resolvedApps)) {
|
|
106
|
+
if (gitConnectionIds.has(provider)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
107
109
|
const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
|
|
108
110
|
consola.warn(`The organization has no \`${provider}\` git connection, so the repositories of ${appCount} app(s) will not be linked. Connect the git provider at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${organizationId}/git before running the import to link them automatically, or link the repositories manually in the Capawesome Cloud Console afterwards.`);
|
|
109
111
|
}
|
|
@@ -125,7 +127,7 @@ export default defineCommand({
|
|
|
125
127
|
if (dryRun) {
|
|
126
128
|
continue;
|
|
127
129
|
}
|
|
128
|
-
await importApp(organizationId, outcome,
|
|
130
|
+
await importApp(organizationId, outcome, gitConnectionIds);
|
|
129
131
|
}
|
|
130
132
|
const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
|
|
131
133
|
if (json) {
|
|
@@ -234,22 +236,23 @@ const assignUniqueAppNames = async (apps, organizationId) => {
|
|
|
234
236
|
}
|
|
235
237
|
}
|
|
236
238
|
};
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
for (const provider of
|
|
241
|
-
const
|
|
239
|
+
const getGitProviders = (apps) => new Set(apps.flatMap((app) => (app.repository ? [app.repository.provider] : [])));
|
|
240
|
+
const findGitConnectionIds = async (apps, organizationId) => {
|
|
241
|
+
const gitConnectionIds = new Map();
|
|
242
|
+
for (const provider of getGitProviders(apps)) {
|
|
243
|
+
const [gitConnection] = await gitConnectionsService.findAll({
|
|
242
244
|
organizationId,
|
|
243
|
-
provider
|
|
245
|
+
provider,
|
|
246
|
+
restricted: false,
|
|
244
247
|
limit: 1,
|
|
245
248
|
});
|
|
246
|
-
if (
|
|
247
|
-
|
|
249
|
+
if (gitConnection) {
|
|
250
|
+
gitConnectionIds.set(provider, gitConnection.id);
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
|
-
return
|
|
253
|
+
return gitConnectionIds;
|
|
251
254
|
};
|
|
252
|
-
const importApp = async (organizationId, outcome,
|
|
255
|
+
const importApp = async (organizationId, outcome, gitConnectionIds) => {
|
|
253
256
|
const { app } = outcome;
|
|
254
257
|
consola.start(`Importing app \`${app.sourceName}\`...`);
|
|
255
258
|
let appId;
|
|
@@ -426,22 +429,20 @@ const importApp = async (organizationId, outcome, unconnectedGitProviders) => {
|
|
|
426
429
|
outcome.errors.push(`Failed to create automation \`${automation.name}\`: ${getMessageFromUnknownError(error)}`);
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
|
-
if (app.repository
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
catch (error) {
|
|
444
|
-
app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` could not be linked: ${getMessageFromUnknownError(error)} Make sure the git provider is connected in the Capawesome Cloud Console and link the repository manually.`);
|
|
432
|
+
if (app.repository) {
|
|
433
|
+
const gitConnectionId = gitConnectionIds.get(app.repository.provider);
|
|
434
|
+
const repositoryPath = getGitRepositoryPath(app.repository);
|
|
435
|
+
if (!gitConnectionId) {
|
|
436
|
+
app.notes.push(`The repository \`${repositoryPath}\` was not linked because the organization has no \`${app.repository.provider}\` git connection.`);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
try {
|
|
440
|
+
await appsService.linkRepository({ appId, gitConnectionId, path: repositoryPath });
|
|
441
|
+
consola.success(`Linked repository \`${repositoryPath}\`.`);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
app.notes.push(`The repository \`${repositoryPath}\` could not be linked: ${getMessageFromUnknownError(error)} Link the repository manually in the Capawesome Cloud Console.`);
|
|
445
|
+
}
|
|
445
446
|
}
|
|
446
447
|
}
|
|
447
448
|
};
|
|
@@ -153,12 +153,11 @@ describe('apps-import', () => {
|
|
|
153
153
|
})
|
|
154
154
|
.reply(201, { id: 'automation-1', name: 'Android Release' })
|
|
155
155
|
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
156
|
-
.query({ provider: 'github', limit: 1 })
|
|
156
|
+
.query({ provider: 'github', restricted: 'false', limit: 1 })
|
|
157
157
|
.reply(200, [{ id: 'git-connection-1', provider: 'github' }])
|
|
158
158
|
.put(`/v1/apps/${appId}/repository`, {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
repositorySlug: 'appflow-export-test',
|
|
159
|
+
gitConnectionId: 'git-connection-1',
|
|
160
|
+
path: 'robingenz/appflow-export-test',
|
|
162
161
|
})
|
|
163
162
|
.reply(200, { id: appId });
|
|
164
163
|
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
@@ -409,19 +408,14 @@ describe('apps-import', () => {
|
|
|
409
408
|
.query({ organizationId, limit: 50, offset: 0 })
|
|
410
409
|
.reply(200, [])
|
|
411
410
|
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
412
|
-
.query({ provider: 'azure_devops', limit: 1 })
|
|
411
|
+
.query({ provider: 'azure_devops', restricted: 'false', limit: 1 })
|
|
413
412
|
.reply(200, [{ id: 'git-connection-1', provider: 'azure_devops' }])
|
|
414
413
|
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
415
414
|
.query({ organizationId })
|
|
416
415
|
.reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
|
|
417
416
|
.get('/v1/apps/app-1/channels')
|
|
418
417
|
.reply(200, [])
|
|
419
|
-
.put('/v1/apps/app-1/repository', {
|
|
420
|
-
ownerSlug: 'my-org',
|
|
421
|
-
provider: 'azure',
|
|
422
|
-
repositorySlug: 'my-repo',
|
|
423
|
-
projectSlug: 'my-project',
|
|
424
|
-
})
|
|
418
|
+
.put('/v1/apps/app-1/repository', { gitConnectionId: 'git-connection-1', path: 'my-org/my-project/my-repo' })
|
|
425
419
|
.reply(200, { id: 'app-1' });
|
|
426
420
|
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
427
421
|
expect(scope.isDone()).toBe(true);
|
|
@@ -445,7 +439,7 @@ describe('apps-import', () => {
|
|
|
445
439
|
.query({ organizationId, limit: 50, offset: 0 })
|
|
446
440
|
.reply(200, [])
|
|
447
441
|
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
448
|
-
.query({ provider: 'github', limit: 1 })
|
|
442
|
+
.query({ provider: 'github', restricted: 'false', limit: 1 })
|
|
449
443
|
.reply(200, [])
|
|
450
444
|
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
451
445
|
.query({ organizationId })
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
|
|
1
2
|
import appsService from '../../services/apps.js';
|
|
3
|
+
import gitConnectionsService from '../../services/git-connections.js';
|
|
2
4
|
import { withAuth } from '../../utils/auth.js';
|
|
3
5
|
import { isInteractive } from '../../utils/environment.js';
|
|
4
|
-
import { getGitRemoteInfo } from '../../utils/git.js';
|
|
6
|
+
import { getGitRemoteInfo, getGitRepositoryPath } from '../../utils/git.js';
|
|
5
7
|
import { promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
6
8
|
import consola from 'consola';
|
|
7
9
|
import { z } from 'zod';
|
|
@@ -10,9 +12,13 @@ export default defineCommand({
|
|
|
10
12
|
description: 'Connect a git repository to an app.',
|
|
11
13
|
options: defineOptions(z.object({
|
|
12
14
|
appId: z.string().optional().describe('ID of the app.'),
|
|
15
|
+
gitConnectionId: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('ID of the git connection to use. Defaults to the first git connection of the organization for the git provider of the repository.'),
|
|
13
19
|
})),
|
|
14
20
|
action: withAuth(async (options, args) => {
|
|
15
|
-
let { appId } = options;
|
|
21
|
+
let { appId, gitConnectionId } = options;
|
|
16
22
|
if (!appId) {
|
|
17
23
|
if (!isInteractive()) {
|
|
18
24
|
consola.error('You must provide the app ID when running in non-interactive environment.');
|
|
@@ -22,12 +28,24 @@ export default defineCommand({
|
|
|
22
28
|
appId = await promptAppSelection(organizationId);
|
|
23
29
|
}
|
|
24
30
|
const gitRemoteInfo = getGitRemoteInfo();
|
|
31
|
+
if (!gitConnectionId) {
|
|
32
|
+
const app = await appsService.findOne({ appId });
|
|
33
|
+
const [gitConnection] = await gitConnectionsService.findAll({
|
|
34
|
+
organizationId: app.organizationId,
|
|
35
|
+
provider: gitRemoteInfo.provider,
|
|
36
|
+
restricted: false,
|
|
37
|
+
limit: 1,
|
|
38
|
+
});
|
|
39
|
+
if (!gitConnection) {
|
|
40
|
+
consola.error(`No \`${gitRemoteInfo.provider}\` git connection found in the organization. Connect the git provider first at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${app.organizationId}/git.`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
gitConnectionId = gitConnection.id;
|
|
44
|
+
}
|
|
25
45
|
await appsService.linkRepository({
|
|
26
46
|
appId,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
repositorySlug: gitRemoteInfo.repositorySlug,
|
|
30
|
-
projectSlug: gitRemoteInfo.projectSlug,
|
|
47
|
+
gitConnectionId,
|
|
48
|
+
path: getGitRepositoryPath(gitRemoteInfo),
|
|
31
49
|
});
|
|
32
50
|
consola.success('Repository connected successfully.');
|
|
33
51
|
}),
|
|
@@ -13,7 +13,8 @@ vi.mock('consola');
|
|
|
13
13
|
vi.mock('@/utils/environment.js', () => ({
|
|
14
14
|
isInteractive: () => true,
|
|
15
15
|
}));
|
|
16
|
-
vi.mock('@/utils/git.js', () => ({
|
|
16
|
+
vi.mock('@/utils/git.js', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal()),
|
|
17
18
|
getGitRemoteInfo: () => ({
|
|
18
19
|
ownerSlug: 'capawesome-team',
|
|
19
20
|
provider: 'github',
|
|
@@ -26,10 +27,13 @@ describe('apps-link', () => {
|
|
|
26
27
|
const mockPromptAppSelection = vi.mocked(promptAppSelection);
|
|
27
28
|
const mockConsola = vi.mocked(consola);
|
|
28
29
|
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
30
|
+
const appId = 'app-123';
|
|
31
|
+
const organizationId = 'org-1';
|
|
32
|
+
const testToken = 'test-token';
|
|
29
33
|
beforeEach(() => {
|
|
30
34
|
vi.clearAllMocks();
|
|
31
|
-
mockUserConfig.read.mockReturnValue({ token:
|
|
32
|
-
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(
|
|
35
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
36
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
33
37
|
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
34
38
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
35
39
|
throw new Error(`Process exited with code ${code}`);
|
|
@@ -39,56 +43,50 @@ describe('apps-link', () => {
|
|
|
39
43
|
nock.cleanAll();
|
|
40
44
|
vi.restoreAllMocks();
|
|
41
45
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
46
|
+
const mockGitConnectionLookup = (gitConnections) => nock(DEFAULT_API_BASE_URL)
|
|
47
|
+
.get(`/v1/apps/${appId}`)
|
|
48
|
+
.reply(200, { id: appId, name: 'Test App', organizationId, type: 'capacitor' })
|
|
49
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
50
|
+
.query({ provider: 'github', restricted: 'false', limit: 1 })
|
|
51
|
+
.reply(200, gitConnections);
|
|
52
|
+
it('should link repository using the git connection of the organization', async () => {
|
|
53
|
+
const scope = mockGitConnectionLookup([{ id: 'git-connection-1' }])
|
|
54
|
+
.put(`/v1/apps/${appId}/repository`, { gitConnectionId: 'git-connection-1', path: 'capawesome-team/cli' })
|
|
52
55
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
53
56
|
.reply(200, { id: appId, name: 'Test App' });
|
|
54
|
-
await linkCommand.action(
|
|
57
|
+
await linkCommand.action({ appId }, undefined);
|
|
55
58
|
expect(scope.isDone()).toBe(true);
|
|
56
59
|
expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.');
|
|
57
60
|
});
|
|
61
|
+
it('should link repository using the provided git connection ID', async () => {
|
|
62
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
63
|
+
.put(`/v1/apps/${appId}/repository`, { gitConnectionId: 'git-connection-2', path: 'capawesome-team/cli' })
|
|
64
|
+
.reply(200, { id: appId, name: 'Test App' });
|
|
65
|
+
await linkCommand.action({ appId, gitConnectionId: 'git-connection-2' }, undefined);
|
|
66
|
+
expect(scope.isDone()).toBe(true);
|
|
67
|
+
});
|
|
58
68
|
it('should prompt for organization and app when app ID is not provided', async () => {
|
|
59
|
-
|
|
60
|
-
const orgId = 'org-1';
|
|
61
|
-
const testToken = 'test-token';
|
|
62
|
-
const options = {};
|
|
63
|
-
mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
|
|
69
|
+
mockPromptOrganizationSelection.mockResolvedValueOnce(organizationId);
|
|
64
70
|
mockPromptAppSelection.mockResolvedValueOnce(appId);
|
|
65
|
-
const scope =
|
|
66
|
-
.put(`/v1/apps/${appId}/repository`, {
|
|
67
|
-
ownerSlug: 'capawesome-team',
|
|
68
|
-
provider: 'github',
|
|
69
|
-
repositorySlug: 'cli',
|
|
70
|
-
})
|
|
71
|
-
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
71
|
+
const scope = mockGitConnectionLookup([{ id: 'git-connection-1' }])
|
|
72
|
+
.put(`/v1/apps/${appId}/repository`, { gitConnectionId: 'git-connection-1', path: 'capawesome-team/cli' })
|
|
72
73
|
.reply(200, { id: appId, name: 'Test App' });
|
|
73
|
-
await linkCommand.action(
|
|
74
|
+
await linkCommand.action({}, undefined);
|
|
74
75
|
expect(scope.isDone()).toBe(true);
|
|
75
76
|
expect(mockPromptOrganizationSelection).toHaveBeenCalled();
|
|
76
|
-
expect(mockPromptAppSelection).toHaveBeenCalledWith(
|
|
77
|
-
|
|
77
|
+
expect(mockPromptAppSelection).toHaveBeenCalledWith(organizationId);
|
|
78
|
+
});
|
|
79
|
+
it('should error if the organization has no git connection for the provider', async () => {
|
|
80
|
+
const scope = mockGitConnectionLookup([]);
|
|
81
|
+
await expect(linkCommand.action({ appId }, undefined)).rejects.toThrow('Process exited with code 1');
|
|
82
|
+
expect(scope.isDone()).toBe(true);
|
|
83
|
+
expect(mockConsola.error).toHaveBeenCalledWith(expect.stringContaining('No `github` git connection found'));
|
|
78
84
|
});
|
|
79
85
|
it('should handle API error', async () => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.put(`/v1/apps/${appId}/repository`, {
|
|
85
|
-
ownerSlug: 'capawesome-team',
|
|
86
|
-
provider: 'github',
|
|
87
|
-
repositorySlug: 'cli',
|
|
88
|
-
})
|
|
89
|
-
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
90
|
-
.reply(400, { message: 'Git provider not connected' });
|
|
91
|
-
await expect(linkCommand.action(options, undefined)).rejects.toThrow();
|
|
86
|
+
const scope = mockGitConnectionLookup([{ id: 'git-connection-1' }])
|
|
87
|
+
.put(`/v1/apps/${appId}/repository`, { gitConnectionId: 'git-connection-1', path: 'capawesome-team/cli' })
|
|
88
|
+
.reply(400, { message: 'Repository not found' });
|
|
89
|
+
await expect(linkCommand.action({ appId }, undefined)).rejects.toThrow();
|
|
92
90
|
expect(scope.isDone()).toBe(true);
|
|
93
91
|
});
|
|
94
92
|
});
|
|
@@ -54,8 +54,8 @@ export default defineCommand({
|
|
|
54
54
|
.optional()
|
|
55
55
|
.describe('The rollout percentage for the deployment (0-100). Default: 100.'),
|
|
56
56
|
stack: z
|
|
57
|
-
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
58
|
-
message: 'Build stack must be
|
|
57
|
+
.enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
|
|
58
|
+
message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
|
|
59
59
|
})
|
|
60
60
|
.optional()
|
|
61
61
|
.describe('The build stack to use for the build process.'),
|
|
@@ -16,6 +16,9 @@ class GitConnectionsServiceImpl {
|
|
|
16
16
|
if (dto.provider !== undefined) {
|
|
17
17
|
params.provider = dto.provider;
|
|
18
18
|
}
|
|
19
|
+
if (dto.restricted !== undefined) {
|
|
20
|
+
params.restricted = dto.restricted.toString();
|
|
21
|
+
}
|
|
19
22
|
const response = await this.httpClient.get(`/v1/organizations/${dto.organizationId}/git-connections`, {
|
|
20
23
|
headers: {
|
|
21
24
|
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import appBuildsService from '../services/app-builds.js';
|
|
2
|
+
import consola from 'consola';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
export const APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM = {
|
|
6
|
+
android: ['apk', 'aab'],
|
|
7
|
+
ios: ['ipa', 'app'],
|
|
8
|
+
web: ['zip'],
|
|
9
|
+
};
|
|
10
|
+
export const APP_BUILD_ARTIFACT_LABELS = {
|
|
11
|
+
aab: 'AAB',
|
|
12
|
+
apk: 'APK',
|
|
13
|
+
app: 'APP',
|
|
14
|
+
ipa: 'IPA',
|
|
15
|
+
zip: 'ZIP',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The supported form factors, ordered by selection priority.
|
|
19
|
+
*/
|
|
20
|
+
export const APP_BUILD_ARTIFACT_FORM_FACTORS = ['mobile', 'watch', 'tv', 'automotive'];
|
|
21
|
+
/**
|
|
22
|
+
* Download the build artifact of the given type and report the outcome to the user.
|
|
23
|
+
*
|
|
24
|
+
* Skips the download with a warning if no matching artifact exists or it is not ready yet.
|
|
25
|
+
*/
|
|
26
|
+
export const handleAppBuildArtifactDownload = async (options) => {
|
|
27
|
+
const { appId, buildId, buildArtifacts, artifactType, formFactor, filePath } = options;
|
|
28
|
+
const artifactLabel = APP_BUILD_ARTIFACT_LABELS[artifactType];
|
|
29
|
+
try {
|
|
30
|
+
consola.start(`Downloading ${artifactLabel}...`);
|
|
31
|
+
const artifact = findAppBuildArtifact(buildArtifacts, { type: artifactType, formFactor });
|
|
32
|
+
if (!artifact) {
|
|
33
|
+
consola.warn(formFactor
|
|
34
|
+
? `No ${artifactLabel} artifact with form factor "${formFactor}" found for this build.`
|
|
35
|
+
: `No ${artifactLabel} artifact found for this build.`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (artifact.status !== 'ready') {
|
|
39
|
+
consola.warn(`${artifactLabel} artifact is not ready (status: ${artifact.status}).`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const outputPath = await downloadAppBuildArtifact({ appId, buildId, artifact, filePath });
|
|
43
|
+
consola.success(`${artifactLabel} downloaded successfully: ${outputPath}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
consola.error(`Failed to download ${artifactLabel}:`, error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Find a build artifact by its type and, optionally, its form factor.
|
|
51
|
+
*
|
|
52
|
+
* Without a form factor, the first artifact in the order of `APP_BUILD_ARTIFACT_FORM_FACTORS` is returned.
|
|
53
|
+
*/
|
|
54
|
+
export const findAppBuildArtifact = (artifacts, options) => {
|
|
55
|
+
const artifactsOfType = artifacts?.filter((artifact) => artifact.type === options.type) ?? [];
|
|
56
|
+
const formFactors = options.formFactor ? [options.formFactor] : APP_BUILD_ARTIFACT_FORM_FACTORS;
|
|
57
|
+
for (const formFactor of formFactors) {
|
|
58
|
+
const artifact = artifactsOfType.find((artifact) => artifact.formFactor === formFactor);
|
|
59
|
+
if (artifact) {
|
|
60
|
+
return artifact;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Build the default file name for a build artifact.
|
|
67
|
+
*/
|
|
68
|
+
export const getAppBuildArtifactFileName = (buildId, artifact) => {
|
|
69
|
+
const formFactorSuffix = artifact.formFactor === 'mobile' ? '' : `-${artifact.formFactor}`;
|
|
70
|
+
return `${buildId}${formFactorSuffix}.${getFileExtension(artifact.type)}`;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Download a build artifact to disk and return the output path.
|
|
74
|
+
*/
|
|
75
|
+
export const downloadAppBuildArtifact = async (options) => {
|
|
76
|
+
const { appId, buildId, artifact, filePath } = options;
|
|
77
|
+
const artifactData = await appBuildsService.downloadArtifact({
|
|
78
|
+
appId,
|
|
79
|
+
appBuildId: buildId,
|
|
80
|
+
artifactId: artifact.id,
|
|
81
|
+
});
|
|
82
|
+
const outputPath = path.resolve(filePath || getAppBuildArtifactFileName(buildId, artifact));
|
|
83
|
+
await fs.writeFile(outputPath, Buffer.from(artifactData));
|
|
84
|
+
return outputPath;
|
|
85
|
+
};
|
|
86
|
+
// The app artifact is a zipped `.app` bundle
|
|
87
|
+
const getFileExtension = (artifactType) => artifactType === 'app' ? 'app.zip' : artifactType;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { findAppBuildArtifact, getAppBuildArtifactFileName } from './app-build-artifacts.js';
|
|
3
|
+
const createArtifact = (artifact) => ({
|
|
4
|
+
id: 'artifact-id',
|
|
5
|
+
fileMimeType: 'application/octet-stream',
|
|
6
|
+
fileName: 'app.aab',
|
|
7
|
+
fileSizeInBytes: 1,
|
|
8
|
+
formFactor: 'mobile',
|
|
9
|
+
status: 'ready',
|
|
10
|
+
type: 'aab',
|
|
11
|
+
...artifact,
|
|
12
|
+
});
|
|
13
|
+
describe('findAppBuildArtifact', () => {
|
|
14
|
+
it('should return the artifacts in the selection order', () => {
|
|
15
|
+
const watchArtifact = createArtifact({ id: 'watch', formFactor: 'watch' });
|
|
16
|
+
const mobileArtifact = createArtifact({ id: 'mobile', formFactor: 'mobile' });
|
|
17
|
+
expect(findAppBuildArtifact([watchArtifact, mobileArtifact], { type: 'aab' })).toBe(mobileArtifact);
|
|
18
|
+
});
|
|
19
|
+
it('should fall back to the next form factor if no mobile artifact exists', () => {
|
|
20
|
+
const automotiveArtifact = createArtifact({ id: 'automotive', formFactor: 'automotive' });
|
|
21
|
+
const tvArtifact = createArtifact({ id: 'tv', formFactor: 'tv' });
|
|
22
|
+
expect(findAppBuildArtifact([automotiveArtifact, tvArtifact], { type: 'aab' })).toBe(tvArtifact);
|
|
23
|
+
});
|
|
24
|
+
it('should return the artifact with the requested form factor', () => {
|
|
25
|
+
const mobileArtifact = createArtifact({ id: 'mobile', formFactor: 'mobile' });
|
|
26
|
+
const watchArtifact = createArtifact({ id: 'watch', formFactor: 'watch' });
|
|
27
|
+
expect(findAppBuildArtifact([mobileArtifact, watchArtifact], { type: 'aab', formFactor: 'watch' })).toBe(watchArtifact);
|
|
28
|
+
});
|
|
29
|
+
it('should return undefined if no artifact matches the requested form factor', () => {
|
|
30
|
+
const mobileArtifact = createArtifact({ formFactor: 'mobile' });
|
|
31
|
+
expect(findAppBuildArtifact([mobileArtifact], { type: 'aab', formFactor: 'tv' })).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('should ignore artifacts of another type', () => {
|
|
34
|
+
const apkArtifact = createArtifact({ id: 'apk', type: 'apk' });
|
|
35
|
+
const aabArtifact = createArtifact({ id: 'aab', type: 'aab' });
|
|
36
|
+
expect(findAppBuildArtifact([apkArtifact, aabArtifact], { type: 'apk' })).toBe(apkArtifact);
|
|
37
|
+
});
|
|
38
|
+
it('should return undefined if no artifacts are given', () => {
|
|
39
|
+
expect(findAppBuildArtifact(undefined, { type: 'aab' })).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('getAppBuildArtifactFileName', () => {
|
|
43
|
+
it('should not add a suffix for a mobile artifact', () => {
|
|
44
|
+
const artifact = createArtifact({ formFactor: 'mobile' });
|
|
45
|
+
expect(getAppBuildArtifactFileName('build-id', artifact)).toBe('build-id.aab');
|
|
46
|
+
});
|
|
47
|
+
it('should add the form factor as a suffix for a watch artifact', () => {
|
|
48
|
+
const artifact = createArtifact({ formFactor: 'watch' });
|
|
49
|
+
expect(getAppBuildArtifactFileName('build-id', artifact)).toBe('build-id-watch.aab');
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/utils/git.js
CHANGED
|
@@ -4,8 +4,8 @@ const HOSTNAME_TO_PROVIDER = {
|
|
|
4
4
|
'github.com': 'github',
|
|
5
5
|
'gitlab.com': 'gitlab',
|
|
6
6
|
'bitbucket.org': 'bitbucket',
|
|
7
|
-
'dev.azure.com': '
|
|
8
|
-
'ssh.dev.azure.com': '
|
|
7
|
+
'dev.azure.com': 'azure_devops',
|
|
8
|
+
'ssh.dev.azure.com': 'azure_devops',
|
|
9
9
|
};
|
|
10
10
|
export const getGitRemoteInfo = () => {
|
|
11
11
|
const remoteUrl = getGitRemoteUrl();
|
|
@@ -25,7 +25,7 @@ export const parseGitRemoteUrl = (remoteUrl) => {
|
|
|
25
25
|
if (azureHttpsMatch && azureHttpsMatch[1] && azureHttpsMatch[2] && azureHttpsMatch[3]) {
|
|
26
26
|
return {
|
|
27
27
|
ownerSlug: azureHttpsMatch[1],
|
|
28
|
-
provider: '
|
|
28
|
+
provider: 'azure_devops',
|
|
29
29
|
repositorySlug: azureHttpsMatch[3],
|
|
30
30
|
projectSlug: azureHttpsMatch[2],
|
|
31
31
|
};
|
|
@@ -35,7 +35,7 @@ export const parseGitRemoteUrl = (remoteUrl) => {
|
|
|
35
35
|
if (azureSshMatch && azureSshMatch[1] && azureSshMatch[2] && azureSshMatch[3]) {
|
|
36
36
|
return {
|
|
37
37
|
ownerSlug: azureSshMatch[1],
|
|
38
|
-
provider: '
|
|
38
|
+
provider: 'azure_devops',
|
|
39
39
|
repositorySlug: azureSshMatch[3],
|
|
40
40
|
projectSlug: azureSshMatch[2],
|
|
41
41
|
};
|
|
@@ -45,7 +45,7 @@ export const parseGitRemoteUrl = (remoteUrl) => {
|
|
|
45
45
|
if (vsHttpsMatch && vsHttpsMatch[1] && vsHttpsMatch[2] && vsHttpsMatch[3]) {
|
|
46
46
|
return {
|
|
47
47
|
ownerSlug: vsHttpsMatch[1],
|
|
48
|
-
provider: '
|
|
48
|
+
provider: 'azure_devops',
|
|
49
49
|
repositorySlug: vsHttpsMatch[3],
|
|
50
50
|
projectSlug: vsHttpsMatch[2],
|
|
51
51
|
};
|
|
@@ -90,3 +90,4 @@ export const parseGitRemoteUrl = (remoteUrl) => {
|
|
|
90
90
|
}
|
|
91
91
|
throw new UserError('Could not parse git remote URL.');
|
|
92
92
|
};
|
|
93
|
+
export const getGitRepositoryPath = (remoteInfo) => [remoteInfo.ownerSlug, remoteInfo.projectSlug, remoteInfo.repositorySlug].filter(Boolean).join('/');
|
package/dist/utils/git.test.js
CHANGED
|
@@ -87,7 +87,7 @@ describe('parseGitRemoteUrl', () => {
|
|
|
87
87
|
const result = parseGitRemoteUrl('https://dev.azure.com/my-org/my-project/_git/my-repo');
|
|
88
88
|
expect(result).toEqual({
|
|
89
89
|
ownerSlug: 'my-org',
|
|
90
|
-
provider: '
|
|
90
|
+
provider: 'azure_devops',
|
|
91
91
|
repositorySlug: 'my-repo',
|
|
92
92
|
projectSlug: 'my-project',
|
|
93
93
|
});
|
|
@@ -96,7 +96,7 @@ describe('parseGitRemoteUrl', () => {
|
|
|
96
96
|
const result = parseGitRemoteUrl('git@ssh.dev.azure.com:v3/my-org/my-project/my-repo');
|
|
97
97
|
expect(result).toEqual({
|
|
98
98
|
ownerSlug: 'my-org',
|
|
99
|
-
provider: '
|
|
99
|
+
provider: 'azure_devops',
|
|
100
100
|
repositorySlug: 'my-repo',
|
|
101
101
|
projectSlug: 'my-project',
|
|
102
102
|
});
|
|
@@ -105,7 +105,7 @@ describe('parseGitRemoteUrl', () => {
|
|
|
105
105
|
const result = parseGitRemoteUrl('https://my-org.visualstudio.com/my-project/_git/my-repo');
|
|
106
106
|
expect(result).toEqual({
|
|
107
107
|
ownerSlug: 'my-org',
|
|
108
|
-
provider: '
|
|
108
|
+
provider: 'azure_devops',
|
|
109
109
|
repositorySlug: 'my-repo',
|
|
110
110
|
projectSlug: 'my-project',
|
|
111
111
|
});
|
|
@@ -2,19 +2,28 @@ import { createRequire } from 'module';
|
|
|
2
2
|
import configService from '../services/config.js';
|
|
3
3
|
import axios from 'axios';
|
|
4
4
|
import axiosRetry from 'axios-retry';
|
|
5
|
+
import consola from 'consola';
|
|
5
6
|
import { HttpProxyAgent } from 'http-proxy-agent';
|
|
6
7
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const pkg = require('../../package.json');
|
|
10
|
+
const isRateLimitError = (error) => error.response?.status === 429;
|
|
9
11
|
// Register middleware to retry failed requests
|
|
10
12
|
axiosRetry(axios, {
|
|
11
13
|
retries: 3,
|
|
14
|
+
// Waits at least as long as the `Retry-After` header of rate limited responses
|
|
12
15
|
retryDelay: axiosRetry.exponentialDelay,
|
|
13
16
|
retryCondition: (error) => {
|
|
14
|
-
// Network errors and 5xx responses are retried
|
|
17
|
+
// Network errors, rate limited requests and 5xx responses are retried
|
|
15
18
|
return (axiosRetry.isNetworkOrIdempotentRequestError(error) ||
|
|
19
|
+
isRateLimitError(error) ||
|
|
16
20
|
(error.response?.status !== undefined && error.response.status >= 500));
|
|
17
21
|
},
|
|
22
|
+
onRetry: (_retryCount, error) => {
|
|
23
|
+
if (isRateLimitError(error)) {
|
|
24
|
+
consola.warn('Rate limit reached. Retrying...');
|
|
25
|
+
}
|
|
26
|
+
},
|
|
18
27
|
});
|
|
19
28
|
/**
|
|
20
29
|
* Gets the appropriate proxy agent based on the target URL protocol and environment variables.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import configService from '../services/config.js';
|
|
2
|
+
import consola from 'consola';
|
|
2
3
|
import nock from 'nock';
|
|
3
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
// Mock the config service
|
|
@@ -7,6 +8,7 @@ vi.mock('@/services/config.js', () => ({
|
|
|
7
8
|
getValueForKey: vi.fn().mockResolvedValue('https://api.example.com'),
|
|
8
9
|
},
|
|
9
10
|
}));
|
|
11
|
+
vi.mock('consola');
|
|
10
12
|
describe('http-client', () => {
|
|
11
13
|
let originalEnv;
|
|
12
14
|
beforeEach(() => {
|
|
@@ -23,6 +25,7 @@ describe('http-client', () => {
|
|
|
23
25
|
afterEach(() => {
|
|
24
26
|
// Restore original environment variables
|
|
25
27
|
process.env = originalEnv;
|
|
28
|
+
vi.useRealTimers();
|
|
26
29
|
});
|
|
27
30
|
it('should retry requests on 5xx status codes', async () => {
|
|
28
31
|
// Mock the API_BASE_URL
|
|
@@ -50,6 +53,29 @@ describe('http-client', () => {
|
|
|
50
53
|
await expect(httpClient.get('/not-found')).rejects.toThrow();
|
|
51
54
|
expect(nock.isDone()).toBe(true);
|
|
52
55
|
});
|
|
56
|
+
it('should retry rate limited requests after the `Retry-After` delay', async () => {
|
|
57
|
+
vi.mocked(configService.getValueForKey).mockResolvedValue('https://api.example.com');
|
|
58
|
+
// Mock a 429 response followed by success
|
|
59
|
+
nock('https://api.example.com')
|
|
60
|
+
.post('/rate-limited')
|
|
61
|
+
.reply(429, { error: 'Too Many Requests' }, { 'Retry-After': '10' })
|
|
62
|
+
.post('/rate-limited')
|
|
63
|
+
.reply(200, { success: true });
|
|
64
|
+
const { default: httpClient } = await import('./http-client.js');
|
|
65
|
+
// Only fake `setTimeout` so the request itself is still served by nock
|
|
66
|
+
vi.useFakeTimers({ toFake: ['setTimeout'] });
|
|
67
|
+
const responsePromise = httpClient.post('/rate-limited');
|
|
68
|
+
// Wait until the retry has been scheduled
|
|
69
|
+
await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1));
|
|
70
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
71
|
+
expect(nock.isDone()).toBe(false);
|
|
72
|
+
await vi.runAllTimersAsync();
|
|
73
|
+
const response = await responsePromise;
|
|
74
|
+
expect(response.status).toBe(200);
|
|
75
|
+
expect(response.data).toEqual({ success: true });
|
|
76
|
+
expect(nock.isDone()).toBe(true);
|
|
77
|
+
expect(consola.warn).toHaveBeenCalledWith('Rate limit reached. Retrying...');
|
|
78
|
+
});
|
|
53
79
|
it('should eventually fail after maximum retries on persistent 5xx errors', async () => {
|
|
54
80
|
vi.mocked(configService.getValueForKey).mockResolvedValue('https://api.example.com');
|
|
55
81
|
// Mock 4 consecutive 500 responses (initial + 3 retries)
|