@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 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 { 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';
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
- fs.rmSync(tempDirectory, { recursive: true, force: true });
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 = new Set();
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
- for (const app of page) {
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 = generateUniqueAppName(app.sourceName, takenNames);
209
- takenNames.add(app.name);
232
+ app.name = generateUniqueName(app.sourceName, takenNames);
233
+ takenNames.push(app.name);
210
234
  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}\`.`);
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 importApp = async (organizationId, outcome) => {
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 = new Set();
340
+ let existingChannelNames = [];
301
341
  try {
302
342
  const existingChannels = await appChannelsService.findAll({ appId });
303
- existingChannelNames = new Set(existingChannels.map((channel) => channel.name));
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 (existingChannelNames.has(channel)) {
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
- try {
394
- await appsService.linkRepository({
395
- appId,
396
- ownerSlug: app.repository.ownerSlug,
397
- provider: app.repository.provider,
398
- repositorySlug: app.repository.repositorySlug,
399
- projectSlug: app.repository.projectSlug,
400
- });
401
- consola.success(`Linked repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\`.`);
402
- }
403
- catch (error) {
404
- app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` could not be linked: ${getMessageFromUnknownError(error)} Make sure the git provider is connected in the Capawesome Cloud Console and link the repository manually.`);
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
- ownerSlug: 'robingenz',
157
- provider: 'github',
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].notes).toContainEqual(expect.stringContaining('`My App (2)`'));
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
- ownerSlug: gitRemoteInfo.ownerSlug,
28
- provider: gitRemoteInfo.provider,
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: 'test-token' });
32
- mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
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
- it('should link repository with provided app ID', async () => {
43
- const appId = 'app-123';
44
- const testToken = 'test-token';
45
- const options = { appId };
46
- const scope = nock(DEFAULT_API_BASE_URL)
47
- .put(`/v1/apps/${appId}/repository`, {
48
- ownerSlug: 'capawesome-team',
49
- provider: 'github',
50
- repositorySlug: 'cli',
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(options, undefined);
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
- const appId = 'app-123';
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 = nock(DEFAULT_API_BASE_URL)
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(options, undefined);
74
+ await linkCommand.action({}, undefined);
74
75
  expect(scope.isDone()).toBe(true);
75
76
  expect(mockPromptOrganizationSelection).toHaveBeenCalled();
76
- expect(mockPromptAppSelection).toHaveBeenCalledWith(orgId);
77
- expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.');
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 appId = 'app-123';
81
- const testToken = 'test-token';
82
- const options = { appId };
83
- const scope = nock(DEFAULT_API_BASE_URL)
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 {};
@@ -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/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': 'azure',
8
- 'ssh.dev.azure.com': 'azure',
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: 'azure',
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: 'azure',
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: 'azure',
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('/');
@@ -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: 'azure',
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: 'azure',
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: 'azure',
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.19.0",
3
+ "version": "4.21.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {