@capawesome/cli 4.20.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,18 @@
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
+
5
17
  ## [4.20.0](https://github.com/capawesome-team/cli/compare/v4.19.0...v4.20.0) (2026-09-22)
6
18
 
7
19
 
@@ -15,6 +15,7 @@ import { withAuth } from '../../utils/auth.js';
15
15
  import { isInteractive } from '../../utils/environment.js';
16
16
  import { getMessageFromUnknownError, UserError } from '../../utils/error.js';
17
17
  import { isReadable } from '../../utils/file.js';
18
+ import { getGitRepositoryPath } from '../../utils/git.js';
18
19
  import { prompt, promptOrganizationSelection } from '../../utils/prompt.js';
19
20
  import zip from '../../utils/zip.js';
20
21
  import { defineCommand, defineOptions } from 'zodline';
@@ -24,8 +25,6 @@ import os from 'os';
24
25
  import path from 'path';
25
26
  import { z } from 'zod';
26
27
  const SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
27
- // Maps the providers of `parseGitRemoteUrl` to the providers of git connections.
28
- const GIT_CONNECTION_PROVIDERS = { azure: 'azure_devops' };
29
28
  export default defineCommand({
30
29
  description: 'Import apps from an Ionic Appflow export.',
31
30
  options: defineOptions(z.object({
@@ -102,8 +101,11 @@ export default defineCommand({
102
101
  const { resolvedApps, skippedApps: unresolvedApps } = await resolveIonicAppTypes(selectedApps, ionicAppType);
103
102
  selectedSkippedApps.push(...unresolvedApps);
104
103
  await assignUniqueAppNames(resolvedApps, organizationId);
105
- const unconnectedGitProviders = await findUnconnectedGitProviders(resolvedApps, organizationId);
106
- for (const provider of unconnectedGitProviders) {
104
+ const gitConnectionIds = await findGitConnectionIds(resolvedApps, organizationId);
105
+ for (const provider of getGitProviders(resolvedApps)) {
106
+ if (gitConnectionIds.has(provider)) {
107
+ continue;
108
+ }
107
109
  const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
108
110
  consola.warn(`The organization has no \`${provider}\` git connection, so the repositories of ${appCount} app(s) will not be linked. Connect the git provider at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${organizationId}/git before running the import to link them automatically, or link the repositories manually in the Capawesome Cloud Console afterwards.`);
109
111
  }
@@ -125,7 +127,7 @@ export default defineCommand({
125
127
  if (dryRun) {
126
128
  continue;
127
129
  }
128
- await importApp(organizationId, outcome, unconnectedGitProviders);
130
+ await importApp(organizationId, outcome, gitConnectionIds);
129
131
  }
130
132
  const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
131
133
  if (json) {
@@ -234,22 +236,23 @@ const assignUniqueAppNames = async (apps, organizationId) => {
234
236
  }
235
237
  }
236
238
  };
237
- const 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({
239
+ const getGitProviders = (apps) => new Set(apps.flatMap((app) => (app.repository ? [app.repository.provider] : [])));
240
+ const findGitConnectionIds = async (apps, organizationId) => {
241
+ const gitConnectionIds = new Map();
242
+ for (const provider of getGitProviders(apps)) {
243
+ const [gitConnection] = await gitConnectionsService.findAll({
242
244
  organizationId,
243
- provider: GIT_CONNECTION_PROVIDERS[provider] ?? provider,
245
+ provider,
246
+ restricted: false,
244
247
  limit: 1,
245
248
  });
246
- if (gitConnections.length === 0) {
247
- unconnectedProviders.add(provider);
249
+ if (gitConnection) {
250
+ gitConnectionIds.set(provider, gitConnection.id);
248
251
  }
249
252
  }
250
- return unconnectedProviders;
253
+ return gitConnectionIds;
251
254
  };
252
- const importApp = async (organizationId, outcome, unconnectedGitProviders) => {
255
+ const importApp = async (organizationId, outcome, gitConnectionIds) => {
253
256
  const { app } = outcome;
254
257
  consola.start(`Importing app \`${app.sourceName}\`...`);
255
258
  let appId;
@@ -426,22 +429,20 @@ const importApp = async (organizationId, outcome, unconnectedGitProviders) => {
426
429
  outcome.errors.push(`Failed to create automation \`${automation.name}\`: ${getMessageFromUnknownError(error)}`);
427
430
  }
428
431
  }
429
- if (app.repository && 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) {
433
- try {
434
- await appsService.linkRepository({
435
- appId,
436
- ownerSlug: app.repository.ownerSlug,
437
- provider: app.repository.provider,
438
- repositorySlug: app.repository.repositorySlug,
439
- projectSlug: app.repository.projectSlug,
440
- });
441
- consola.success(`Linked repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\`.`);
442
- }
443
- catch (error) {
444
- app.notes.push(`The repository \`${app.repository.ownerSlug}/${app.repository.repositorySlug}\` could not be linked: ${getMessageFromUnknownError(error)} Make sure the git provider is connected in the Capawesome Cloud Console and link the repository manually.`);
432
+ if (app.repository) {
433
+ const gitConnectionId = gitConnectionIds.get(app.repository.provider);
434
+ const repositoryPath = getGitRepositoryPath(app.repository);
435
+ if (!gitConnectionId) {
436
+ app.notes.push(`The repository \`${repositoryPath}\` was not linked because the organization has no \`${app.repository.provider}\` git connection.`);
437
+ }
438
+ else {
439
+ try {
440
+ await appsService.linkRepository({ appId, gitConnectionId, path: repositoryPath });
441
+ consola.success(`Linked repository \`${repositoryPath}\`.`);
442
+ }
443
+ catch (error) {
444
+ app.notes.push(`The repository \`${repositoryPath}\` could not be linked: ${getMessageFromUnknownError(error)} Link the repository manually in the Capawesome Cloud Console.`);
445
+ }
445
446
  }
446
447
  }
447
448
  };
@@ -153,12 +153,11 @@ describe('apps-import', () => {
153
153
  })
154
154
  .reply(201, { id: 'automation-1', name: 'Android Release' })
155
155
  .get(`/v1/organizations/${organizationId}/git-connections`)
156
- .query({ provider: 'github', limit: 1 })
156
+ .query({ provider: 'github', restricted: 'false', limit: 1 })
157
157
  .reply(200, [{ id: 'git-connection-1', provider: 'github' }])
158
158
  .put(`/v1/apps/${appId}/repository`, {
159
- ownerSlug: 'robingenz',
160
- provider: 'github',
161
- repositorySlug: 'appflow-export-test',
159
+ gitConnectionId: 'git-connection-1',
160
+ path: 'robingenz/appflow-export-test',
162
161
  })
163
162
  .reply(200, { id: appId });
164
163
  await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
@@ -409,19 +408,14 @@ describe('apps-import', () => {
409
408
  .query({ organizationId, limit: 50, offset: 0 })
410
409
  .reply(200, [])
411
410
  .get(`/v1/organizations/${organizationId}/git-connections`)
412
- .query({ provider: 'azure_devops', limit: 1 })
411
+ .query({ provider: 'azure_devops', restricted: 'false', limit: 1 })
413
412
  .reply(200, [{ id: 'git-connection-1', provider: 'azure_devops' }])
414
413
  .post('/v1/apps', { name: 'My App', type: 'capacitor' })
415
414
  .query({ organizationId })
416
415
  .reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
417
416
  .get('/v1/apps/app-1/channels')
418
417
  .reply(200, [])
419
- .put('/v1/apps/app-1/repository', {
420
- ownerSlug: 'my-org',
421
- provider: 'azure',
422
- repositorySlug: 'my-repo',
423
- projectSlug: 'my-project',
424
- })
418
+ .put('/v1/apps/app-1/repository', { gitConnectionId: 'git-connection-1', path: 'my-org/my-project/my-repo' })
425
419
  .reply(200, { id: 'app-1' });
426
420
  await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
427
421
  expect(scope.isDone()).toBe(true);
@@ -445,7 +439,7 @@ describe('apps-import', () => {
445
439
  .query({ organizationId, limit: 50, offset: 0 })
446
440
  .reply(200, [])
447
441
  .get(`/v1/organizations/${organizationId}/git-connections`)
448
- .query({ provider: 'github', limit: 1 })
442
+ .query({ provider: 'github', restricted: 'false', limit: 1 })
449
443
  .reply(200, [])
450
444
  .post('/v1/apps', { name: 'My App', type: 'capacitor' })
451
445
  .query({ organizationId })
@@ -1,7 +1,9 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
1
2
  import appsService from '../../services/apps.js';
3
+ import gitConnectionsService from '../../services/git-connections.js';
2
4
  import { withAuth } from '../../utils/auth.js';
3
5
  import { isInteractive } from '../../utils/environment.js';
4
- import { getGitRemoteInfo } from '../../utils/git.js';
6
+ import { getGitRemoteInfo, getGitRepositoryPath } from '../../utils/git.js';
5
7
  import { promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
6
8
  import consola from 'consola';
7
9
  import { z } from 'zod';
@@ -10,9 +12,13 @@ export default defineCommand({
10
12
  description: 'Connect a git repository to an app.',
11
13
  options: defineOptions(z.object({
12
14
  appId: z.string().optional().describe('ID of the app.'),
15
+ gitConnectionId: z
16
+ .string()
17
+ .optional()
18
+ .describe('ID of the git connection to use. Defaults to the first git connection of the organization for the git provider of the repository.'),
13
19
  })),
14
20
  action: withAuth(async (options, args) => {
15
- let { appId } = options;
21
+ let { appId, gitConnectionId } = options;
16
22
  if (!appId) {
17
23
  if (!isInteractive()) {
18
24
  consola.error('You must provide the app ID when running in non-interactive environment.');
@@ -22,12 +28,24 @@ export default defineCommand({
22
28
  appId = await promptAppSelection(organizationId);
23
29
  }
24
30
  const gitRemoteInfo = getGitRemoteInfo();
31
+ if (!gitConnectionId) {
32
+ const app = await appsService.findOne({ appId });
33
+ const [gitConnection] = await gitConnectionsService.findAll({
34
+ organizationId: app.organizationId,
35
+ provider: gitRemoteInfo.provider,
36
+ restricted: false,
37
+ limit: 1,
38
+ });
39
+ if (!gitConnection) {
40
+ consola.error(`No \`${gitRemoteInfo.provider}\` git connection found in the organization. Connect the git provider first at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${app.organizationId}/git.`);
41
+ process.exit(1);
42
+ }
43
+ gitConnectionId = gitConnection.id;
44
+ }
25
45
  await appsService.linkRepository({
26
46
  appId,
27
- 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
  });
@@ -16,6 +16,9 @@ class GitConnectionsServiceImpl {
16
16
  if (dto.provider !== undefined) {
17
17
  params.provider = dto.provider;
18
18
  }
19
+ if (dto.restricted !== undefined) {
20
+ params.restricted = dto.restricted.toString();
21
+ }
19
22
  const response = await this.httpClient.get(`/v1/organizations/${dto.organizationId}/git-connections`, {
20
23
  headers: {
21
24
  Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
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.20.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": {