@devicecloud.dev/dcd 4.0.1 → 4.0.3-beta.1

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.
@@ -18,6 +18,7 @@ export default class Cloud extends Command {
18
18
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
19
19
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
20
20
  'artifacts-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
+ 'junit-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
22
  async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
22
23
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
23
24
  debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -54,10 +55,14 @@ export default class Cloud extends Command {
54
55
  private versionCheck;
55
56
  run(): Promise<any>;
56
57
  /**
57
- * Generate the JSON output file path based on upload ID or custom filename
58
- * @param uploadId - Upload ID to use if custom filename is not provided
59
- * @param jsonFileName - Optional custom filename (can include relative path)
60
- * @returns Path to the JSON output file
58
+ * Handle downloading reports based on the report type specified
59
+ * @param reportType The type of report to download ('junit', 'allure', or 'all')
60
+ * @param apiUrl The base URL for the API server
61
+ * @param apiKey The API key for authentication
62
+ * @param uploadId The unique identifier for the test upload
63
+ * @param junitPath Optional file path where the JUnit report should be saved
64
+ * @param debug Whether debug logging is enabled
65
+ * @returns Promise that resolves when all reports have been downloaded
61
66
  */
62
- private getJsonOutputPath;
67
+ private handleReportDownloads;
63
68
  }
@@ -64,7 +64,7 @@ class Cloud extends core_1.Command {
64
64
  let jsonFile = false;
65
65
  try {
66
66
  const { args, flags, raw } = await this.parse(Cloud);
67
- let { 'additional-app-binary-ids': nonFlatAdditionalAppBinaryIds, 'additional-app-files': nonFlatAdditionalAppFiles, 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'x86-arch': x86Arch, ...rest } = flags;
67
+ let { 'additional-app-binary-ids': nonFlatAdditionalAppBinaryIds, 'additional-app-files': nonFlatAdditionalAppFiles, 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, 'junit-path': junitPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'x86-arch': x86Arch, ...rest } = flags;
68
68
  // Resolve "latest" maestro version to actual version
69
69
  const resolvedMaestroVersion = (0, constants_1.resolveMaestroVersion)(maestroVersion);
70
70
  // Store debug flag for use in catch block
@@ -228,6 +228,9 @@ class Cloud extends core_1.Command {
228
228
  if (runnerType === 'm1') {
229
229
  this.log('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.');
230
230
  }
231
+ if (runnerType === 'gpu1') {
232
+ this.log('Note: runnerType gpu1 is Android-only and requires contacting support to enable. Without support enablement, your runner type will revert to default.');
233
+ }
231
234
  const additionalAppBinaryIds = nonFlatAdditionalAppBinaryIds?.flat();
232
235
  const additionalAppFiles = nonFlatAdditionalAppFiles?.flat();
233
236
  const { firstFile, secondFile } = args;
@@ -589,7 +592,7 @@ class Cloud extends core_1.Command {
589
592
  uploadId: results[0].test_upload_id,
590
593
  };
591
594
  if (flags['json-file']) {
592
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
595
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
593
596
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
594
597
  }
595
598
  if (json) {
@@ -681,6 +684,10 @@ class Cloud extends core_1.Command {
681
684
  this.warn('Failed to download artifacts');
682
685
  }
683
686
  }
687
+ // Handle report downloads based on --report flag
688
+ if (report && ['allure', 'html', 'junit'].includes(report)) {
689
+ await this.handleReportDownloads(report, apiUrl, apiKey, results[0].test_upload_id, junitPath, debug);
690
+ }
684
691
  const resultsWithoutEarlierTries = updatedResults.filter((result) => {
685
692
  const originalTryId = result.retry_of || result.id;
686
693
  const tries = updatedResults.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
@@ -704,7 +711,7 @@ class Cloud extends core_1.Command {
704
711
  uploadId: results[0].test_upload_id,
705
712
  };
706
713
  if (flags['json-file']) {
707
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
714
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
708
715
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
709
716
  }
710
717
  if (json) {
@@ -730,7 +737,7 @@ class Cloud extends core_1.Command {
730
737
  uploadId: results[0].test_upload_id,
731
738
  };
732
739
  if (flags['json-file']) {
733
- const jsonFilePath = this.getJsonOutputPath(results[0].test_upload_id, jsonFileName);
740
+ const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
734
741
  (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, this);
735
742
  }
736
743
  if (json) {
@@ -781,16 +788,62 @@ class Cloud extends core_1.Command {
781
788
  }
782
789
  }
783
790
  /**
784
- * Generate the JSON output file path based on upload ID or custom filename
785
- * @param uploadId - Upload ID to use if custom filename is not provided
786
- * @param jsonFileName - Optional custom filename (can include relative path)
787
- * @returns Path to the JSON output file
791
+ * Handle downloading reports based on the report type specified
792
+ * @param reportType The type of report to download ('junit', 'allure', or 'all')
793
+ * @param apiUrl The base URL for the API server
794
+ * @param apiKey The API key for authentication
795
+ * @param uploadId The unique identifier for the test upload
796
+ * @param junitPath Optional file path where the JUnit report should be saved
797
+ * @param debug Whether debug logging is enabled
798
+ * @returns Promise that resolves when all reports have been downloaded
788
799
  */
789
- getJsonOutputPath(uploadId, jsonFileName) {
790
- if (jsonFileName) {
791
- return jsonFileName;
800
+ // eslint-disable-next-line max-params
801
+ async handleReportDownloads(reportType, apiUrl, apiKey, uploadId, junitPath, debug) {
802
+ const downloadReport = async (type, filePath) => {
803
+ try {
804
+ if (debug) {
805
+ this.log(`DEBUG: Downloading ${type.toUpperCase()} report`);
806
+ }
807
+ await api_gateway_1.ApiGateway.downloadReportGeneric(apiUrl, apiKey, uploadId, type, filePath);
808
+ this.log(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
809
+ }
810
+ catch (error) {
811
+ if (debug) {
812
+ this.log(`DEBUG: Error downloading ${type.toUpperCase()} report: ${error}`);
813
+ }
814
+ const errorMessage = error instanceof Error ? error.message : String(error);
815
+ this.warn(`Failed to download ${type.toUpperCase()} report: ${errorMessage}`);
816
+ if (errorMessage.includes('404')) {
817
+ this.warn(`No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`);
818
+ }
819
+ else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) {
820
+ this.warn('Permission denied. Check write permissions for the current directory.');
821
+ }
822
+ else if (errorMessage.includes('ENOENT')) {
823
+ this.warn('Directory does not exist. Make sure you have write access to the current directory.');
824
+ }
825
+ }
826
+ };
827
+ switch (reportType) {
828
+ case 'junit': {
829
+ const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml');
830
+ await downloadReport('junit', reportPath);
831
+ break;
832
+ }
833
+ case 'allure': {
834
+ const allureReportPath = path.resolve(process.cwd(), `allure-report-${uploadId}.zip`);
835
+ await downloadReport('allure', allureReportPath);
836
+ break;
837
+ }
838
+ case 'html': {
839
+ const htmlReportPath = path.resolve(process.cwd(), `html-report-${uploadId}.zip`);
840
+ await downloadReport('html', htmlReportPath);
841
+ break;
842
+ }
843
+ default: {
844
+ this.warn(`Unknown report type: ${reportType}`);
845
+ }
792
846
  }
793
- return `${uploadId}_dcd.json`;
794
847
  }
795
848
  }
796
849
  exports.default = Cloud;
@@ -12,6 +12,7 @@ export declare const flags: {
12
12
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
13
13
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
14
14
  'artifacts-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
+ 'junit-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
16
  async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
16
17
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
17
18
  debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
package/dist/constants.js CHANGED
@@ -73,6 +73,10 @@ exports.flags = {
73
73
  dependsOn: ['download-artifacts'],
74
74
  description: 'Custom file path for downloaded artifacts (default: ./artifacts.zip)',
75
75
  }),
76
+ 'junit-path': core_1.Flags.string({
77
+ dependsOn: ['report'],
78
+ description: 'Custom file path for downloaded JUnit report (default: ./report.xml)',
79
+ }),
76
80
  async: core_1.Flags.boolean({
77
81
  description: 'Immediately return (exit code 0) from the command without waiting for the results of the run (useful for saving CI minutes)',
78
82
  }),
@@ -87,7 +91,7 @@ exports.flags = {
87
91
  description: 'Locale that will be set to a device, ISO-639-1 code and uppercase ISO-3166-1 code e.g. "de_DE" for Germany',
88
92
  }),
89
93
  'download-artifacts': core_1.Flags.string({
90
- description: 'Download a zip containing the logs, screenshots and videos for each result in this run. You will debited a $0.01 egress fee for each result. Use --download-artifacts=FAILED for failures only or --download-artifacts=ALL for every result.',
94
+ description: 'Download a zip containing the logs, screenshots and videos for each result in this run. You will be debited a $0.01 egress fee for each result. Options: ALL (everything), FAILED (failures only).',
91
95
  options: ['ALL', 'FAILED'],
92
96
  }),
93
97
  'dry-run': core_1.Flags.boolean({
@@ -190,16 +194,16 @@ exports.flags = {
190
194
  }),
191
195
  report: core_1.Flags.string({
192
196
  aliases: ['format'],
193
- description: 'Runs Maestro with the --format flag, this will generate a report in the specified format',
194
- options: ['junit', 'html'],
197
+ description: 'Generate and download test reports in the specified format. HTML reports will be returned in the Allure format, please see the documentation for more details.',
198
+ options: ['junit', 'allure', 'html'],
195
199
  }),
196
200
  retry: core_1.Flags.integer({
197
201
  description: 'Automatically retry the run up to the number of times specified (same as pressing retry in the UI) - this is free of charge',
198
202
  }),
199
203
  'runner-type': core_1.Flags.string({
200
204
  default: 'default',
201
- description: '[experimental] The type of runner to use - note: anything other than default will incur premium pricing tiers, see https://docs.devicecloud.dev/reference/runner-type for more information',
202
- options: ['default', 'm4', 'm1'],
205
+ description: '[experimental] The type of runner to use - note: anything other than default will incur premium pricing tiers, see https://docs.devicecloud.dev/reference/runner-type for more information. gpu1 is Android-only and requires contacting support to enable, otherwise reverts to default.',
206
+ options: ['default', 'm4', 'm1', 'gpu1'],
203
207
  }),
204
208
  'show-crosshairs': core_1.Flags.boolean({
205
209
  default: false,
@@ -1,5 +1,12 @@
1
1
  import { TAppMetadata } from '../types';
2
2
  export declare const ApiGateway: {
3
+ /**
4
+ * Standardized error handling for API responses
5
+ * @param res - The fetch response object
6
+ * @param operation - Description of the operation that failed
7
+ * @returns Never returns, always throws
8
+ */
9
+ handleApiError(res: Response, operation: string): Promise<never>;
3
10
  checkForExistingUpload(baseUrl: string, apiKey: string, sha: string): Promise<{
4
11
  appBinaryId: string;
5
12
  exists: boolean;
@@ -7,14 +14,14 @@ export declare const ApiGateway: {
7
14
  downloadArtifactsZip(baseUrl: string, apiKey: string, uploadId: string, results: "ALL" | "FAILED", artifactsPath?: string): Promise<void>;
8
15
  finaliseUpload(baseUrl: string, apiKey: string, id: string, metadata: TAppMetadata, path: string, sha: string): Promise<Record<string, never>>;
9
16
  getBinaryUploadUrl(baseUrl: string, apiKey: string, platform: "android" | "ios"): Promise<{
10
- id: string;
11
17
  message: string;
12
18
  path: string;
13
19
  token: string;
20
+ id: string;
14
21
  }>;
15
22
  getResultsForUpload(baseUrl: string, apiKey: string, uploadId: string): Promise<{
16
- results?: import("../types/schema.types").components["schemas"]["TResultResponse"][];
17
23
  statusCode?: number;
24
+ results?: import("../types/schema.types").components["schemas"]["TResultResponse"][];
18
25
  }>;
19
26
  getUploadStatus(baseUrl: string, apiKey: string, options: {
20
27
  name?: string;
@@ -32,4 +39,14 @@ export declare const ApiGateway: {
32
39
  message?: string;
33
40
  results?: import("../types/schema.types").components["schemas"]["IDBResult"][];
34
41
  }>;
42
+ /**
43
+ * Generic report download method that handles both junit and allure reports
44
+ * @param baseUrl - API base URL
45
+ * @param apiKey - API key for authentication
46
+ * @param uploadId - Upload ID to download report for
47
+ * @param reportType - Type of report to download ('junit' or 'allure')
48
+ * @param reportPath - Optional custom path for the downloaded report
49
+ * @returns Promise that resolves when download is complete
50
+ */
51
+ downloadReportGeneric(baseUrl: string, apiKey: string, uploadId: string, reportType: "allure" | "junit" | "html", reportPath?: string): Promise<void>;
35
52
  };
@@ -1,7 +1,50 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiGateway = void 0;
4
+ const path = require("node:path");
4
5
  exports.ApiGateway = {
6
+ /**
7
+ * Standardized error handling for API responses
8
+ * @param res - The fetch response object
9
+ * @param operation - Description of the operation that failed
10
+ * @returns Never returns, always throws
11
+ */
12
+ async handleApiError(res, operation) {
13
+ const errorText = await res.text();
14
+ let userMessage;
15
+ // Parse common API error formats
16
+ try {
17
+ const errorData = JSON.parse(errorText);
18
+ userMessage = errorData.message || errorData.error || errorText;
19
+ }
20
+ catch {
21
+ userMessage = errorText;
22
+ }
23
+ // Add context and improve readability
24
+ switch (res.status) {
25
+ case 400: {
26
+ throw new Error(`Invalid request: ${userMessage}`);
27
+ }
28
+ case 401: {
29
+ throw new Error(`Authentication failed. Please check your API key.`);
30
+ }
31
+ case 403: {
32
+ throw new Error(`Access denied. ${userMessage}`);
33
+ }
34
+ case 404: {
35
+ throw new Error(`Resource not found. ${userMessage}`);
36
+ }
37
+ case 429: {
38
+ throw new Error(`Rate limit exceeded. Please try again later.`);
39
+ }
40
+ case 500: {
41
+ throw new Error(`Server error occurred. Please try again or contact support.`);
42
+ }
43
+ default: {
44
+ throw new Error(`${operation} failed: ${userMessage} (HTTP ${res.status})`);
45
+ }
46
+ }
47
+ },
5
48
  async checkForExistingUpload(baseUrl, apiKey, sha) {
6
49
  const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
7
50
  body: JSON.stringify({ sha }),
@@ -23,7 +66,7 @@ exports.ApiGateway = {
23
66
  method: 'POST',
24
67
  });
25
68
  if (!res.ok) {
26
- throw new Error(await res.text());
69
+ await this.handleApiError(res, 'Failed to download artifacts');
27
70
  }
28
71
  // Handle tilde expansion for home directory
29
72
  if (artifactsPath.startsWith('~/') || artifactsPath === '~') {
@@ -66,7 +109,7 @@ exports.ApiGateway = {
66
109
  method: 'POST',
67
110
  });
68
111
  if (!res.ok) {
69
- throw new Error(await res.text());
112
+ await this.handleApiError(res, 'Failed to finalize upload');
70
113
  }
71
114
  return res.json();
72
115
  },
@@ -80,7 +123,7 @@ exports.ApiGateway = {
80
123
  method: 'POST',
81
124
  });
82
125
  if (!res.ok) {
83
- throw new Error(await res.text());
126
+ await this.handleApiError(res, 'Failed to get upload URL');
84
127
  }
85
128
  return res.json();
86
129
  },
@@ -90,7 +133,7 @@ exports.ApiGateway = {
90
133
  headers: { 'x-app-api-key': apiKey },
91
134
  });
92
135
  if (!res.ok) {
93
- throw new Error(await res.text());
136
+ await this.handleApiError(res, 'Failed to get results');
94
137
  }
95
138
  return res.json();
96
139
  },
@@ -108,7 +151,7 @@ exports.ApiGateway = {
108
151
  },
109
152
  });
110
153
  if (!response.ok) {
111
- throw new Error(`Failed to fetch status: ${response.statusText}`);
154
+ await this.handleApiError(response, 'Failed to get upload status');
112
155
  }
113
156
  return response.json();
114
157
  },
@@ -121,8 +164,88 @@ exports.ApiGateway = {
121
164
  method: 'POST',
122
165
  });
123
166
  if (!res.ok) {
124
- throw new Error(await res.text());
167
+ await this.handleApiError(res, 'Failed to upload test flows');
125
168
  }
126
169
  return res.json();
127
170
  },
171
+ /**
172
+ * Generic report download method that handles both junit and allure reports
173
+ * @param baseUrl - API base URL
174
+ * @param apiKey - API key for authentication
175
+ * @param uploadId - Upload ID to download report for
176
+ * @param reportType - Type of report to download ('junit' or 'allure')
177
+ * @param reportPath - Optional custom path for the downloaded report
178
+ * @returns Promise that resolves when download is complete
179
+ */
180
+ async downloadReportGeneric(baseUrl, apiKey, uploadId, reportType, reportPath) {
181
+ // Define endpoint and default filename mappings
182
+ const config = {
183
+ junit: {
184
+ endpoint: `/results/${uploadId}/report`,
185
+ defaultFilename: `report-${uploadId}.xml`,
186
+ notFoundMessage: `Upload ID '${uploadId}' not found or no results available for this upload`,
187
+ errorPrefix: 'Failed to download report',
188
+ },
189
+ allure: {
190
+ endpoint: `/allure/${uploadId}/download`,
191
+ defaultFilename: `allure-report-${uploadId}.zip`,
192
+ notFoundMessage: `Upload ID '${uploadId}' not found or no Allure report available for this upload`,
193
+ errorPrefix: 'Failed to download Allure report',
194
+ },
195
+ html: {
196
+ endpoint: `/results/${uploadId}/html-report`,
197
+ defaultFilename: `html-report-${uploadId}.zip`,
198
+ notFoundMessage: `Upload ID '${uploadId}' not found or no HTML report available for this upload`,
199
+ errorPrefix: 'Failed to download HTML report',
200
+ },
201
+ };
202
+ const { endpoint, defaultFilename, notFoundMessage, errorPrefix } = config[reportType];
203
+ const finalReportPath = reportPath || path.resolve(process.cwd(), defaultFilename);
204
+ const url = `${baseUrl}${endpoint}`;
205
+ // Make the download request
206
+ const res = await fetch(url, {
207
+ headers: {
208
+ 'x-app-api-key': apiKey,
209
+ },
210
+ method: 'GET',
211
+ });
212
+ if (!res.ok) {
213
+ const errorText = await res.text();
214
+ if (res.status === 404) {
215
+ throw new Error(notFoundMessage);
216
+ }
217
+ throw new Error(`${errorPrefix}: ${res.status} ${errorText}`);
218
+ }
219
+ // Handle tilde expansion for home directory (applies to all report types)
220
+ let expandedPath = finalReportPath;
221
+ if (finalReportPath.startsWith('~/') || finalReportPath === '~') {
222
+ expandedPath = finalReportPath.replace(/^~/,
223
+ // eslint-disable-next-line unicorn/prefer-module
224
+ require('node:os').homedir());
225
+ }
226
+ // Create directory structure if it doesn't exist
227
+ // eslint-disable-next-line unicorn/prefer-module
228
+ const { dirname } = require('node:path');
229
+ // eslint-disable-next-line unicorn/prefer-module
230
+ const { createWriteStream, mkdirSync } = require('node:fs');
231
+ // eslint-disable-next-line unicorn/prefer-module
232
+ const { finished } = require('node:stream/promises');
233
+ // eslint-disable-next-line unicorn/prefer-module
234
+ const { Readable } = require('node:stream');
235
+ const directory = dirname(expandedPath);
236
+ if (directory !== '.') {
237
+ try {
238
+ mkdirSync(directory, { recursive: true });
239
+ }
240
+ catch (error) {
241
+ // Ignore EEXIST errors (directory already exists)
242
+ if (error.code !== 'EEXIST') {
243
+ throw error;
244
+ }
245
+ }
246
+ }
247
+ // Write the file using streaming for better memory efficiency
248
+ const fileStream = createWriteStream(expandedPath, { flags: 'wx' });
249
+ await finished(Readable.fromWeb(res.body).pipe(fileStream));
250
+ },
128
251
  };