@capawesome/cli 4.19.0 → 4.21.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 +25 -0
- package/dist/commands/apps/import.js +75 -30
- package/dist/commands/apps/import.test.js +88 -4
- package/dist/commands/apps/link.js +24 -6
- package/dist/commands/apps/link.test.js +39 -41
- package/dist/services/git-connections.js +32 -0
- package/dist/types/git-connection.js +1 -0
- package/dist/utils/app-import.js +7 -6
- package/dist/utils/app-import.test.js +20 -0
- package/dist/utils/appflow-export.js +62 -52
- package/dist/utils/appflow-export.test.js +60 -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,31 @@
|
|
|
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.21.0](https://github.com/capawesome-team/cli/compare/v4.20.0...v4.21.0) (2026-09-22)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **apps:** link repositories via git connections ([#222](https://github.com/capawesome-team/cli/issues/222)) ([f567ea6](https://github.com/capawesome-team/cli/commit/f567ea6d0e836b4aea8e4d57ff10d934a6788fe2))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* retry rate limited requests ([#223](https://github.com/capawesome-team/cli/issues/223)) ([47d9733](https://github.com/capawesome-team/cli/commit/47d9733ec28bf0bb3235b8dccdd5b353ec24d0b5))
|
|
16
|
+
|
|
17
|
+
## [4.20.0](https://github.com/capawesome-team/cli/compare/v4.19.0...v4.20.0) (2026-09-22)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* **apps:** warn about missing git connections before importing apps ([#221](https://github.com/capawesome-team/cli/issues/221)) ([2b4646d](https://github.com/capawesome-team/cli/commit/2b4646d4c2cbcc3c8dd54c68021a9f1ed47c9521))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **apps:** remove extracted export on cancelled prompts and signals ([#219](https://github.com/capawesome-team/cli/issues/219)) ([0356500](https://github.com/capawesome-team/cli/commit/035650041f456d5831ecf2fff8cee5fb231db8db))
|
|
28
|
+
* **apps:** rename imported resources whose names only differ in case ([#218](https://github.com/capawesome-team/cli/issues/218)) ([135b577](https://github.com/capawesome-team/cli/commit/135b577e86620dd71d9297a4124a2db2fd1cf15f))
|
|
29
|
+
|
|
5
30
|
## [4.19.0](https://github.com/capawesome-team/cli/compare/v4.18.4...v4.19.0) (2026-09-22)
|
|
6
31
|
|
|
7
32
|
|
|
@@ -8,12 +8,14 @@ import appEnvironmentsService from '../../services/app-environments.js';
|
|
|
8
8
|
import appGoogleServiceAccountKeysService from '../../services/app-google-service-account-keys.js';
|
|
9
9
|
import appProvisioningProfilesService from '../../services/app-provisioning-profiles.js';
|
|
10
10
|
import appsService from '../../services/apps.js';
|
|
11
|
-
import
|
|
11
|
+
import gitConnectionsService from '../../services/git-connections.js';
|
|
12
|
+
import { generateUniqueName, isNameTaken } from '../../utils/app-import.js';
|
|
12
13
|
import { parseAppflowExport } from '../../utils/appflow-export.js';
|
|
13
14
|
import { withAuth } from '../../utils/auth.js';
|
|
14
15
|
import { isInteractive } from '../../utils/environment.js';
|
|
15
16
|
import { getMessageFromUnknownError, UserError } from '../../utils/error.js';
|
|
16
17
|
import { isReadable } from '../../utils/file.js';
|
|
18
|
+
import { getGitRepositoryPath } from '../../utils/git.js';
|
|
17
19
|
import { prompt, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
18
20
|
import zip from '../../utils/zip.js';
|
|
19
21
|
import { defineCommand, defineOptions } from 'zodline';
|
|
@@ -22,6 +24,7 @@ import fs from 'fs';
|
|
|
22
24
|
import os from 'os';
|
|
23
25
|
import path from 'path';
|
|
24
26
|
import { z } from 'zod';
|
|
27
|
+
const SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
|
25
28
|
export default defineCommand({
|
|
26
29
|
description: 'Import apps from an Ionic Appflow export.',
|
|
27
30
|
options: defineOptions(z.object({
|
|
@@ -72,6 +75,19 @@ export default defineCommand({
|
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
77
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'capawesome-appflow-import-'));
|
|
78
|
+
// The extracted export contains secrets, so it must also be removed when the
|
|
79
|
+
// process exits via `process.exit` (e.g. a cancelled prompt) or a signal,
|
|
80
|
+
// which would skip the `finally` block.
|
|
81
|
+
const removeTempDirectory = () => fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 3 });
|
|
82
|
+
const handleSignal = (signal) => {
|
|
83
|
+
removeTempDirectory();
|
|
84
|
+
// Re-raise the signal to keep the default exit code (128 + signal number).
|
|
85
|
+
process.kill(process.pid, signal);
|
|
86
|
+
};
|
|
87
|
+
process.once('exit', removeTempDirectory);
|
|
88
|
+
for (const signal of SIGNALS) {
|
|
89
|
+
process.once(signal, handleSignal);
|
|
90
|
+
}
|
|
75
91
|
try {
|
|
76
92
|
await zip.unzipToFolder(fs.readFileSync(file), tempDirectory);
|
|
77
93
|
const { apps, skippedApps, warnings } = await parseAppflowExport(tempDirectory);
|
|
@@ -85,6 +101,14 @@ export default defineCommand({
|
|
|
85
101
|
const { resolvedApps, skippedApps: unresolvedApps } = await resolveIonicAppTypes(selectedApps, ionicAppType);
|
|
86
102
|
selectedSkippedApps.push(...unresolvedApps);
|
|
87
103
|
await assignUniqueAppNames(resolvedApps, organizationId);
|
|
104
|
+
const gitConnectionIds = await findGitConnectionIds(resolvedApps, organizationId);
|
|
105
|
+
for (const provider of getGitProviders(resolvedApps)) {
|
|
106
|
+
if (gitConnectionIds.has(provider)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
|
|
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.`);
|
|
111
|
+
}
|
|
88
112
|
const outcomes = [];
|
|
89
113
|
for (const app of resolvedApps) {
|
|
90
114
|
const outcome = {
|
|
@@ -103,7 +127,7 @@ export default defineCommand({
|
|
|
103
127
|
if (dryRun) {
|
|
104
128
|
continue;
|
|
105
129
|
}
|
|
106
|
-
await importApp(organizationId, outcome);
|
|
130
|
+
await importApp(organizationId, outcome, gitConnectionIds);
|
|
107
131
|
}
|
|
108
132
|
const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
|
|
109
133
|
if (json) {
|
|
@@ -113,13 +137,15 @@ export default defineCommand({
|
|
|
113
137
|
printSummary(outcomes, selectedSkippedApps, dryRun === true, file);
|
|
114
138
|
}
|
|
115
139
|
if (errorCount > 0) {
|
|
116
|
-
// Set the exit code instead of calling `process.exit` so the `finally` block
|
|
117
|
-
// still deletes the extracted export, which contains secrets.
|
|
118
140
|
process.exitCode = 1;
|
|
119
141
|
}
|
|
120
142
|
}
|
|
121
143
|
finally {
|
|
122
|
-
|
|
144
|
+
removeTempDirectory();
|
|
145
|
+
process.off('exit', removeTempDirectory);
|
|
146
|
+
for (const signal of SIGNALS) {
|
|
147
|
+
process.off(signal, handleSignal);
|
|
148
|
+
}
|
|
123
149
|
}
|
|
124
150
|
}),
|
|
125
151
|
});
|
|
@@ -191,28 +217,42 @@ const resolveIonicAppTypes = async (apps, ionicAppType) => {
|
|
|
191
217
|
return { resolvedApps, skippedApps };
|
|
192
218
|
};
|
|
193
219
|
const assignUniqueAppNames = async (apps, organizationId) => {
|
|
194
|
-
const takenNames =
|
|
220
|
+
const takenNames = [];
|
|
195
221
|
const limit = 50;
|
|
196
222
|
let offset = 0;
|
|
197
223
|
while (true) {
|
|
198
224
|
const page = await appsService.findAll({ organizationId, limit, offset });
|
|
199
|
-
|
|
200
|
-
takenNames.add(app.name);
|
|
201
|
-
}
|
|
225
|
+
takenNames.push(...page.map((app) => app.name));
|
|
202
226
|
if (page.length < limit) {
|
|
203
227
|
break;
|
|
204
228
|
}
|
|
205
229
|
offset += limit;
|
|
206
230
|
}
|
|
207
231
|
for (const app of apps) {
|
|
208
|
-
app.name =
|
|
209
|
-
takenNames.
|
|
232
|
+
app.name = generateUniqueName(app.sourceName, takenNames);
|
|
233
|
+
takenNames.push(app.name);
|
|
210
234
|
if (app.name !== app.sourceName) {
|
|
211
|
-
app.
|
|
235
|
+
app.renames.push(`The app \`${app.sourceName}\` was renamed to \`${app.name}\` because its name is already taken in the organization.`);
|
|
212
236
|
}
|
|
213
237
|
}
|
|
214
238
|
};
|
|
215
|
-
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({
|
|
244
|
+
organizationId,
|
|
245
|
+
provider,
|
|
246
|
+
restricted: false,
|
|
247
|
+
limit: 1,
|
|
248
|
+
});
|
|
249
|
+
if (gitConnection) {
|
|
250
|
+
gitConnectionIds.set(provider, gitConnection.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return gitConnectionIds;
|
|
254
|
+
};
|
|
255
|
+
const importApp = async (organizationId, outcome, gitConnectionIds) => {
|
|
216
256
|
const { app } = outcome;
|
|
217
257
|
consola.start(`Importing app \`${app.sourceName}\`...`);
|
|
218
258
|
let appId;
|
|
@@ -297,16 +337,16 @@ const importApp = async (organizationId, outcome) => {
|
|
|
297
337
|
outcome.errors.push(`Failed to create environment \`${environment.name}\`: ${getMessageFromUnknownError(error)}`);
|
|
298
338
|
}
|
|
299
339
|
}
|
|
300
|
-
let existingChannelNames =
|
|
340
|
+
let existingChannelNames = [];
|
|
301
341
|
try {
|
|
302
342
|
const existingChannels = await appChannelsService.findAll({ appId });
|
|
303
|
-
existingChannelNames =
|
|
343
|
+
existingChannelNames = existingChannels.map((channel) => channel.name);
|
|
304
344
|
}
|
|
305
345
|
catch {
|
|
306
346
|
// If the lookup fails, channel creation errors are reported individually below.
|
|
307
347
|
}
|
|
308
348
|
for (const channel of app.channels) {
|
|
309
|
-
if (
|
|
349
|
+
if (isNameTaken(channel, existingChannelNames)) {
|
|
310
350
|
outcome.created.channels++;
|
|
311
351
|
continue;
|
|
312
352
|
}
|
|
@@ -390,18 +430,19 @@ const importApp = async (organizationId, outcome) => {
|
|
|
390
430
|
}
|
|
391
431
|
}
|
|
392
432
|
if (app.repository) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
}
|
|
405
446
|
}
|
|
406
447
|
}
|
|
407
448
|
};
|
|
@@ -422,17 +463,20 @@ const printSummary = (outcomes, skippedApps, dryRun, file) => {
|
|
|
422
463
|
Errors: outcome.errors.length,
|
|
423
464
|
})));
|
|
424
465
|
for (const outcome of outcomes) {
|
|
425
|
-
if (outcome.app.notes.length === 0 && outcome.errors.length === 0) {
|
|
466
|
+
if (outcome.app.notes.length === 0 && outcome.app.renames.length === 0 && outcome.errors.length === 0) {
|
|
426
467
|
continue;
|
|
427
468
|
}
|
|
428
469
|
consola.log(`\n${outcome.app.sourceName}:`);
|
|
429
|
-
for (const note of outcome.app.notes) {
|
|
470
|
+
for (const note of [...outcome.app.notes, ...outcome.app.renames]) {
|
|
430
471
|
consola.warn(note);
|
|
431
472
|
}
|
|
432
473
|
for (const error of outcome.errors) {
|
|
433
474
|
consola.error(error);
|
|
434
475
|
}
|
|
435
476
|
}
|
|
477
|
+
if (outcomes.some((outcome) => outcome.app.renames.length > 0)) {
|
|
478
|
+
consola.info('Some resources were renamed because their names were already taken. Make sure to update any CI/CD workflows or scripts that reference the old names.');
|
|
479
|
+
}
|
|
436
480
|
for (const outcome of outcomes) {
|
|
437
481
|
if (outcome.id) {
|
|
438
482
|
consola.info(`App URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}`);
|
|
@@ -468,6 +512,7 @@ const printJsonSummary = (outcomes, skippedApps, warnings, dryRun) => {
|
|
|
468
512
|
webUrl: outcome.id ? `${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}` : null,
|
|
469
513
|
created: outcome.created,
|
|
470
514
|
notes: outcome.app.notes,
|
|
515
|
+
renames: outcome.app.renames,
|
|
471
516
|
errors: outcome.errors,
|
|
472
517
|
})),
|
|
473
518
|
skippedApps: skippedApps.map((app) => ({
|
|
@@ -152,10 +152,12 @@ describe('apps-import', () => {
|
|
|
152
152
|
body.appEnvironmentName === 'Production');
|
|
153
153
|
})
|
|
154
154
|
.reply(201, { id: 'automation-1', name: 'Android Release' })
|
|
155
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
156
|
+
.query({ provider: 'github', restricted: 'false', limit: 1 })
|
|
157
|
+
.reply(200, [{ id: 'git-connection-1', provider: 'github' }])
|
|
155
158
|
.put(`/v1/apps/${appId}/repository`, {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
repositorySlug: 'appflow-export-test',
|
|
159
|
+
gitConnectionId: 'git-connection-1',
|
|
160
|
+
path: 'robingenz/appflow-export-test',
|
|
159
161
|
})
|
|
160
162
|
.reply(200, { id: appId });
|
|
161
163
|
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
@@ -193,7 +195,27 @@ describe('apps-import', () => {
|
|
|
193
195
|
expect(scope.isDone()).toBe(true);
|
|
194
196
|
const output = getJsonOutput();
|
|
195
197
|
expect(output.apps[0].name).toBe('My App (2)');
|
|
196
|
-
expect(output.apps[0].
|
|
198
|
+
expect(output.apps[0].renames).toContainEqual(expect.stringContaining('`My App (2)`'));
|
|
199
|
+
});
|
|
200
|
+
it('should rename the app if the name is already taken in a different case', async () => {
|
|
201
|
+
await writeExportFile([
|
|
202
|
+
{
|
|
203
|
+
folder: 'test-6668c18c',
|
|
204
|
+
files: { 'app-detail.json': { id: '6668c18c', name: 'test', appType: 'capacitor' } },
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
208
|
+
.get('/v1/apps')
|
|
209
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
210
|
+
.reply(200, [{ id: 'app-1', name: 'Test', type: 'capacitor' }])
|
|
211
|
+
.post('/v1/apps', { name: 'test (2)', type: 'capacitor' })
|
|
212
|
+
.query({ organizationId })
|
|
213
|
+
.reply(201, { id: 'app-789', name: 'test (2)', type: 'capacitor' })
|
|
214
|
+
.get('/v1/apps/app-789/channels')
|
|
215
|
+
.reply(200, []);
|
|
216
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
217
|
+
expect(scope.isDone()).toBe(true);
|
|
218
|
+
expect(getJsonOutput().apps[0].name).toBe('test (2)');
|
|
197
219
|
});
|
|
198
220
|
it('should continue with the remaining resources if one fails', async () => {
|
|
199
221
|
await writeExportFile([
|
|
@@ -368,6 +390,68 @@ describe('apps-import', () => {
|
|
|
368
390
|
{ sourceId: '22222222', sourceName: 'RN App', reason: expect.stringContaining('react_native') },
|
|
369
391
|
]);
|
|
370
392
|
});
|
|
393
|
+
it('should link an Azure DevOps repository using the matching git connection', async () => {
|
|
394
|
+
await writeExportFile([
|
|
395
|
+
{
|
|
396
|
+
folder: 'My App-6668c18c',
|
|
397
|
+
files: {
|
|
398
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
399
|
+
'repo-association.json': {
|
|
400
|
+
gitProvider: 'azure_devops',
|
|
401
|
+
cloneUrl: 'https://dev.azure.com/my-org/my-project/_git/my-repo',
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
]);
|
|
406
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
407
|
+
.get('/v1/apps')
|
|
408
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
409
|
+
.reply(200, [])
|
|
410
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
411
|
+
.query({ provider: 'azure_devops', restricted: 'false', limit: 1 })
|
|
412
|
+
.reply(200, [{ id: 'git-connection-1', provider: 'azure_devops' }])
|
|
413
|
+
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
414
|
+
.query({ organizationId })
|
|
415
|
+
.reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
|
|
416
|
+
.get('/v1/apps/app-1/channels')
|
|
417
|
+
.reply(200, [])
|
|
418
|
+
.put('/v1/apps/app-1/repository', { gitConnectionId: 'git-connection-1', path: 'my-org/my-project/my-repo' })
|
|
419
|
+
.reply(200, { id: 'app-1' });
|
|
420
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
421
|
+
expect(scope.isDone()).toBe(true);
|
|
422
|
+
expect(getJsonOutput().apps[0].notes).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
it('should not link repositories if the git provider is not connected', async () => {
|
|
425
|
+
await writeExportFile([
|
|
426
|
+
{
|
|
427
|
+
folder: 'My App-6668c18c',
|
|
428
|
+
files: {
|
|
429
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
430
|
+
'repo-association.json': {
|
|
431
|
+
gitProvider: 'github',
|
|
432
|
+
cloneUrl: 'https://github.com/robingenz/appflow-export-test.git',
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
]);
|
|
437
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
438
|
+
.get('/v1/apps')
|
|
439
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
440
|
+
.reply(200, [])
|
|
441
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
442
|
+
.query({ provider: 'github', restricted: 'false', limit: 1 })
|
|
443
|
+
.reply(200, [])
|
|
444
|
+
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
445
|
+
.query({ organizationId })
|
|
446
|
+
.reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
|
|
447
|
+
.get('/v1/apps/app-1/channels')
|
|
448
|
+
.reply(200, []);
|
|
449
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
450
|
+
expect(scope.isDone()).toBe(true);
|
|
451
|
+
expect(vi.mocked(consola).warn).toHaveBeenCalledWith(expect.stringContaining('no `github` git connection'));
|
|
452
|
+
const output = getJsonOutput();
|
|
453
|
+
expect(output.apps[0].notes).toContainEqual(expect.stringContaining('was not linked'));
|
|
454
|
+
});
|
|
371
455
|
it('should error in non-interactive environment if no file is provided', async () => {
|
|
372
456
|
await expect(importCommand.action({ organizationId }, undefined)).rejects.toThrow('Process exited with code 1');
|
|
373
457
|
expect(vi.mocked(consola).error).toHaveBeenCalledWith('You must provide the export file path when running in non-interactive environment.');
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import authorizationService from '../services/authorization-service.js';
|
|
2
|
+
import httpClient from '../utils/http-client.js';
|
|
3
|
+
class GitConnectionsServiceImpl {
|
|
4
|
+
httpClient;
|
|
5
|
+
constructor(httpClient) {
|
|
6
|
+
this.httpClient = httpClient;
|
|
7
|
+
}
|
|
8
|
+
async findAll(dto) {
|
|
9
|
+
const params = {};
|
|
10
|
+
if (dto.limit !== undefined) {
|
|
11
|
+
params.limit = dto.limit.toString();
|
|
12
|
+
}
|
|
13
|
+
if (dto.offset !== undefined) {
|
|
14
|
+
params.offset = dto.offset.toString();
|
|
15
|
+
}
|
|
16
|
+
if (dto.provider !== undefined) {
|
|
17
|
+
params.provider = dto.provider;
|
|
18
|
+
}
|
|
19
|
+
if (dto.restricted !== undefined) {
|
|
20
|
+
params.restricted = dto.restricted.toString();
|
|
21
|
+
}
|
|
22
|
+
const response = await this.httpClient.get(`/v1/organizations/${dto.organizationId}/git-connections`, {
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
25
|
+
},
|
|
26
|
+
params,
|
|
27
|
+
});
|
|
28
|
+
return response.data;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const gitConnectionsService = new GitConnectionsServiceImpl(httpClient);
|
|
32
|
+
export default gitConnectionsService;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils/app-import.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Capawesome Cloud enforces name uniqueness on the lowercased name.
|
|
2
|
+
export const isNameTaken = (name, takenNames) => takenNames.some((takenName) => takenName.toLowerCase() === name.toLowerCase());
|
|
3
|
+
export const generateUniqueName = (name, takenNames) => {
|
|
4
|
+
let uniqueName = name;
|
|
5
5
|
let counter = 2;
|
|
6
|
-
while (
|
|
6
|
+
while (isNameTaken(uniqueName, takenNames)) {
|
|
7
|
+
uniqueName = `${name} (${counter})`;
|
|
7
8
|
counter++;
|
|
8
9
|
}
|
|
9
|
-
return
|
|
10
|
+
return uniqueName;
|
|
10
11
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { generateUniqueName, isNameTaken } from '../utils/app-import.js';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
describe('isNameTaken', () => {
|
|
4
|
+
it('should match names regardless of case', () => {
|
|
5
|
+
expect(isNameTaken('test', ['Test'])).toBe(true);
|
|
6
|
+
expect(isNameTaken('TEST', ['Test'])).toBe(true);
|
|
7
|
+
expect(isNameTaken('other', ['Test'])).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
describe('generateUniqueName', () => {
|
|
11
|
+
it('should keep a name that is not taken', () => {
|
|
12
|
+
expect(generateUniqueName('Production', [])).toBe('Production');
|
|
13
|
+
});
|
|
14
|
+
it('should suffix a name that is taken regardless of case', () => {
|
|
15
|
+
expect(generateUniqueName('Production', ['production'])).toBe('Production (2)');
|
|
16
|
+
});
|
|
17
|
+
it('should skip suffixes that are taken', () => {
|
|
18
|
+
expect(generateUniqueName('Prod', ['prod', 'Prod (2)'])).toBe('Prod (3)');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateUniqueName, } from '../utils/app-import.js';
|
|
1
2
|
import { getMessageFromUnknownError, UserError } from '../utils/error.js';
|
|
2
3
|
import { parseGitRemoteUrl } from '../utils/git.js';
|
|
3
4
|
import fs from 'fs';
|
|
@@ -160,11 +161,21 @@ const mapAppType = (appType) => {
|
|
|
160
161
|
};
|
|
161
162
|
const parseApp = (appFolder, detail, type) => {
|
|
162
163
|
const notes = [];
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
const
|
|
164
|
+
const renames = [];
|
|
165
|
+
// Duplicates are renamed before the id-to-name maps are built so that
|
|
166
|
+
// automations reference the renamed resources.
|
|
167
|
+
const environments = renameDuplicates(parseJsonFileIfExists(path.join(appFolder, 'environments.json'), environmentsSchema) ?? [], 'environment', renames);
|
|
168
|
+
const channels = renameDuplicates(parseJsonFileIfExists(path.join(appFolder, 'live-update-channels.json'), liveUpdateChannelsSchema) ?? [], 'channel', renames);
|
|
169
|
+
const nativeConfigs = renameDuplicates(parseJsonFileIfExists(path.join(appFolder, 'native-configs.json'), nativeConfigsSchema) ?? [], 'configuration', renames);
|
|
170
|
+
const certificates = renameDuplicates(parseCertificates(appFolder, notes), 'certificate', renames);
|
|
171
|
+
const destinations = renameDuplicates(parseDestinations(appFolder), 'destination', renames);
|
|
172
|
+
const automations = renameDuplicates(parseAutomations(appFolder, {
|
|
173
|
+
certificateNamesById: toNameMap(certificates),
|
|
174
|
+
channelNamesById: new Map(channels.map((channel) => [channel.id, channel.name])),
|
|
175
|
+
configurationNamesById: toNameMap(nativeConfigs),
|
|
176
|
+
destinationNamesById: toNameMap(destinations),
|
|
177
|
+
environmentNamesById: toNameMap(environments),
|
|
178
|
+
}, notes), 'automation', renames);
|
|
168
179
|
return {
|
|
169
180
|
sourceId: detail.id,
|
|
170
181
|
sourceName: detail.name,
|
|
@@ -173,17 +184,12 @@ const parseApp = (appFolder, detail, type) => {
|
|
|
173
184
|
type,
|
|
174
185
|
latestBuildNumber: detail.latestBuildNumber ?? undefined,
|
|
175
186
|
notes,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
configurationNamesById: toNameMap(nativeConfigs),
|
|
180
|
-
destinationNamesById: toNameMap(destinations.map((destination) => ({ id: destination.id, name: destination.destination.name }))),
|
|
181
|
-
environmentNamesById: toNameMap(environments),
|
|
182
|
-
}, notes),
|
|
183
|
-
certificates: certificates.map((certificate) => certificate.certificate),
|
|
187
|
+
renames,
|
|
188
|
+
automations,
|
|
189
|
+
certificates: certificates.map(({ id, ...certificate }) => certificate),
|
|
184
190
|
channels: channels.map((channel) => channel.name),
|
|
185
191
|
configurations: parseConfigurations(nativeConfigs, notes),
|
|
186
|
-
destinations: destinations.map((destination) => destination
|
|
192
|
+
destinations: destinations.map(({ id, ...destination }) => destination),
|
|
187
193
|
environments: environments.map((environment) => ({
|
|
188
194
|
name: environment.name,
|
|
189
195
|
variables: Object.entries(environment.vars ?? {}).map(([key, value]) => ({ key, value })),
|
|
@@ -201,10 +207,13 @@ const parseRepository = (appFolder, notes) => {
|
|
|
201
207
|
return parseGitRemoteUrl(repoAssociation.cloneUrl);
|
|
202
208
|
}
|
|
203
209
|
catch {
|
|
204
|
-
notes.push(`The git repository \`${repoAssociation.cloneUrl}\` (provider \`${repoAssociation.gitProvider}\`) is not supported and was not linked. You can link a repository manually in the Capawesome Cloud Console.`);
|
|
210
|
+
notes.push(`The git repository \`${stripCredentials(repoAssociation.cloneUrl)}\` (provider \`${repoAssociation.gitProvider}\`) is not supported and was not linked. You can link a repository manually in the Capawesome Cloud Console.`);
|
|
205
211
|
return null;
|
|
206
212
|
}
|
|
207
213
|
};
|
|
214
|
+
// Removes `user:password@` from URLs as well as from scp-like locations
|
|
215
|
+
// such as `user@host:owner/repo.git`, including malformed ones.
|
|
216
|
+
const stripCredentials = (url) => url.replace(/^([a-z+]+:\/\/)?[^/@]*@/i, '$1');
|
|
208
217
|
const parseConfigurations = (nativeConfigs, notes) => {
|
|
209
218
|
const configurationsWithLiveUpdateSettings = nativeConfigs.filter((config) => config.configs?.ionic);
|
|
210
219
|
if (configurationsWithLiveUpdateSettings.length > 0) {
|
|
@@ -231,15 +240,13 @@ const parseCertificates = (appFolder, notes) => {
|
|
|
231
240
|
}
|
|
232
241
|
certificates.push({
|
|
233
242
|
id: metadata.id,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
provisioningProfilePaths: [],
|
|
242
|
-
},
|
|
243
|
+
name: metadata.name,
|
|
244
|
+
platform: 'android',
|
|
245
|
+
filePath,
|
|
246
|
+
password: metadata.keystorePassword,
|
|
247
|
+
keyAlias: metadata.keyAlias,
|
|
248
|
+
keyPassword: metadata.keyPassword,
|
|
249
|
+
provisioningProfilePaths: [],
|
|
243
250
|
});
|
|
244
251
|
}
|
|
245
252
|
for (const folder of getSubfolders(path.join(appFolder, 'signing-certificates', 'ios'))) {
|
|
@@ -268,13 +275,11 @@ const parseCertificates = (appFolder, notes) => {
|
|
|
268
275
|
}
|
|
269
276
|
certificates.push({
|
|
270
277
|
id: metadata.id,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
provisioningProfilePaths,
|
|
277
|
-
},
|
|
278
|
+
name: metadata.name,
|
|
279
|
+
platform: 'ios',
|
|
280
|
+
filePath,
|
|
281
|
+
password: metadata.p12Password,
|
|
282
|
+
provisioningProfilePaths,
|
|
278
283
|
});
|
|
279
284
|
}
|
|
280
285
|
return certificates;
|
|
@@ -286,33 +291,27 @@ const parseDestinations = (appFolder) => {
|
|
|
286
291
|
const googleServiceAccountKeyPath = path.join(folder, 'json-key.json');
|
|
287
292
|
destinations.push({
|
|
288
293
|
id: metadata.id,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
googleServiceAccountKeyPath: fs.existsSync(googleServiceAccountKeyPath)
|
|
299
|
-
? googleServiceAccountKeyPath
|
|
300
|
-
: undefined,
|
|
301
|
-
},
|
|
294
|
+
name: metadata.name,
|
|
295
|
+
platform: 'android',
|
|
296
|
+
androidPackageName: metadata.packageName ?? undefined,
|
|
297
|
+
androidBuildArtifactType: metadata.artifactType === 'aab' || metadata.artifactType === 'apk' ? metadata.artifactType : undefined,
|
|
298
|
+
androidReleaseStatus: metadata.releaseStatus === 'completed' || metadata.releaseStatus === 'draft'
|
|
299
|
+
? metadata.releaseStatus
|
|
300
|
+
: undefined,
|
|
301
|
+
googlePlayTrack: metadata.track ?? undefined,
|
|
302
|
+
googleServiceAccountKeyPath: fs.existsSync(googleServiceAccountKeyPath) ? googleServiceAccountKeyPath : undefined,
|
|
302
303
|
});
|
|
303
304
|
}
|
|
304
305
|
for (const folder of getSubfolders(path.join(appFolder, 'store-destinations', 'ios'))) {
|
|
305
306
|
const metadata = parseJsonFile(path.join(folder, 'app-store-destination.json'), appStoreDestinationSchema);
|
|
306
307
|
destinations.push({
|
|
307
308
|
id: metadata.id,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
appleAppPassword: metadata.appPassword ?? undefined,
|
|
315
|
-
},
|
|
309
|
+
name: metadata.name,
|
|
310
|
+
platform: 'ios',
|
|
311
|
+
appleId: metadata.appleId ?? undefined,
|
|
312
|
+
appleAppId: metadata.appAppleId ?? undefined,
|
|
313
|
+
appleTeamId: metadata.teamId ?? undefined,
|
|
314
|
+
appleAppPassword: metadata.appPassword ?? undefined,
|
|
316
315
|
});
|
|
317
316
|
}
|
|
318
317
|
return destinations;
|
|
@@ -388,6 +387,17 @@ const resolveName = (namesById, id, automationName, label, notes) => {
|
|
|
388
387
|
const toNameMap = (entities) => {
|
|
389
388
|
return new Map(entities.map((entity) => [entity.id, entity.name]));
|
|
390
389
|
};
|
|
390
|
+
const renameDuplicates = (items, label, renames) => {
|
|
391
|
+
const takenNames = [];
|
|
392
|
+
return items.map((item) => {
|
|
393
|
+
const name = generateUniqueName(item.name, takenNames);
|
|
394
|
+
takenNames.push(name);
|
|
395
|
+
if (name !== item.name) {
|
|
396
|
+
renames.push(`The ${label} \`${item.name}\` was renamed to \`${name}\` because its name is already taken.`);
|
|
397
|
+
}
|
|
398
|
+
return { ...item, name };
|
|
399
|
+
});
|
|
400
|
+
};
|
|
391
401
|
// Guards against path traversal: the file names come from the export's JSON and
|
|
392
402
|
// must never resolve outside the folder they were extracted to.
|
|
393
403
|
const resolvePathWithinFolder = (folder, fileName) => {
|
|
@@ -328,6 +328,66 @@ describe('appflow-export', () => {
|
|
|
328
328
|
expect(app.notes).toContainEqual(expect.stringContaining('`/etc/hosts` is invalid'));
|
|
329
329
|
expect(app.notes).toContainEqual(expect.stringContaining('`../../../../etc/hosts`'));
|
|
330
330
|
});
|
|
331
|
+
it('should rename resources whose names only differ in case', async () => {
|
|
332
|
+
writeAppFiles('Dupes-88888888', {
|
|
333
|
+
'app-detail.json': { id: '88888888', name: 'Dupes', appType: 'capacitor' },
|
|
334
|
+
'environments.json': [
|
|
335
|
+
{ id: 1, name: 'Staging', vars: {}, secrets: null },
|
|
336
|
+
{ id: 2, name: 'staging', vars: {}, secrets: null },
|
|
337
|
+
],
|
|
338
|
+
'native-build-automations.json': [
|
|
339
|
+
{
|
|
340
|
+
name: 'Android Release',
|
|
341
|
+
gitBranch: 'main',
|
|
342
|
+
platform: 'android',
|
|
343
|
+
buildType: 'release',
|
|
344
|
+
environmentId: 2,
|
|
345
|
+
webhook: null,
|
|
346
|
+
automationEnabled: true,
|
|
347
|
+
nativeConfigId: null,
|
|
348
|
+
signingCertificateId: null,
|
|
349
|
+
destinationId: 27892,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
'store-destinations/android/Prod-27890/play-store-destination.json': {
|
|
353
|
+
id: 27890,
|
|
354
|
+
name: 'Prod',
|
|
355
|
+
artifactType: 'aab',
|
|
356
|
+
packageName: 'dev.example.app',
|
|
357
|
+
track: 'internal',
|
|
358
|
+
},
|
|
359
|
+
'store-destinations/android/prod-27892/play-store-destination.json': {
|
|
360
|
+
id: 27892,
|
|
361
|
+
name: 'prod',
|
|
362
|
+
artifactType: 'aab',
|
|
363
|
+
packageName: 'dev.example.app',
|
|
364
|
+
track: 'production',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
368
|
+
const app = apps[0];
|
|
369
|
+
expect(app.environments.map((environment) => environment.name)).toEqual(['Staging', 'staging (2)']);
|
|
370
|
+
expect(app.destinations.map((destination) => destination.name)).toEqual(['Prod', 'prod (2)']);
|
|
371
|
+
expect(app.automations[0]).toMatchObject({ appEnvironmentName: 'staging (2)', appDestinationName: 'prod (2)' });
|
|
372
|
+
expect(app.renames).toEqual([
|
|
373
|
+
'The environment `staging` was renamed to `staging (2)` because its name is already taken.',
|
|
374
|
+
'The destination `prod` was renamed to `prod (2)` because its name is already taken.',
|
|
375
|
+
]);
|
|
376
|
+
});
|
|
377
|
+
it.each([
|
|
378
|
+
['https://user:secret-token@git.example.com/owner/repo.git', 'https://git.example.com/owner/repo.git'],
|
|
379
|
+
['https://user:secret-token@', 'https://'],
|
|
380
|
+
['user:secret-token@git.example.com:owner/repo.git', 'git.example.com:owner/repo.git'],
|
|
381
|
+
])('should not echo credentials of the unsupported repository url %s', async (cloneUrl, sanitizedUrl) => {
|
|
382
|
+
writeAppFiles('Private Repo-99999999', {
|
|
383
|
+
'app-detail.json': { id: '99999999', name: 'Private Repo', appType: 'capacitor' },
|
|
384
|
+
'repo-association.json': { gitProvider: 'gitlab_enterprise', cloneUrl },
|
|
385
|
+
});
|
|
386
|
+
const { apps } = await parseAppflowExport(exportDirectory);
|
|
387
|
+
expect(apps[0].repository).toBeNull();
|
|
388
|
+
expect(apps[0].notes).toContainEqual(expect.stringContaining(`\`${sanitizedUrl}\``));
|
|
389
|
+
expect(JSON.stringify(apps[0].notes)).not.toContain('secret-token');
|
|
390
|
+
});
|
|
331
391
|
it('should skip an app with an invalid app detail file', async () => {
|
|
332
392
|
writeAppFiles('Invalid-66666666', {
|
|
333
393
|
'app-detail.json': 'not json',
|
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)
|