@redocly/cli 1.7.0 → 1.8.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/__tests__/commands/build-docs.test.js +3 -3
  3. package/lib/__tests__/commands/bundle.test.js +5 -5
  4. package/lib/__tests__/commands/join.test.js +11 -11
  5. package/lib/__tests__/commands/lint.test.js +14 -14
  6. package/lib/__tests__/commands/push-region.test.js +1 -1
  7. package/lib/__tests__/commands/push.test.js +11 -11
  8. package/lib/__tests__/fetch-with-timeout.test.js +4 -13
  9. package/lib/__tests__/spinner.test.js +43 -0
  10. package/lib/__tests__/utils.test.js +36 -36
  11. package/lib/__tests__/wrapper.test.js +8 -8
  12. package/lib/cms/api/__tests__/api-keys.test.d.ts +1 -0
  13. package/lib/cms/api/__tests__/api-keys.test.js +26 -0
  14. package/lib/cms/api/__tests__/api.client.test.d.ts +1 -0
  15. package/lib/cms/api/__tests__/api.client.test.js +217 -0
  16. package/lib/cms/api/__tests__/domains.test.d.ts +1 -0
  17. package/lib/cms/api/__tests__/domains.test.js +13 -0
  18. package/lib/cms/api/api-client.d.ts +50 -0
  19. package/lib/cms/api/api-client.js +148 -0
  20. package/lib/cms/api/api-keys.d.ts +1 -0
  21. package/lib/cms/api/api-keys.js +24 -0
  22. package/lib/cms/api/domains.d.ts +1 -0
  23. package/lib/cms/api/domains.js +12 -0
  24. package/lib/cms/api/index.d.ts +3 -0
  25. package/lib/cms/api/index.js +19 -0
  26. package/lib/cms/api/types.d.ts +91 -0
  27. package/lib/cms/api/types.js +2 -0
  28. package/lib/cms/commands/__tests__/push-status.test.d.ts +1 -0
  29. package/lib/cms/commands/__tests__/push-status.test.js +164 -0
  30. package/lib/cms/commands/__tests__/push.test.d.ts +1 -0
  31. package/lib/cms/commands/__tests__/push.test.js +226 -0
  32. package/lib/cms/commands/push-status.d.ts +12 -0
  33. package/lib/cms/commands/push-status.js +150 -0
  34. package/lib/cms/commands/push.d.ts +23 -0
  35. package/lib/cms/commands/push.js +142 -0
  36. package/lib/cms/utils.d.ts +2 -0
  37. package/lib/cms/utils.js +6 -0
  38. package/lib/commands/build-docs/index.js +4 -4
  39. package/lib/commands/build-docs/utils.js +2 -2
  40. package/lib/commands/bundle.js +13 -13
  41. package/lib/commands/join.js +25 -25
  42. package/lib/commands/lint.js +10 -10
  43. package/lib/commands/login.js +2 -2
  44. package/lib/commands/preview-docs/index.js +4 -4
  45. package/lib/commands/preview-docs/preview-server/preview-server.js +2 -2
  46. package/lib/commands/preview-project/types.d.ts +1 -1
  47. package/lib/commands/push.d.ts +5 -0
  48. package/lib/commands/push.js +25 -17
  49. package/lib/commands/split/__tests__/index.test.js +2 -2
  50. package/lib/commands/split/index.js +17 -17
  51. package/lib/commands/stats.js +4 -4
  52. package/lib/index.d.ts +1 -1
  53. package/lib/index.js +130 -17
  54. package/lib/types.d.ts +8 -1
  55. package/lib/{__mocks__/utils.js → utils/__mocks__/miscellaneous.js} +1 -1
  56. package/lib/utils/assert-node-version.d.ts +1 -0
  57. package/lib/{fetch-with-timeout.js → utils/fetch-with-timeout.js} +2 -7
  58. package/lib/{utils.d.ts → utils/miscellaneous.d.ts} +1 -1
  59. package/lib/{utils.js → utils/miscellaneous.js} +2 -2
  60. package/lib/utils/spinner.d.ts +10 -0
  61. package/lib/utils/spinner.js +42 -0
  62. package/lib/{update-version-notifier.js → utils/update-version-notifier.js} +4 -4
  63. package/lib/wrapper.js +5 -5
  64. package/package.json +5 -3
  65. package/src/__tests__/commands/build-docs.test.ts +2 -2
  66. package/src/__tests__/commands/bundle.test.ts +2 -2
  67. package/src/__tests__/commands/join.test.ts +2 -2
  68. package/src/__tests__/commands/lint.test.ts +3 -3
  69. package/src/__tests__/commands/push-region.test.ts +1 -1
  70. package/src/__tests__/commands/push.test.ts +2 -2
  71. package/src/__tests__/fetch-with-timeout.test.ts +4 -16
  72. package/src/__tests__/spinner.test.ts +51 -0
  73. package/src/__tests__/utils.test.ts +2 -5
  74. package/src/__tests__/wrapper.test.ts +2 -2
  75. package/src/cms/api/__tests__/api-keys.test.ts +37 -0
  76. package/src/cms/api/__tests__/api.client.test.ts +275 -0
  77. package/src/cms/api/__tests__/domains.test.ts +15 -0
  78. package/src/cms/api/api-client.ts +199 -0
  79. package/src/cms/api/api-keys.ts +26 -0
  80. package/src/cms/api/domains.ts +11 -0
  81. package/src/cms/api/index.ts +3 -0
  82. package/src/cms/api/types.ts +101 -0
  83. package/src/cms/commands/__tests__/push-status.test.ts +212 -0
  84. package/src/cms/commands/__tests__/push.test.ts +293 -0
  85. package/src/cms/commands/push-status.ts +203 -0
  86. package/src/cms/commands/push.ts +215 -0
  87. package/src/cms/utils.ts +1 -0
  88. package/src/commands/build-docs/index.ts +1 -1
  89. package/src/commands/build-docs/utils.ts +1 -1
  90. package/src/commands/bundle.ts +2 -2
  91. package/src/commands/join.ts +2 -2
  92. package/src/commands/lint.ts +1 -1
  93. package/src/commands/login.ts +1 -1
  94. package/src/commands/preview-docs/index.ts +5 -1
  95. package/src/commands/preview-docs/preview-server/preview-server.ts +1 -1
  96. package/src/commands/preview-project/types.ts +1 -1
  97. package/src/commands/push.ts +15 -1
  98. package/src/commands/split/__tests__/index.test.ts +3 -4
  99. package/src/commands/split/index.ts +2 -2
  100. package/src/commands/stats.ts +2 -2
  101. package/src/index.ts +138 -20
  102. package/src/types.ts +8 -0
  103. package/src/{__mocks__/utils.ts → utils/__mocks__/miscellaneous.ts} +1 -1
  104. package/src/{fetch-with-timeout.ts → utils/fetch-with-timeout.ts} +1 -6
  105. package/src/{utils.ts → utils/miscellaneous.ts} +2 -2
  106. package/src/utils/spinner.ts +50 -0
  107. package/src/{update-version-notifier.ts → utils/update-version-notifier.ts} +2 -2
  108. package/src/wrapper.ts +7 -2
  109. package/tsconfig.tsbuildinfo +1 -1
  110. /package/lib/{assert-node-version.d.ts → __tests__/spinner.test.d.ts} +0 -0
  111. /package/lib/{__mocks__/utils.d.ts → utils/__mocks__/miscellaneous.d.ts} +0 -0
  112. /package/lib/{assert-node-version.js → utils/assert-node-version.js} +0 -0
  113. /package/lib/{fetch-with-timeout.d.ts → utils/fetch-with-timeout.d.ts} +0 -0
  114. /package/lib/{js-utils.d.ts → utils/js-utils.d.ts} +0 -0
  115. /package/lib/{js-utils.js → utils/js-utils.js} +0 -0
  116. /package/lib/{update-version-notifier.d.ts → utils/update-version-notifier.d.ts} +0 -0
  117. /package/src/{assert-node-version.ts → utils/assert-node-version.ts} +0 -0
  118. /package/src/{js-utils.ts → utils/js-utils.ts} +0 -0
@@ -12,7 +12,7 @@ jest.mock('node-fetch', () => ({
12
12
  }));
13
13
  jest.mock('@redocly/openapi-core');
14
14
  jest.mock('../../commands/login');
15
- jest.mock('../../utils');
15
+ jest.mock('../../utils/miscellaneous');
16
16
 
17
17
  (getMergedConfig as jest.Mock).mockImplementation((config) => config);
18
18
 
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import { Config, getMergedConfig } from '@redocly/openapi-core';
3
- import { exitWithError } from '../../utils';
3
+ import { exitWithError } from '../../utils/miscellaneous';
4
4
  import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push';
5
5
  import { ConfigFixture } from '../fixtures/config';
6
6
  import { yellow } from 'colorette';
@@ -13,7 +13,7 @@ jest.mock('node-fetch', () => ({
13
13
  })),
14
14
  }));
15
15
  jest.mock('@redocly/openapi-core');
16
- jest.mock('../../utils');
16
+ jest.mock('../../utils/miscellaneous');
17
17
 
18
18
  (getMergedConfig as jest.Mock).mockImplementation((config) => config);
19
19
 
@@ -1,4 +1,5 @@
1
- import fetchWithTimeout from '../fetch-with-timeout';
1
+ import AbortController from 'abort-controller';
2
+ import fetchWithTimeout from '../utils/fetch-with-timeout';
2
3
  import nodeFetch from 'node-fetch';
3
4
 
4
5
  jest.mock('node-fetch');
@@ -8,20 +9,7 @@ describe('fetchWithTimeout', () => {
8
9
  jest.clearAllMocks();
9
10
  });
10
11
 
11
- it('should use bare node-fetch if AbortController is not available', async () => {
12
- // @ts-ignore
13
- global.AbortController = undefined;
14
- // @ts-ignore
15
- global.setTimeout = jest.fn();
16
- await fetchWithTimeout('url', { method: 'GET' });
17
-
18
- expect(nodeFetch).toHaveBeenCalledWith('url', { method: 'GET' });
19
-
20
- expect(global.setTimeout).toHaveBeenCalledTimes(0);
21
- });
22
-
23
- it('should call node-fetch with signal if AbortController is available', async () => {
24
- global.AbortController = jest.fn().mockImplementation(() => ({ signal: 'something' }));
12
+ it('should call node-fetch with signal', async () => {
25
13
  // @ts-ignore
26
14
  global.setTimeout = jest.fn();
27
15
 
@@ -29,7 +17,7 @@ describe('fetchWithTimeout', () => {
29
17
  await fetchWithTimeout('url');
30
18
 
31
19
  expect(global.setTimeout).toHaveBeenCalledTimes(1);
32
- expect(nodeFetch).toHaveBeenCalledWith('url', { signal: 'something' });
20
+ expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal });
33
21
  expect(global.clearTimeout).toHaveBeenCalledTimes(1);
34
22
  });
35
23
  });
@@ -0,0 +1,51 @@
1
+ import { Spinner } from '../utils/spinner';
2
+ import * as process from 'process';
3
+
4
+ jest.useFakeTimers();
5
+
6
+ describe('Spinner', () => {
7
+ const IS_TTY = process.stdout.isTTY;
8
+
9
+ let writeMock: jest.SpyInstance;
10
+ let spinner: Spinner;
11
+
12
+ beforeEach(() => {
13
+ process.stdout.isTTY = true;
14
+ writeMock = jest.spyOn(process.stdout, 'write').mockImplementation(jest.fn());
15
+ spinner = new Spinner();
16
+ });
17
+
18
+ afterEach(() => {
19
+ writeMock.mockRestore();
20
+ jest.clearAllTimers();
21
+ });
22
+
23
+ afterAll(() => {
24
+ process.stdout.isTTY = IS_TTY;
25
+ });
26
+
27
+ it('starts the spinner', () => {
28
+ spinner.start('Loading');
29
+ jest.advanceTimersByTime(100);
30
+ expect(writeMock).toHaveBeenCalledWith('\r⠋ Loading');
31
+ });
32
+
33
+ it('stops the spinner', () => {
34
+ spinner.start('Loading');
35
+ spinner.stop();
36
+ expect(writeMock).toHaveBeenCalledWith('\r');
37
+ });
38
+
39
+ it('should write 3 frames', () => {
40
+ spinner.start('Loading');
41
+ jest.advanceTimersByTime(300);
42
+ expect(writeMock).toHaveBeenCalledTimes(3);
43
+ });
44
+
45
+ it('should call write 1 times if CI set to true', () => {
46
+ process.stdout.isTTY = false;
47
+ spinner.start('Loading');
48
+ jest.advanceTimersByTime(300);
49
+ expect(writeMock).toHaveBeenCalledTimes(1);
50
+ });
51
+ });
@@ -13,10 +13,8 @@ import {
13
13
  cleanArgs,
14
14
  cleanRawInput,
15
15
  getAndValidateFileExtension,
16
- writeYaml,
17
- writeJson,
18
16
  writeToFileByExtension,
19
- } from '../utils';
17
+ } from '../utils/miscellaneous';
20
18
  import {
21
19
  ResolvedApi,
22
20
  Totals,
@@ -26,10 +24,9 @@ import {
26
24
  stringifyYaml,
27
25
  } from '@redocly/openapi-core';
28
26
  import { blue, red, yellow } from 'colorette';
29
- import { existsSync, statSync, writeFileSync } from 'fs';
27
+ import { existsSync, statSync } from 'fs';
30
28
  import * as path from 'path';
31
29
  import * as process from 'process';
32
- import * as utils from '../utils';
33
30
 
34
31
  jest.mock('os');
35
32
  jest.mock('colorette');
@@ -1,4 +1,4 @@
1
- import { loadConfigAndHandleErrors, sendTelemetry } from '../utils';
1
+ import { loadConfigAndHandleErrors, sendTelemetry } from '../utils/miscellaneous';
2
2
  import * as process from 'process';
3
3
  import { commandWrapper } from '../wrapper';
4
4
  import { handleLint } from '../commands/lint';
@@ -6,7 +6,7 @@ import { Arguments } from 'yargs';
6
6
  import { handlePush, PushOptions } from '../commands/push';
7
7
 
8
8
  jest.mock('node-fetch');
9
- jest.mock('../utils', () => ({
9
+ jest.mock('../utils/miscellaneous', () => ({
10
10
  sendTelemetry: jest.fn(),
11
11
  loadConfigAndHandleErrors: jest.fn(),
12
12
  }));
@@ -0,0 +1,37 @@
1
+ import { getApiKeys } from '../api-keys';
2
+ import * as fs from 'fs';
3
+
4
+ describe('getApiKeys()', () => {
5
+ afterEach(() => {
6
+ jest.resetAllMocks();
7
+ });
8
+
9
+ it('should return api key from environment variable', () => {
10
+ process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
11
+
12
+ expect(getApiKeys('test-domain')).toEqual('test-api-key');
13
+ });
14
+
15
+ it('should return api key from credentials file', () => {
16
+ process.env.REDOCLY_AUTHORIZATION = '';
17
+
18
+ jest.spyOn(fs, 'existsSync').mockReturnValue(true);
19
+ jest.spyOn(fs, 'readFileSync').mockReturnValue(
20
+ JSON.stringify({
21
+ ['test-domain']: 'test-api-key-from-credentials-file',
22
+ })
23
+ );
24
+
25
+ expect(getApiKeys('test-domain')).toEqual('test-api-key-from-credentials-file');
26
+ });
27
+
28
+ it('should throw an error if no api key provided', () => {
29
+ process.env.REDOCLY_AUTHORIZATION = '';
30
+
31
+ jest.spyOn(fs, 'existsSync').mockReturnValue(false);
32
+
33
+ expect(() => getApiKeys('test-domain')).toThrowError(
34
+ 'No api key provided, please use environment variable REDOCLY_AUTHORIZATION.'
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,275 @@
1
+ import fetch, { Response } from 'node-fetch';
2
+ import * as FormData from 'form-data';
3
+
4
+ import { ReuniteApiClient, PushPayload } from '../api-client';
5
+
6
+ jest.mock('node-fetch', () => ({
7
+ default: jest.fn(),
8
+ }));
9
+
10
+ function mockFetchResponse(response: any) {
11
+ (fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(response as unknown as Response);
12
+ }
13
+
14
+ describe('ApiClient', () => {
15
+ const testToken = 'test-token';
16
+ const testDomain = 'test-domain.com';
17
+ const testOrg = 'test-org';
18
+ const testProject = 'test-project';
19
+
20
+ describe('getDefaultBranch()', () => {
21
+ let apiClient: ReuniteApiClient;
22
+
23
+ beforeEach(() => {
24
+ apiClient = new ReuniteApiClient(testDomain, testToken);
25
+ });
26
+
27
+ it('should get default project branch', async () => {
28
+ mockFetchResponse({
29
+ ok: true,
30
+ json: jest.fn().mockResolvedValue({
31
+ branchName: 'test-branch',
32
+ }),
33
+ });
34
+
35
+ const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject);
36
+
37
+ expect(fetch).toHaveBeenCalledWith(
38
+ `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`,
39
+ {
40
+ method: 'GET',
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ Authorization: `Bearer ${testToken}`,
44
+ },
45
+ }
46
+ );
47
+
48
+ expect(result).toEqual('test-branch');
49
+ });
50
+
51
+ it('should throw parsed error if response is not ok', async () => {
52
+ mockFetchResponse({
53
+ ok: false,
54
+ json: jest.fn().mockResolvedValue({
55
+ type: 'about:blank',
56
+ title: 'Project source not found',
57
+ status: 404,
58
+ detail: 'Not Found',
59
+ object: 'problem',
60
+ }),
61
+ });
62
+
63
+ await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
64
+ new Error('Failed to fetch default branch: Project source not found')
65
+ );
66
+ });
67
+
68
+ it('should throw statusText error if response is not ok', async () => {
69
+ mockFetchResponse({
70
+ ok: false,
71
+ statusText: 'Not found',
72
+ json: jest.fn().mockResolvedValue({
73
+ unknownField: 'unknown-error',
74
+ }),
75
+ });
76
+
77
+ await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
78
+ new Error('Failed to fetch default branch: Not found')
79
+ );
80
+ });
81
+ });
82
+
83
+ describe('upsert()', () => {
84
+ const remotePayload = {
85
+ mountBranchName: 'remote-mount-branch-name',
86
+ mountPath: 'remote-mount-path',
87
+ };
88
+ let apiClient: ReuniteApiClient;
89
+
90
+ beforeEach(() => {
91
+ apiClient = new ReuniteApiClient(testDomain, testToken);
92
+ });
93
+
94
+ it('should upsert remote', async () => {
95
+ const responseMock = {
96
+ id: 'remote-id',
97
+ type: 'CICD',
98
+ mountPath: 'remote-mount-path',
99
+ mountBranchName: 'remote-mount-branch-name',
100
+ organizationId: testOrg,
101
+ projectId: testProject,
102
+ };
103
+
104
+ mockFetchResponse({
105
+ ok: true,
106
+ json: jest.fn().mockResolvedValue(responseMock),
107
+ });
108
+
109
+ const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload);
110
+
111
+ expect(fetch).toHaveBeenCalledWith(
112
+ `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`,
113
+ {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ Authorization: `Bearer ${testToken}`,
118
+ },
119
+ body: JSON.stringify({
120
+ mountPath: remotePayload.mountPath,
121
+ mountBranchName: remotePayload.mountBranchName,
122
+ type: 'CICD',
123
+ autoMerge: true,
124
+ }),
125
+ }
126
+ );
127
+
128
+ expect(result).toEqual(responseMock);
129
+ });
130
+
131
+ it('should throw parsed error if response is not ok', async () => {
132
+ mockFetchResponse({
133
+ ok: false,
134
+ json: jest.fn().mockResolvedValue({
135
+ type: 'about:blank',
136
+ title: 'Not allowed to mount remote outside of project content path: /docs',
137
+ status: 403,
138
+ detail: 'Forbidden',
139
+ object: 'problem',
140
+ }),
141
+ });
142
+
143
+ await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
144
+ new Error(
145
+ 'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs'
146
+ )
147
+ );
148
+ });
149
+
150
+ it('should throw statusText error if response is not ok', async () => {
151
+ mockFetchResponse({
152
+ ok: false,
153
+ statusText: 'Not found',
154
+ json: jest.fn().mockResolvedValue({
155
+ unknownField: 'unknown-error',
156
+ }),
157
+ });
158
+
159
+ await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
160
+ new Error('Failed to upsert remote: Not found')
161
+ );
162
+ });
163
+ });
164
+
165
+ describe('push()', () => {
166
+ const testRemoteId = 'test-remote-id';
167
+ const pushPayload = {
168
+ remoteId: testRemoteId,
169
+ commit: {
170
+ message: 'test-message',
171
+ author: {
172
+ name: 'test-name',
173
+ email: 'test-email',
174
+ },
175
+ branchName: 'test-branch-name',
176
+ },
177
+ } as unknown as PushPayload;
178
+
179
+ const filesMock = [{ path: 'some-file.yaml', stream: Buffer.from('fefef') }];
180
+
181
+ const responseMock = {
182
+ branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main',
183
+ hasChanges: true,
184
+ files: [
185
+ {
186
+ type: 'file',
187
+ name: 'some-file.yaml',
188
+ path: 'docs/remotes/some-file.yaml',
189
+ lastModified: 1698925132394.2993,
190
+ mimeType: 'text/yaml',
191
+ },
192
+ ],
193
+ commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43',
194
+ outdated: false,
195
+ };
196
+
197
+ let apiClient: ReuniteApiClient;
198
+
199
+ beforeEach(() => {
200
+ apiClient = new ReuniteApiClient(testDomain, testToken);
201
+ });
202
+
203
+ it('should push to remote', async () => {
204
+ let passedFormData = new FormData();
205
+
206
+ (fetch as jest.MockedFunction<typeof fetch>).mockImplementationOnce(
207
+ async (_: any, options: any): Promise<Response> => {
208
+ passedFormData = options.body as FormData;
209
+
210
+ return {
211
+ ok: true,
212
+ json: jest.fn().mockResolvedValue(responseMock),
213
+ } as unknown as Response;
214
+ }
215
+ );
216
+
217
+ const formData = new FormData();
218
+
219
+ formData.append('remoteId', testRemoteId);
220
+ formData.append('commit[message]', pushPayload.commit.message);
221
+ formData.append('commit[author][name]', pushPayload.commit.author.name);
222
+ formData.append('commit[author][email]', pushPayload.commit.author.email);
223
+ formData.append('commit[branchName]', pushPayload.commit.branchName);
224
+ formData.append('files[some-file.yaml]', filesMock[0].stream);
225
+
226
+ const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock);
227
+
228
+ expect(fetch).toHaveBeenCalledWith(
229
+ `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`,
230
+ expect.objectContaining({
231
+ method: 'POST',
232
+ headers: {
233
+ Authorization: `Bearer ${testToken}`,
234
+ },
235
+ })
236
+ );
237
+
238
+ expect(
239
+ JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '')
240
+ ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), ''));
241
+ expect(result).toEqual(responseMock);
242
+ });
243
+
244
+ it('should throw parsed error if response is not ok', async () => {
245
+ mockFetchResponse({
246
+ ok: false,
247
+ json: jest.fn().mockResolvedValue({
248
+ type: 'about:blank',
249
+ title: 'Cannot push to remote',
250
+ status: 403,
251
+ detail: 'Forbidden',
252
+ object: 'problem',
253
+ }),
254
+ });
255
+
256
+ await expect(
257
+ apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
258
+ ).rejects.toThrow(new Error('Failed to push: Cannot push to remote'));
259
+ });
260
+
261
+ it('should throw statusText error if response is not ok', async () => {
262
+ mockFetchResponse({
263
+ ok: false,
264
+ statusText: 'Not found',
265
+ json: jest.fn().mockResolvedValue({
266
+ unknownField: 'unknown-error',
267
+ }),
268
+ });
269
+
270
+ await expect(
271
+ apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
272
+ ).rejects.toThrow(new Error('Failed to push: Not found'));
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,15 @@
1
+ import { getDomain } from '../domains';
2
+
3
+ describe('getDomain()', () => {
4
+ it('should return the domain from environment variable', () => {
5
+ process.env.REDOCLY_DOMAIN = 'test-domain';
6
+
7
+ expect(getDomain()).toBe('test-domain');
8
+ });
9
+
10
+ it('should return the default domain if no domain provided', () => {
11
+ process.env.REDOCLY_DOMAIN = '';
12
+
13
+ expect(getDomain()).toBe('https://app.cloud.redocly.com');
14
+ });
15
+ });
@@ -0,0 +1,199 @@
1
+ import fetchWithTimeout from '../../utils/fetch-with-timeout';
2
+ import fetch from 'node-fetch';
3
+ import * as FormData from 'form-data';
4
+
5
+ import type { Response } from 'node-fetch';
6
+ import type { ReadStream } from 'fs';
7
+ import type {
8
+ ListRemotesResponse,
9
+ ProjectSourceResponse,
10
+ PushResponse,
11
+ UpsertRemoteResponse,
12
+ } from './types';
13
+
14
+ class RemotesApiClient {
15
+ constructor(private readonly domain: string, private readonly apiKey: string) {}
16
+
17
+ private async getParsedResponse<T>(response: Response): Promise<T> {
18
+ const responseBody = await response.json();
19
+
20
+ if (response.ok) {
21
+ return responseBody as T;
22
+ }
23
+
24
+ throw new Error(responseBody.title || response.statusText);
25
+ }
26
+
27
+ async getDefaultBranch(organizationId: string, projectId: string) {
28
+ const response = await fetch(
29
+ `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
30
+ {
31
+ method: 'GET',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ Authorization: `Bearer ${this.apiKey}`,
35
+ },
36
+ }
37
+ );
38
+
39
+ try {
40
+ const source = await this.getParsedResponse<ProjectSourceResponse>(response);
41
+
42
+ return source.branchName;
43
+ } catch (err) {
44
+ throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`);
45
+ }
46
+ }
47
+
48
+ async upsert(
49
+ organizationId: string,
50
+ projectId: string,
51
+ remote: {
52
+ mountPath: string;
53
+ mountBranchName: string;
54
+ }
55
+ ): Promise<UpsertRemoteResponse> {
56
+ const response = await fetch(
57
+ `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
58
+ {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ Authorization: `Bearer ${this.apiKey}`,
63
+ },
64
+ body: JSON.stringify({
65
+ mountPath: remote.mountPath,
66
+ mountBranchName: remote.mountBranchName,
67
+ type: 'CICD',
68
+ autoMerge: true,
69
+ }),
70
+ }
71
+ );
72
+
73
+ try {
74
+ return await this.getParsedResponse<UpsertRemoteResponse>(response);
75
+ } catch (err) {
76
+ throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`);
77
+ }
78
+ }
79
+
80
+ async push(
81
+ organizationId: string,
82
+ projectId: string,
83
+ payload: PushPayload,
84
+ files: { path: string; stream: ReadStream | Buffer }[]
85
+ ): Promise<PushResponse> {
86
+ const formData = new FormData();
87
+
88
+ formData.append('remoteId', payload.remoteId);
89
+ formData.append('commit[message]', payload.commit.message);
90
+ formData.append('commit[author][name]', payload.commit.author.name);
91
+ formData.append('commit[author][email]', payload.commit.author.email);
92
+ formData.append('commit[branchName]', payload.commit.branchName);
93
+ payload.commit.url && formData.append('commit[url]', payload.commit.url);
94
+ payload.commit.namespace && formData.append('commit[namespaceId]', payload.commit.namespace);
95
+ payload.commit.sha && formData.append('commit[sha]', payload.commit.sha);
96
+ payload.commit.repository && formData.append('commit[repositoryId]', payload.commit.repository);
97
+ payload.commit.createdAt && formData.append('commit[createdAt]', payload.commit.createdAt);
98
+
99
+ for (const file of files) {
100
+ formData.append(`files[${file.path}]`, file.stream);
101
+ }
102
+
103
+ payload.isMainBranch && formData.append('isMainBranch', 'true');
104
+
105
+ const response = await fetch(
106
+ `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
107
+ {
108
+ method: 'POST',
109
+ headers: {
110
+ Authorization: `Bearer ${this.apiKey}`,
111
+ },
112
+ body: formData,
113
+ }
114
+ );
115
+
116
+ try {
117
+ return await this.getParsedResponse<PushResponse>(response);
118
+ } catch (err) {
119
+ throw new Error(`Failed to push: ${err.message || 'Unknown error'}`);
120
+ }
121
+ }
122
+
123
+ async getRemotesList(organizationId: string, projectId: string, mountPath: string) {
124
+ const response = await fetch(
125
+ `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
126
+ {
127
+ method: 'GET',
128
+ headers: {
129
+ 'Content-Type': 'application/json',
130
+ Authorization: `Bearer ${this.apiKey}`,
131
+ },
132
+ }
133
+ );
134
+
135
+ try {
136
+ return await this.getParsedResponse<ListRemotesResponse>(response);
137
+ } catch (err) {
138
+ throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`);
139
+ }
140
+ }
141
+
142
+ async getPush({
143
+ organizationId,
144
+ projectId,
145
+ pushId,
146
+ }: {
147
+ organizationId: string;
148
+ projectId: string;
149
+ pushId: string;
150
+ }) {
151
+ const response = await fetchWithTimeout(
152
+ `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
153
+ {
154
+ method: 'GET',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ Authorization: `Bearer ${this.apiKey}`,
158
+ },
159
+ }
160
+ );
161
+
162
+ if (!response) {
163
+ throw new Error(`Failed to get push status: Time is up`);
164
+ }
165
+
166
+ try {
167
+ return await this.getParsedResponse<PushResponse>(response);
168
+ } catch (err) {
169
+ throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ export class ReuniteApiClient {
175
+ remotes: RemotesApiClient;
176
+
177
+ constructor(public domain: string, private readonly apiKey: string) {
178
+ this.remotes = new RemotesApiClient(this.domain, this.apiKey);
179
+ }
180
+ }
181
+
182
+ export type PushPayload = {
183
+ remoteId: string;
184
+ commit: {
185
+ message: string;
186
+ branchName: string;
187
+ sha?: string;
188
+ url?: string;
189
+ createdAt?: string;
190
+ namespace?: string;
191
+ repository?: string;
192
+ author: {
193
+ name: string;
194
+ email: string;
195
+ image?: string;
196
+ };
197
+ };
198
+ isMainBranch?: boolean;
199
+ };