@capawesome/cli 4.19.0 → 4.20.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 +13 -0
- package/dist/commands/apps/import.js +63 -19
- package/dist/commands/apps/import.test.js +91 -1
- package/dist/services/git-connections.js +29 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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.20.0](https://github.com/capawesome-team/cli/compare/v4.19.0...v4.20.0) (2026-09-22)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **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))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **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))
|
|
16
|
+
* **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))
|
|
17
|
+
|
|
5
18
|
## [4.19.0](https://github.com/capawesome-team/cli/compare/v4.18.4...v4.19.0) (2026-09-22)
|
|
6
19
|
|
|
7
20
|
|
|
@@ -8,7 +8,8 @@ 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';
|
|
@@ -22,6 +23,9 @@ import fs from 'fs';
|
|
|
22
23
|
import os from 'os';
|
|
23
24
|
import path from 'path';
|
|
24
25
|
import { z } from 'zod';
|
|
26
|
+
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' };
|
|
25
29
|
export default defineCommand({
|
|
26
30
|
description: 'Import apps from an Ionic Appflow export.',
|
|
27
31
|
options: defineOptions(z.object({
|
|
@@ -72,6 +76,19 @@ export default defineCommand({
|
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'capawesome-appflow-import-'));
|
|
79
|
+
// The extracted export contains secrets, so it must also be removed when the
|
|
80
|
+
// process exits via `process.exit` (e.g. a cancelled prompt) or a signal,
|
|
81
|
+
// which would skip the `finally` block.
|
|
82
|
+
const removeTempDirectory = () => fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 3 });
|
|
83
|
+
const handleSignal = (signal) => {
|
|
84
|
+
removeTempDirectory();
|
|
85
|
+
// Re-raise the signal to keep the default exit code (128 + signal number).
|
|
86
|
+
process.kill(process.pid, signal);
|
|
87
|
+
};
|
|
88
|
+
process.once('exit', removeTempDirectory);
|
|
89
|
+
for (const signal of SIGNALS) {
|
|
90
|
+
process.once(signal, handleSignal);
|
|
91
|
+
}
|
|
75
92
|
try {
|
|
76
93
|
await zip.unzipToFolder(fs.readFileSync(file), tempDirectory);
|
|
77
94
|
const { apps, skippedApps, warnings } = await parseAppflowExport(tempDirectory);
|
|
@@ -85,6 +102,11 @@ export default defineCommand({
|
|
|
85
102
|
const { resolvedApps, skippedApps: unresolvedApps } = await resolveIonicAppTypes(selectedApps, ionicAppType);
|
|
86
103
|
selectedSkippedApps.push(...unresolvedApps);
|
|
87
104
|
await assignUniqueAppNames(resolvedApps, organizationId);
|
|
105
|
+
const unconnectedGitProviders = await findUnconnectedGitProviders(resolvedApps, organizationId);
|
|
106
|
+
for (const provider of unconnectedGitProviders) {
|
|
107
|
+
const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
|
|
108
|
+
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
|
+
}
|
|
88
110
|
const outcomes = [];
|
|
89
111
|
for (const app of resolvedApps) {
|
|
90
112
|
const outcome = {
|
|
@@ -103,7 +125,7 @@ export default defineCommand({
|
|
|
103
125
|
if (dryRun) {
|
|
104
126
|
continue;
|
|
105
127
|
}
|
|
106
|
-
await importApp(organizationId, outcome);
|
|
128
|
+
await importApp(organizationId, outcome, unconnectedGitProviders);
|
|
107
129
|
}
|
|
108
130
|
const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
|
|
109
131
|
if (json) {
|
|
@@ -113,13 +135,15 @@ export default defineCommand({
|
|
|
113
135
|
printSummary(outcomes, selectedSkippedApps, dryRun === true, file);
|
|
114
136
|
}
|
|
115
137
|
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
138
|
process.exitCode = 1;
|
|
119
139
|
}
|
|
120
140
|
}
|
|
121
141
|
finally {
|
|
122
|
-
|
|
142
|
+
removeTempDirectory();
|
|
143
|
+
process.off('exit', removeTempDirectory);
|
|
144
|
+
for (const signal of SIGNALS) {
|
|
145
|
+
process.off(signal, handleSignal);
|
|
146
|
+
}
|
|
123
147
|
}
|
|
124
148
|
}),
|
|
125
149
|
});
|
|
@@ -191,28 +215,41 @@ const resolveIonicAppTypes = async (apps, ionicAppType) => {
|
|
|
191
215
|
return { resolvedApps, skippedApps };
|
|
192
216
|
};
|
|
193
217
|
const assignUniqueAppNames = async (apps, organizationId) => {
|
|
194
|
-
const takenNames =
|
|
218
|
+
const takenNames = [];
|
|
195
219
|
const limit = 50;
|
|
196
220
|
let offset = 0;
|
|
197
221
|
while (true) {
|
|
198
222
|
const page = await appsService.findAll({ organizationId, limit, offset });
|
|
199
|
-
|
|
200
|
-
takenNames.add(app.name);
|
|
201
|
-
}
|
|
223
|
+
takenNames.push(...page.map((app) => app.name));
|
|
202
224
|
if (page.length < limit) {
|
|
203
225
|
break;
|
|
204
226
|
}
|
|
205
227
|
offset += limit;
|
|
206
228
|
}
|
|
207
229
|
for (const app of apps) {
|
|
208
|
-
app.name =
|
|
209
|
-
takenNames.
|
|
230
|
+
app.name = generateUniqueName(app.sourceName, takenNames);
|
|
231
|
+
takenNames.push(app.name);
|
|
210
232
|
if (app.name !== app.sourceName) {
|
|
211
|
-
app.
|
|
233
|
+
app.renames.push(`The app \`${app.sourceName}\` was renamed to \`${app.name}\` because its name is already taken in the organization.`);
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
};
|
|
215
|
-
const
|
|
237
|
+
const findUnconnectedGitProviders = async (apps, organizationId) => {
|
|
238
|
+
const providers = new Set(apps.flatMap((app) => (app.repository ? [app.repository.provider] : [])));
|
|
239
|
+
const unconnectedProviders = new Set();
|
|
240
|
+
for (const provider of providers) {
|
|
241
|
+
const gitConnections = await gitConnectionsService.findAll({
|
|
242
|
+
organizationId,
|
|
243
|
+
provider: GIT_CONNECTION_PROVIDERS[provider] ?? provider,
|
|
244
|
+
limit: 1,
|
|
245
|
+
});
|
|
246
|
+
if (gitConnections.length === 0) {
|
|
247
|
+
unconnectedProviders.add(provider);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return unconnectedProviders;
|
|
251
|
+
};
|
|
252
|
+
const importApp = async (organizationId, outcome, unconnectedGitProviders) => {
|
|
216
253
|
const { app } = outcome;
|
|
217
254
|
consola.start(`Importing app \`${app.sourceName}\`...`);
|
|
218
255
|
let appId;
|
|
@@ -297,16 +334,16 @@ const importApp = async (organizationId, outcome) => {
|
|
|
297
334
|
outcome.errors.push(`Failed to create environment \`${environment.name}\`: ${getMessageFromUnknownError(error)}`);
|
|
298
335
|
}
|
|
299
336
|
}
|
|
300
|
-
let existingChannelNames =
|
|
337
|
+
let existingChannelNames = [];
|
|
301
338
|
try {
|
|
302
339
|
const existingChannels = await appChannelsService.findAll({ appId });
|
|
303
|
-
existingChannelNames =
|
|
340
|
+
existingChannelNames = existingChannels.map((channel) => channel.name);
|
|
304
341
|
}
|
|
305
342
|
catch {
|
|
306
343
|
// If the lookup fails, channel creation errors are reported individually below.
|
|
307
344
|
}
|
|
308
345
|
for (const channel of app.channels) {
|
|
309
|
-
if (
|
|
346
|
+
if (isNameTaken(channel, existingChannelNames)) {
|
|
310
347
|
outcome.created.channels++;
|
|
311
348
|
continue;
|
|
312
349
|
}
|
|
@@ -389,7 +426,10 @@ const importApp = async (organizationId, outcome) => {
|
|
|
389
426
|
outcome.errors.push(`Failed to create automation \`${automation.name}\`: ${getMessageFromUnknownError(error)}`);
|
|
390
427
|
}
|
|
391
428
|
}
|
|
392
|
-
if (app.repository) {
|
|
429
|
+
if (app.repository && unconnectedGitProviders.has(app.repository.provider)) {
|
|
430
|
+
app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` was not linked because the organization has no \`${app.repository.provider}\` git connection.`);
|
|
431
|
+
}
|
|
432
|
+
else if (app.repository) {
|
|
393
433
|
try {
|
|
394
434
|
await appsService.linkRepository({
|
|
395
435
|
appId,
|
|
@@ -422,17 +462,20 @@ const printSummary = (outcomes, skippedApps, dryRun, file) => {
|
|
|
422
462
|
Errors: outcome.errors.length,
|
|
423
463
|
})));
|
|
424
464
|
for (const outcome of outcomes) {
|
|
425
|
-
if (outcome.app.notes.length === 0 && outcome.errors.length === 0) {
|
|
465
|
+
if (outcome.app.notes.length === 0 && outcome.app.renames.length === 0 && outcome.errors.length === 0) {
|
|
426
466
|
continue;
|
|
427
467
|
}
|
|
428
468
|
consola.log(`\n${outcome.app.sourceName}:`);
|
|
429
|
-
for (const note of outcome.app.notes) {
|
|
469
|
+
for (const note of [...outcome.app.notes, ...outcome.app.renames]) {
|
|
430
470
|
consola.warn(note);
|
|
431
471
|
}
|
|
432
472
|
for (const error of outcome.errors) {
|
|
433
473
|
consola.error(error);
|
|
434
474
|
}
|
|
435
475
|
}
|
|
476
|
+
if (outcomes.some((outcome) => outcome.app.renames.length > 0)) {
|
|
477
|
+
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.');
|
|
478
|
+
}
|
|
436
479
|
for (const outcome of outcomes) {
|
|
437
480
|
if (outcome.id) {
|
|
438
481
|
consola.info(`App URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}`);
|
|
@@ -468,6 +511,7 @@ const printJsonSummary = (outcomes, skippedApps, warnings, dryRun) => {
|
|
|
468
511
|
webUrl: outcome.id ? `${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}` : null,
|
|
469
512
|
created: outcome.created,
|
|
470
513
|
notes: outcome.app.notes,
|
|
514
|
+
renames: outcome.app.renames,
|
|
471
515
|
errors: outcome.errors,
|
|
472
516
|
})),
|
|
473
517
|
skippedApps: skippedApps.map((app) => ({
|
|
@@ -152,6 +152,9 @@ 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', limit: 1 })
|
|
157
|
+
.reply(200, [{ id: 'git-connection-1', provider: 'github' }])
|
|
155
158
|
.put(`/v1/apps/${appId}/repository`, {
|
|
156
159
|
ownerSlug: 'robingenz',
|
|
157
160
|
provider: 'github',
|
|
@@ -193,7 +196,27 @@ describe('apps-import', () => {
|
|
|
193
196
|
expect(scope.isDone()).toBe(true);
|
|
194
197
|
const output = getJsonOutput();
|
|
195
198
|
expect(output.apps[0].name).toBe('My App (2)');
|
|
196
|
-
expect(output.apps[0].
|
|
199
|
+
expect(output.apps[0].renames).toContainEqual(expect.stringContaining('`My App (2)`'));
|
|
200
|
+
});
|
|
201
|
+
it('should rename the app if the name is already taken in a different case', async () => {
|
|
202
|
+
await writeExportFile([
|
|
203
|
+
{
|
|
204
|
+
folder: 'test-6668c18c',
|
|
205
|
+
files: { 'app-detail.json': { id: '6668c18c', name: 'test', appType: 'capacitor' } },
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
209
|
+
.get('/v1/apps')
|
|
210
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
211
|
+
.reply(200, [{ id: 'app-1', name: 'Test', type: 'capacitor' }])
|
|
212
|
+
.post('/v1/apps', { name: 'test (2)', type: 'capacitor' })
|
|
213
|
+
.query({ organizationId })
|
|
214
|
+
.reply(201, { id: 'app-789', name: 'test (2)', type: 'capacitor' })
|
|
215
|
+
.get('/v1/apps/app-789/channels')
|
|
216
|
+
.reply(200, []);
|
|
217
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
218
|
+
expect(scope.isDone()).toBe(true);
|
|
219
|
+
expect(getJsonOutput().apps[0].name).toBe('test (2)');
|
|
197
220
|
});
|
|
198
221
|
it('should continue with the remaining resources if one fails', async () => {
|
|
199
222
|
await writeExportFile([
|
|
@@ -368,6 +391,73 @@ describe('apps-import', () => {
|
|
|
368
391
|
{ sourceId: '22222222', sourceName: 'RN App', reason: expect.stringContaining('react_native') },
|
|
369
392
|
]);
|
|
370
393
|
});
|
|
394
|
+
it('should link an Azure DevOps repository using the matching git connection', async () => {
|
|
395
|
+
await writeExportFile([
|
|
396
|
+
{
|
|
397
|
+
folder: 'My App-6668c18c',
|
|
398
|
+
files: {
|
|
399
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
400
|
+
'repo-association.json': {
|
|
401
|
+
gitProvider: 'azure_devops',
|
|
402
|
+
cloneUrl: 'https://dev.azure.com/my-org/my-project/_git/my-repo',
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
]);
|
|
407
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
408
|
+
.get('/v1/apps')
|
|
409
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
410
|
+
.reply(200, [])
|
|
411
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
412
|
+
.query({ provider: 'azure_devops', limit: 1 })
|
|
413
|
+
.reply(200, [{ id: 'git-connection-1', provider: 'azure_devops' }])
|
|
414
|
+
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
415
|
+
.query({ organizationId })
|
|
416
|
+
.reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
|
|
417
|
+
.get('/v1/apps/app-1/channels')
|
|
418
|
+
.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
|
+
})
|
|
425
|
+
.reply(200, { id: 'app-1' });
|
|
426
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
427
|
+
expect(scope.isDone()).toBe(true);
|
|
428
|
+
expect(getJsonOutput().apps[0].notes).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
it('should not link repositories if the git provider is not connected', async () => {
|
|
431
|
+
await writeExportFile([
|
|
432
|
+
{
|
|
433
|
+
folder: 'My App-6668c18c',
|
|
434
|
+
files: {
|
|
435
|
+
'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
|
|
436
|
+
'repo-association.json': {
|
|
437
|
+
gitProvider: 'github',
|
|
438
|
+
cloneUrl: 'https://github.com/robingenz/appflow-export-test.git',
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
]);
|
|
443
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
444
|
+
.get('/v1/apps')
|
|
445
|
+
.query({ organizationId, limit: 50, offset: 0 })
|
|
446
|
+
.reply(200, [])
|
|
447
|
+
.get(`/v1/organizations/${organizationId}/git-connections`)
|
|
448
|
+
.query({ provider: 'github', limit: 1 })
|
|
449
|
+
.reply(200, [])
|
|
450
|
+
.post('/v1/apps', { name: 'My App', type: 'capacitor' })
|
|
451
|
+
.query({ organizationId })
|
|
452
|
+
.reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
|
|
453
|
+
.get('/v1/apps/app-1/channels')
|
|
454
|
+
.reply(200, []);
|
|
455
|
+
await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
|
|
456
|
+
expect(scope.isDone()).toBe(true);
|
|
457
|
+
expect(vi.mocked(consola).warn).toHaveBeenCalledWith(expect.stringContaining('no `github` git connection'));
|
|
458
|
+
const output = getJsonOutput();
|
|
459
|
+
expect(output.apps[0].notes).toContainEqual(expect.stringContaining('was not linked'));
|
|
460
|
+
});
|
|
371
461
|
it('should error in non-interactive environment if no file is provided', async () => {
|
|
372
462
|
await expect(importCommand.action({ organizationId }, undefined)).rejects.toThrow('Process exited with code 1');
|
|
373
463
|
expect(vi.mocked(consola).error).toHaveBeenCalledWith('You must provide the export file path when running in non-interactive environment.');
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
const response = await this.httpClient.get(`/v1/organizations/${dto.organizationId}/git-connections`, {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
22
|
+
},
|
|
23
|
+
params,
|
|
24
|
+
});
|
|
25
|
+
return response.data;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const gitConnectionsService = new GitConnectionsServiceImpl(httpClient);
|
|
29
|
+
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',
|