@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 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 { generateUniqueAppName } from '../../utils/app-import.js';
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
- fs.rmSync(tempDirectory, { recursive: true, force: true });
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 = new Set();
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
- for (const app of page) {
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 = generateUniqueAppName(app.sourceName, takenNames);
209
- takenNames.add(app.name);
230
+ app.name = generateUniqueName(app.sourceName, takenNames);
231
+ takenNames.push(app.name);
210
232
  if (app.name !== app.sourceName) {
211
- app.notes.push(`An app with the name \`${app.sourceName}\` already exists in the organization. The app is imported as \`${app.name}\`.`);
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 importApp = async (organizationId, outcome) => {
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 = new Set();
337
+ let existingChannelNames = [];
301
338
  try {
302
339
  const existingChannels = await appChannelsService.findAll({ appId });
303
- existingChannelNames = new Set(existingChannels.map((channel) => channel.name));
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 (existingChannelNames.has(channel)) {
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].notes).toContainEqual(expect.stringContaining('`My App (2)`'));
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 {};
@@ -1,10 +1,11 @@
1
- export const generateUniqueAppName = (name, takenNames) => {
2
- if (!takenNames.has(name)) {
3
- return name;
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 (takenNames.has(`${name} (${counter})`)) {
6
+ while (isNameTaken(uniqueName, takenNames)) {
7
+ uniqueName = `${name} (${counter})`;
7
8
  counter++;
8
9
  }
9
- return `${name} (${counter})`;
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 environments = parseJsonFileIfExists(path.join(appFolder, 'environments.json'), environmentsSchema) ?? [];
164
- const channels = parseJsonFileIfExists(path.join(appFolder, 'live-update-channels.json'), liveUpdateChannelsSchema) ?? [];
165
- const nativeConfigs = parseJsonFileIfExists(path.join(appFolder, 'native-configs.json'), nativeConfigsSchema) ?? [];
166
- const certificates = parseCertificates(appFolder, notes);
167
- const destinations = parseDestinations(appFolder);
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
- automations: parseAutomations(appFolder, {
177
- certificateNamesById: toNameMap(certificates.map((certificate) => ({ id: certificate.id, name: certificate.certificate.name }))),
178
- channelNamesById: new Map(channels.map((channel) => [channel.id, channel.name])),
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.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
- certificate: {
235
- name: metadata.name,
236
- platform: 'android',
237
- filePath,
238
- password: metadata.keystorePassword,
239
- keyAlias: metadata.keyAlias,
240
- keyPassword: metadata.keyPassword,
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
- certificate: {
272
- name: metadata.name,
273
- platform: 'ios',
274
- filePath,
275
- password: metadata.p12Password,
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
- destination: {
290
- name: metadata.name,
291
- platform: 'android',
292
- androidPackageName: metadata.packageName ?? undefined,
293
- androidBuildArtifactType: metadata.artifactType === 'aab' || metadata.artifactType === 'apk' ? metadata.artifactType : undefined,
294
- androidReleaseStatus: metadata.releaseStatus === 'completed' || metadata.releaseStatus === 'draft'
295
- ? metadata.releaseStatus
296
- : undefined,
297
- googlePlayTrack: metadata.track ?? undefined,
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
- destination: {
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,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.19.0",
3
+ "version": "4.20.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {