@devicecloud.dev/dcd 4.4.9 → 5.0.0-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.
Files changed (130) hide show
  1. package/README.md +75 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +69 -64
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +430 -342
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +124 -131
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +520 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +252 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +30 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +170 -179
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +76 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +120 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +72 -78
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +31 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +52 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +13 -14
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +14 -18
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +43 -38
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +24 -29
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +31 -41
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +19 -15
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +48 -47
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +17 -20
  42. package/dist/gateways/api-gateway.d.ts +72 -16
  43. package/dist/gateways/api-gateway.js +298 -104
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +54 -0
  46. package/dist/gateways/realtime-gateway.d.ts +32 -0
  47. package/dist/gateways/realtime-gateway.js +103 -0
  48. package/dist/gateways/supabase-gateway.d.ts +11 -11
  49. package/dist/gateways/supabase-gateway.js +20 -48
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.js +98 -4
  52. package/dist/mcp/context.d.ts +33 -0
  53. package/dist/mcp/context.js +33 -0
  54. package/dist/mcp/helpers.d.ts +16 -0
  55. package/dist/mcp/helpers.js +34 -0
  56. package/dist/mcp/index.d.ts +2 -0
  57. package/dist/mcp/index.js +24 -0
  58. package/dist/mcp/server.d.ts +7 -0
  59. package/dist/mcp/server.js +27 -0
  60. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  61. package/dist/mcp/tools/download-artifacts.js +84 -0
  62. package/dist/mcp/tools/get-status.d.ts +7 -0
  63. package/dist/mcp/tools/get-status.js +39 -0
  64. package/dist/mcp/tools/list-devices.d.ts +7 -0
  65. package/dist/mcp/tools/list-devices.js +27 -0
  66. package/dist/mcp/tools/list-runs.d.ts +3 -0
  67. package/dist/mcp/tools/list-runs.js +60 -0
  68. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  69. package/dist/mcp/tools/run-cloud-test.js +233 -0
  70. package/dist/methods.d.ts +34 -5
  71. package/dist/methods.js +266 -215
  72. package/dist/services/device-validation.service.d.ts +9 -1
  73. package/dist/services/device-validation.service.js +56 -40
  74. package/dist/services/execution-plan.service.js +40 -31
  75. package/dist/services/execution-plan.utils.d.ts +3 -0
  76. package/dist/services/execution-plan.utils.js +25 -55
  77. package/dist/services/flow-paths.d.ts +17 -0
  78. package/dist/services/flow-paths.js +52 -0
  79. package/dist/services/metadata-extractor.service.d.ts +0 -2
  80. package/dist/services/metadata-extractor.service.js +75 -78
  81. package/dist/services/moropo.service.js +33 -34
  82. package/dist/services/report-download.service.d.ts +12 -1
  83. package/dist/services/report-download.service.js +34 -27
  84. package/dist/services/results-polling.service.d.ts +23 -9
  85. package/dist/services/results-polling.service.js +257 -123
  86. package/dist/services/telemetry.service.d.ts +49 -0
  87. package/dist/services/telemetry.service.js +252 -0
  88. package/dist/services/test-submission.service.d.ts +21 -4
  89. package/dist/services/test-submission.service.js +51 -33
  90. package/dist/services/version.service.d.ts +4 -3
  91. package/dist/services/version.service.js +28 -16
  92. package/dist/types/domain/auth.types.d.ts +20 -0
  93. package/dist/types/domain/auth.types.js +1 -0
  94. package/dist/types/domain/device.types.js +8 -11
  95. package/dist/types/domain/live.types.d.ts +76 -0
  96. package/dist/types/domain/live.types.js +3 -0
  97. package/dist/types/generated/schema.types.js +1 -2
  98. package/dist/types/index.d.ts +2 -2
  99. package/dist/types/index.js +2 -18
  100. package/dist/types.js +1 -2
  101. package/dist/utils/auth.d.ts +13 -0
  102. package/dist/utils/auth.js +141 -0
  103. package/dist/utils/ci.d.ts +12 -0
  104. package/dist/utils/ci.js +39 -0
  105. package/dist/utils/cli.d.ts +35 -0
  106. package/dist/utils/cli.js +118 -0
  107. package/dist/utils/compatibility.d.ts +2 -1
  108. package/dist/utils/compatibility.js +6 -8
  109. package/dist/utils/config-store.d.ts +35 -0
  110. package/dist/utils/config-store.js +115 -0
  111. package/dist/utils/connectivity.js +8 -7
  112. package/dist/utils/expo.js +29 -24
  113. package/dist/utils/orgs.d.ts +11 -0
  114. package/dist/utils/orgs.js +36 -0
  115. package/dist/utils/paths.d.ts +11 -0
  116. package/dist/utils/paths.js +21 -0
  117. package/dist/utils/progress.d.ts +13 -0
  118. package/dist/utils/progress.js +47 -0
  119. package/dist/utils/styling.d.ts +42 -36
  120. package/dist/utils/styling.js +78 -82
  121. package/dist/utils/ui.d.ts +41 -0
  122. package/dist/utils/ui.js +95 -0
  123. package/package.json +36 -45
  124. package/bin/dev.cmd +0 -3
  125. package/bin/dev.js +0 -6
  126. package/bin/run.cmd +0 -3
  127. package/bin/run.js +0 -7
  128. package/dist/types/schema.types.d.ts +0 -2702
  129. package/dist/types/schema.types.js +0 -3
  130. package/oclif.manifest.json +0 -884
package/dist/methods.js CHANGED
@@ -1,86 +1,101 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.formatDurationSeconds = exports.writeJSONFile = exports.uploadBinary = exports.verifyAppZip = exports.compressFilesFromRelativePath = exports.compressFolderToBlob = exports.toBuffer = void 0;
4
- const core_1 = require("@oclif/core");
5
- const archiver = require("archiver");
6
- const node_crypto_1 = require("node:crypto");
7
- const node_fs_1 = require("node:fs");
8
- const promises_1 = require("node:fs/promises");
9
- const path = require("node:path");
10
- const node_stream_1 = require("node:stream");
11
- const StreamZip = require("node-stream-zip");
12
- const api_gateway_1 = require("./gateways/api-gateway");
13
- const supabase_gateway_1 = require("./gateways/supabase-gateway");
14
- const metadata_extractor_service_1 = require("./services/metadata-extractor.service");
15
- const styling_1 = require("./utils/styling");
1
+ import { ux } from './utils/progress.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream, createWriteStream, mkdirSync, readdirSync, writeFileSync, } from 'node:fs';
4
+ import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
5
+ import * as os from 'node:os';
6
+ import * as path from 'node:path';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import StreamZip from 'node-stream-zip';
9
+ import * as yazl from 'yazl';
10
+ import { inferEnvFromApiUrl } from './config/environments.js';
11
+ import { ApiError, ApiGateway } from './gateways/api-gateway.js';
12
+ import { SupabaseGateway } from './gateways/supabase-gateway.js';
13
+ import { MetadataExtractorService } from './services/metadata-extractor.service.js';
14
+ import { colors, formatId } from './utils/styling.js';
16
15
  const mimeTypeLookupByExtension = {
17
16
  apk: 'application/vnd.android.package-archive',
18
17
  yaml: 'application/x-yaml',
19
18
  zip: 'application/zip',
20
19
  };
21
- const toBuffer = async (archive) => {
22
- const chunks = [];
23
- const writable = new node_stream_1.Writable();
24
- writable._write = (chunk, _, callback) => {
25
- // save to array to concatenate later
26
- chunks.push(chunk);
27
- callback();
28
- };
29
- // pipe to writable
30
- archive.pipe(writable);
31
- await archive.finalize();
32
- // once done, concatenate chunks
33
- return Buffer.concat(chunks);
34
- };
35
- exports.toBuffer = toBuffer;
36
- const compressFolderToBlob = async (sourceDir) => {
37
- const archive = archiver('zip', {
38
- zlib: { level: 9 },
39
- });
40
- archive.on('error', (err) => {
41
- throw err;
42
- });
43
- archive.directory(sourceDir, sourceDir.split('/').pop());
44
- const buffer = await (0, exports.toBuffer)(archive);
45
- return new Blob([buffer], { type: 'application/zip' });
46
- };
47
- exports.compressFolderToBlob = compressFolderToBlob;
48
- const compressFilesFromRelativePath = async (basePath, files, commonRoot) => {
49
- const archive = archiver('zip', {
50
- zlib: { level: 9 },
20
+ async function zipToBuffer(zipfile) {
21
+ return new Promise((resolve, reject) => {
22
+ // Typed as Uint8Array[] so Buffer.concat infers Buffer<ArrayBuffer> — matches
23
+ // the `BlobPart` shape that callers pass to `new Blob([...])`.
24
+ const chunks = [];
25
+ zipfile.outputStream.on('data', (chunk) => chunks.push(chunk));
26
+ zipfile.outputStream.on('end', () => resolve(Buffer.concat(chunks)));
27
+ zipfile.outputStream.on('error', reject);
28
+ zipfile.end();
51
29
  });
52
- archive.on('error', (err) => {
53
- throw err;
30
+ }
31
+ /** Normalize a filesystem path to a POSIX-style zip entry name. */
32
+ function toZipEntryName(relativePath) {
33
+ return relativePath.split(path.sep).join('/').replace(/^\/+/, '');
34
+ }
35
+ /**
36
+ * Zip a .app directory to a temp file on disk, streaming entries through
37
+ * yazl so the archive is never held in memory (iOS bundles run to GBs).
38
+ * Returns the temp directory holding the zip — callers remove it after the
39
+ * upload completes.
40
+ */
41
+ async function compressFolderToTempZip(sourceDir) {
42
+ const zipfile = new yazl.ZipFile();
43
+ const rootName = path.basename(sourceDir);
44
+ const entries = readdirSync(sourceDir, {
45
+ recursive: true,
46
+ withFileTypes: true,
54
47
  });
48
+ for (const entry of entries) {
49
+ if (!entry.isFile())
50
+ continue;
51
+ const absolutePath = path.join(entry.parentPath, entry.name);
52
+ const relativePath = path.relative(sourceDir, absolutePath);
53
+ zipfile.addFile(absolutePath, toZipEntryName(path.join(rootName, relativePath)));
54
+ }
55
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-app-zip-'));
56
+ const zipPath = path.join(tempDir, `${rootName}.zip`);
57
+ zipfile.end();
58
+ await pipeline(zipfile.outputStream, createWriteStream(zipPath));
59
+ return { tempDir, zipPath };
60
+ }
61
+ export const compressFilesFromRelativePath = async (basePath, files, commonRoot) => {
62
+ const zipfile = new yazl.ZipFile();
55
63
  for (const file of files) {
56
- archive.file(path.resolve(basePath, file), {
57
- name: file.replace(commonRoot, ''),
58
- });
64
+ // Anchored prefix strip — replace() would remove the first occurrence
65
+ // of commonRoot anywhere in the path, not just at the start.
66
+ zipfile.addFile(path.resolve(basePath, file), toZipEntryName(file.startsWith(commonRoot) ? file.slice(commonRoot.length) : file));
59
67
  }
60
- const buffer = await (0, exports.toBuffer)(archive);
61
- // await writeFile('./my-zip.zip', buffer);
62
- return buffer;
68
+ return zipToBuffer(zipfile);
63
69
  };
64
- exports.compressFilesFromRelativePath = compressFilesFromRelativePath;
65
- const verifyAppZip = async (zipPath) => {
70
+ export const verifyAppZip = async (zipPath) => {
66
71
  // eslint-disable-next-line import/namespace, new-cap
67
72
  const zip = await new StreamZip.async({
68
73
  file: zipPath,
69
74
  storeEntries: true,
70
75
  });
71
- const entries = await zip.entries();
72
- const topLevelEntries = Object.values(entries).filter((entry) => !entry.name.split('/')[1]);
73
- if (topLevelEntries.length !== 1 ||
74
- !topLevelEntries[0].name.endsWith('.app/')) {
75
- throw new Error('Zip file must contain exactly one entry which is a .app, check the contents of the zip file');
76
+ try {
77
+ const entries = await zip.entries();
78
+ // Derive top-level names from all entries rather than requiring an
79
+ // explicit directory entry — zips created without directory entries
80
+ // (e.g. Python's zipfile) are valid but have no ".app/" entry itself.
81
+ // macOS metadata (__MACOSX resource forks, .DS_Store) doesn't count
82
+ // toward the "exactly one .app" rule.
83
+ const topLevelNames = new Set(Object.values(entries)
84
+ .filter((entry) => !entry.name.startsWith('__MACOSX/') &&
85
+ entry.name.split('/').pop() !== '.DS_Store')
86
+ .map((entry) => entry.name.split('/')[0]));
87
+ if (topLevelNames.size !== 1 || ![...topLevelNames][0].endsWith('.app')) {
88
+ throw new Error('Zip file must contain exactly one entry which is a .app, check the contents of the zip file');
89
+ }
90
+ }
91
+ finally {
92
+ zip.close();
76
93
  }
77
- zip.close();
78
94
  };
79
- exports.verifyAppZip = verifyAppZip;
80
- const uploadBinary = async (config) => {
81
- const { filePath, apiUrl, apiKey, ignoreShaCheck = false, log = true, debug = false } = config;
95
+ export const uploadBinary = async (config) => {
96
+ const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
82
97
  if (log) {
83
- core_1.ux.action.start(styling_1.colors.bold('Checking and uploading binary'), styling_1.colors.dim('Initializing'), {
98
+ ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
84
99
  stdout: true,
85
100
  });
86
101
  }
@@ -91,32 +106,33 @@ const uploadBinary = async (config) => {
91
106
  console.log(`[DEBUG] Ignore SHA check: ${ignoreShaCheck}`);
92
107
  }
93
108
  const startTime = Date.now();
109
+ let source;
94
110
  try {
95
111
  // Prepare file for upload
96
- const file = await prepareFileForUpload(filePath, debug, startTime);
112
+ source = await prepareFileForUpload(filePath, debug, startTime);
97
113
  // Calculate SHA hash
98
- const sha = await calculateFileHash(file, debug, log);
114
+ const sha = await calculateFileHash(source, debug, log);
99
115
  // Check for existing upload with same SHA
100
116
  if (!ignoreShaCheck && sha) {
101
- const { exists, binaryId } = await checkExistingUpload(apiUrl, apiKey, sha, debug);
117
+ const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, sha, debug);
102
118
  if (exists && binaryId) {
103
119
  if (log) {
104
- core_1.ux.info(styling_1.colors.dim('SHA hash matches existing binary with ID: ') + (0, styling_1.formatId)(binaryId) + styling_1.colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
105
- core_1.ux.action.stop(styling_1.colors.info('Skipping upload'));
120
+ ux.info(colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
121
+ ux.action.stop(colors.info('Skipping upload'));
106
122
  }
107
123
  return binaryId;
108
124
  }
109
125
  }
110
126
  // Perform the upload
111
- const uploadId = await performUpload({ apiKey, apiUrl, debug, file, filePath, sha, startTime });
127
+ const uploadId = await performUpload({ auth, apiUrl, debug, filePath, sha, source, startTime });
112
128
  if (log) {
113
- core_1.ux.action.stop(styling_1.colors.success('\n✓ Binary uploaded with ID: ') + (0, styling_1.formatId)(uploadId));
129
+ ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
114
130
  }
115
131
  return uploadId;
116
132
  }
117
133
  catch (error) {
118
134
  if (log) {
119
- core_1.ux.action.stop(styling_1.colors.error('✗ Failed'));
135
+ ux.action.stop(colors.error('✗ Failed'));
120
136
  }
121
137
  if (debug) {
122
138
  console.error('[DEBUG] === BINARY UPLOAD FAILED ===');
@@ -128,38 +144,35 @@ const uploadBinary = async (config) => {
128
144
  }
129
145
  console.error(`[DEBUG] Failed after ${Date.now() - startTime}ms`);
130
146
  }
131
- // Add helpful context for common errors
132
- if (error instanceof Error) {
133
- if (error.name === 'NetworkError') {
134
- throw error; // NetworkError already has detailed troubleshooting info
135
- }
136
- // Re-throw with original message
137
- throw error;
138
- }
139
147
  throw error;
140
148
  }
149
+ finally {
150
+ if (source?.cleanupDir) {
151
+ await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => { });
152
+ }
153
+ }
141
154
  };
142
- exports.uploadBinary = uploadBinary;
143
155
  /**
144
- * Prepares a file for upload by reading or compressing it
156
+ * Prepares a file for upload: .app directories are zipped to a temp file on
157
+ * disk; everything else is described in place. Nothing is read into memory.
145
158
  * @param filePath Path to the file to upload
146
159
  * @param debug Whether debug logging is enabled
147
160
  * @param startTime Timestamp when upload started
148
- * @returns Promise resolving to prepared File object
161
+ * @returns Promise resolving to the upload source descriptor
149
162
  */
150
163
  async function prepareFileForUpload(filePath, debug, startTime) {
151
164
  if (debug) {
152
165
  console.log('[DEBUG] Preparing file for upload...');
153
166
  }
154
- let file;
167
+ let source;
155
168
  if (filePath?.endsWith('.app')) {
156
169
  if (debug) {
157
170
  console.log('[DEBUG] Compressing .app folder to zip...');
158
171
  }
159
- // Validate that the .app directory exists before attempting to compress
160
- // Without this check, archiver silently creates an empty 22-byte zip for non-existent paths
172
+ // Validate that the .app directory exists before attempting to compress
173
+ // zipping a non-existent path silently produces an empty 22-byte zip.
161
174
  try {
162
- await (0, promises_1.access)(filePath);
175
+ await access(filePath);
163
176
  }
164
177
  catch {
165
178
  // Provide helpful error message for common quoting issues
@@ -181,29 +194,36 @@ async function prepareFileForUpload(filePath, debug, startTime) {
181
194
  .join('\n');
182
195
  throw new Error(errorMessage);
183
196
  }
184
- const zippedAppBlob = await (0, exports.compressFolderToBlob)(filePath);
185
- file = new File([zippedAppBlob], filePath + '.zip');
197
+ const { tempDir, zipPath } = await compressFolderToTempZip(filePath);
198
+ const { size } = await stat(zipPath);
199
+ source = {
200
+ contentType: 'application/zip',
201
+ cleanupDir: tempDir,
202
+ diskPath: zipPath,
203
+ name: filePath + '.zip',
204
+ size,
205
+ };
186
206
  if (debug) {
187
- console.log(`[DEBUG] Compressed file size: ${(zippedAppBlob.size / 1024 / 1024).toFixed(2)} MB`);
207
+ console.log(`[DEBUG] Compressed file size: ${(size / 1024 / 1024).toFixed(2)} MB`);
188
208
  }
189
209
  }
190
210
  else {
211
+ const { size } = await stat(filePath);
191
212
  if (debug) {
192
- console.log('[DEBUG] Reading binary file...');
193
- }
194
- const fileBuffer = await (0, promises_1.readFile)(filePath);
195
- if (debug) {
196
- console.log(`[DEBUG] File size: ${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB`);
197
- }
198
- const binaryBlob = new Blob([new Uint8Array(fileBuffer)], {
199
- type: mimeTypeLookupByExtension[filePath.split('.').pop()],
200
- });
201
- file = new File([binaryBlob], filePath);
213
+ console.log(`[DEBUG] File size: ${(size / 1024 / 1024).toFixed(2)} MB`);
214
+ }
215
+ source = {
216
+ contentType: mimeTypeLookupByExtension[filePath.split('.').pop()] ||
217
+ 'application/octet-stream',
218
+ diskPath: filePath,
219
+ name: filePath,
220
+ size,
221
+ };
202
222
  }
203
223
  if (debug) {
204
224
  console.log(`[DEBUG] File preparation completed in ${Date.now() - startTime}ms`);
205
225
  }
206
- return file;
226
+ return source;
207
227
  }
208
228
  /**
209
229
  * Calculates SHA-256 hash for a file
@@ -212,13 +232,13 @@ async function prepareFileForUpload(filePath, debug, startTime) {
212
232
  * @param log Whether to log warnings
213
233
  * @returns Promise resolving to SHA-256 hash or undefined if failed
214
234
  */
215
- async function calculateFileHash(file, debug, log) {
235
+ async function calculateFileHash(source, debug, log) {
216
236
  try {
217
237
  if (debug) {
218
238
  console.log('[DEBUG] Calculating SHA-256 hash...');
219
239
  }
220
240
  const hashStartTime = Date.now();
221
- const sha = await getFileHashFromFile(file);
241
+ const sha = await getFileHashFromPath(source.diskPath);
222
242
  if (debug) {
223
243
  console.log(`[DEBUG] SHA-256 hash: ${sha}`);
224
244
  console.log(`[DEBUG] Hash calculation completed in ${Date.now() - hashStartTime}ms`);
@@ -238,19 +258,19 @@ async function calculateFileHash(file, debug, log) {
238
258
  /**
239
259
  * Checks if an upload with the same SHA already exists
240
260
  * @param apiUrl API base URL
241
- * @param apiKey API authentication key
261
+ * @param auth AuthContext carrying request headers
242
262
  * @param sha SHA-256 hash to check
243
263
  * @param debug Whether debug logging is enabled
244
264
  * @returns Promise resolving to object with exists flag and optional binaryId
245
265
  */
246
- async function checkExistingUpload(apiUrl, apiKey, sha, debug) {
266
+ async function checkExistingUpload(apiUrl, auth, sha, debug) {
247
267
  try {
248
268
  if (debug) {
249
269
  console.log('[DEBUG] Checking for existing upload with matching SHA...');
250
270
  console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`);
251
271
  }
252
272
  const shaCheckStartTime = Date.now();
253
- const { appBinaryId, exists } = await api_gateway_1.ApiGateway.checkForExistingUpload(apiUrl, apiKey, sha);
273
+ const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth, sha);
254
274
  if (debug) {
255
275
  console.log(`[DEBUG] SHA check completed in ${Date.now() - shaCheckStartTime}ms`);
256
276
  console.log(`[DEBUG] Existing binary found: ${exists}`);
@@ -261,6 +281,11 @@ async function checkExistingUpload(apiUrl, apiKey, sha, debug) {
261
281
  return { binaryId: appBinaryId, exists };
262
282
  }
263
283
  catch (error) {
284
+ // Invalid credentials will fail every subsequent request — surface now
285
+ // rather than after the user has waited through a potentially huge upload.
286
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
287
+ throw error;
288
+ }
264
289
  if (debug) {
265
290
  console.error('[DEBUG] === SHA CHECK FAILED ===');
266
291
  console.error('[DEBUG] Continuing with upload despite SHA check failure');
@@ -286,19 +311,19 @@ async function checkExistingUpload(apiUrl, apiKey, sha, debug) {
286
311
  * @param debug - Enable debug logging
287
312
  * @returns Upload result with success status and any error
288
313
  */
289
- async function uploadToSupabase(env, tempPath, file, debug) {
314
+ async function uploadToSupabase(env, tempPath, source, debug) {
290
315
  if (debug) {
291
316
  console.log(`[DEBUG] Uploading to Supabase storage (${env}) using resumable uploads...`);
292
317
  console.log(`[DEBUG] Staging path: ${tempPath}`);
293
- console.log(`[DEBUG] File size: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
318
+ console.log(`[DEBUG] File size: ${(source.size / 1024 / 1024).toFixed(2)} MB`);
294
319
  }
295
320
  try {
296
321
  const uploadStartTime = Date.now();
297
- await supabase_gateway_1.SupabaseGateway.uploadResumable(env, tempPath, file, debug);
322
+ await SupabaseGateway.uploadResumable(env, tempPath, source, debug);
298
323
  if (debug) {
299
324
  const uploadDuration = Date.now() - uploadStartTime;
300
325
  const uploadDurationSeconds = uploadDuration / 1000;
301
- const uploadSpeed = (file.size / 1024 / 1024) / uploadDurationSeconds;
326
+ const uploadSpeed = (source.size / 1024 / 1024) / uploadDurationSeconds;
302
327
  console.log(`[DEBUG] Supabase resumable upload completed in ${uploadDurationSeconds.toFixed(2)}s (${uploadDuration}ms)`);
303
328
  console.log(`[DEBUG] Average upload speed: ${uploadSpeed.toFixed(2)} MB/s`);
304
329
  }
@@ -314,7 +339,7 @@ async function uploadToSupabase(env, tempPath, file, debug) {
314
339
  console.error(`[DEBUG] Error stack:\n${uploadError.stack}`);
315
340
  }
316
341
  console.error(`[DEBUG] Staging path: ${tempPath}`);
317
- console.error(`[DEBUG] File size: ${file.size} bytes`);
342
+ console.error(`[DEBUG] File size: ${source.size} bytes`);
318
343
  console.log('[DEBUG] Will attempt Backblaze fallback if available...');
319
344
  }
320
345
  return { error: uploadError, success: false };
@@ -326,7 +351,7 @@ async function uploadToSupabase(env, tempPath, file, debug) {
326
351
  * @returns Upload result with success status and any error
327
352
  */
328
353
  async function handleBackblazeUpload(config) {
329
- const { b2, apiUrl, apiKey, finalPath, file, filePath, debug } = config;
354
+ const { b2, apiUrl, auth, finalPath, source, debug } = config;
330
355
  if (!b2) {
331
356
  if (debug) {
332
357
  console.log('[DEBUG] Backblaze not configured, will fall back to Supabase');
@@ -341,19 +366,18 @@ async function handleBackblazeUpload(config) {
341
366
  let backblazeSuccess = false;
342
367
  if (b2.strategy === 'simple' && b2.simple) {
343
368
  const simple = b2.simple;
344
- backblazeSuccess = await uploadToBackblaze(simple.uploadUrl, simple.authorizationToken, `organizations/${finalPath}`, file, debug);
369
+ backblazeSuccess = await uploadToBackblaze(simple.uploadUrl, simple.authorizationToken, `organizations/${finalPath}`, source, debug);
345
370
  }
346
371
  else if (b2.strategy === 'large' && b2.large) {
347
372
  const large = b2.large;
348
373
  backblazeSuccess = await uploadLargeFileToBackblaze({
349
- apiKey,
374
+ auth,
350
375
  apiUrl,
351
376
  debug,
352
377
  fileId: large.fileId,
353
378
  fileName: `organizations/${finalPath}`,
354
- fileObject: file,
355
- filePath,
356
- fileSize: file.size,
379
+ filePath: source.diskPath,
380
+ fileSize: source.size,
357
381
  uploadPartUrls: large.uploadPartUrls,
358
382
  });
359
383
  }
@@ -383,13 +407,13 @@ async function handleBackblazeUpload(config) {
383
407
  /**
384
408
  * Requests upload URL and paths from API
385
409
  * @param apiUrl - API base URL
386
- * @param apiKey - API authentication key
410
+ * @param auth - AuthContext carrying request headers
387
411
  * @param filePath - Path to the file being uploaded
388
412
  * @param fileSize - Size of the file in bytes
389
413
  * @param debug - Enable debug logging
390
414
  * @returns Promise resolving to upload paths and configuration
391
415
  */
392
- async function requestUploadPaths(apiUrl, apiKey, filePath, fileSize, debug) {
416
+ async function requestUploadPaths(apiUrl, auth, filePath, fileSize, debug) {
393
417
  const platform = filePath?.endsWith('.apk') ? 'android' : 'ios';
394
418
  if (debug) {
395
419
  console.log('[DEBUG] Requesting upload URL...');
@@ -398,7 +422,7 @@ async function requestUploadPaths(apiUrl, apiKey, filePath, fileSize, debug) {
398
422
  }
399
423
  try {
400
424
  const urlRequestStartTime = Date.now();
401
- const { id, tempPath, finalPath, b2 } = await api_gateway_1.ApiGateway.getBinaryUploadUrl(apiUrl, apiKey, platform, fileSize);
425
+ const { id, tempPath, finalPath, b2 } = await ApiGateway.getBinaryUploadUrl(apiUrl, auth, platform, fileSize);
402
426
  if (debug) {
403
427
  const hasStrategy = b2 && typeof b2 === 'object' && 'strategy' in b2;
404
428
  console.log(`[DEBUG] Upload URL request completed in ${Date.now() - urlRequestStartTime}ms`);
@@ -421,9 +445,11 @@ async function requestUploadPaths(apiUrl, apiKey, filePath, fileSize, debug) {
421
445
  // Add context to the error
422
446
  if (error instanceof Error) {
423
447
  if (error.name === 'NetworkError') {
424
- throw new Error(`Failed to request upload URL from API.\n\n${error.message}`);
448
+ throw new Error(`Failed to request upload URL from API.\n\n${error.message}`, { cause: error });
425
449
  }
426
- throw new Error(`Failed to request upload URL: ${error.message}`);
450
+ throw new Error(`Failed to request upload URL: ${error.message}`, {
451
+ cause: error,
452
+ });
427
453
  }
428
454
  throw error;
429
455
  }
@@ -437,7 +463,7 @@ async function requestUploadPaths(apiUrl, apiKey, filePath, fileSize, debug) {
437
463
  async function extractBinaryMetadata(filePath, debug) {
438
464
  if (debug)
439
465
  console.log('[DEBUG] Extracting app metadata...');
440
- const metadataExtractor = new metadata_extractor_service_1.MetadataExtractorService();
466
+ const metadataExtractor = new MetadataExtractorService();
441
467
  const metadata = await metadataExtractor.extract(filePath);
442
468
  if (!metadata) {
443
469
  throw new Error(`Failed to extract metadata from ${filePath}. Supported formats: .apk, .app, .zip`);
@@ -478,29 +504,27 @@ function validateUploadResults(supabaseSuccess, backblazeSuccess, lastError, b2,
478
504
  * @returns Promise resolving to upload ID
479
505
  */
480
506
  async function performUpload(config) {
481
- const { filePath, apiUrl, apiKey, file, sha, debug, startTime } = config;
507
+ const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
482
508
  // Request upload URL and paths
483
- const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, apiKey, filePath, file.size, debug);
509
+ const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);
484
510
  // Extract app metadata
485
511
  const metadata = await extractBinaryMetadata(filePath, debug);
486
- const env = apiUrl === 'https://api.devicecloud.dev' ? 'prod' : 'dev';
512
+ const env = inferEnvFromApiUrl(apiUrl);
487
513
  // Upload to Backblaze first (primary)
488
514
  const backblazeResult = await handleBackblazeUpload({
489
- apiKey,
515
+ auth,
490
516
  apiUrl,
491
517
  b2: b2,
492
518
  debug,
493
- file,
494
- filePath,
495
519
  finalPath,
520
+ source,
496
521
  });
497
522
  let lastError = backblazeResult.error;
498
523
  // Always upload to Supabase (re-enabled as always-on alongside Backblaze)
499
- let supabaseResult = { error: null, success: false };
500
524
  if (debug) {
501
525
  console.log('[DEBUG] Uploading to Supabase...');
502
526
  }
503
- supabaseResult = await uploadToSupabase(env, tempPath, file, debug);
527
+ const supabaseResult = await uploadToSupabase(env, tempPath, source, debug);
504
528
  if (!supabaseResult.success && supabaseResult.error) {
505
529
  lastError = supabaseResult.error;
506
530
  }
@@ -520,15 +544,16 @@ async function performUpload(config) {
520
544
  }
521
545
  // Finalize upload
522
546
  const finalizeStartTime = Date.now();
523
- await api_gateway_1.ApiGateway.finaliseUpload({
524
- apiKey,
547
+ await ApiGateway.finaliseUpload({
548
+ auth,
525
549
  backblazeSuccess: backblazeResult.success,
526
550
  baseUrl: apiUrl,
527
- bytes: file.size,
551
+ bytes: source.size,
528
552
  id,
529
553
  metadata,
530
554
  path: tempPath,
531
- sha: sha,
555
+ // sha is undefined when hash calculation failed — omit it explicitly
556
+ ...(sha ? { sha } : {}),
532
557
  supabaseSuccess: supabaseResult.success,
533
558
  });
534
559
  if (debug) {
@@ -537,21 +562,89 @@ async function performUpload(config) {
537
562
  }
538
563
  return id;
539
564
  }
565
+ /**
566
+ * Uploads an already-built flow zip directly to storage, mirroring the binary
567
+ * client-direct path: getFlowUploadUrl → upload to storage (Backblaze if
568
+ * offered + TUS to Supabase) → return the storage reference for submitFlowTest.
569
+ *
570
+ * The zip arrives as an in-memory Buffer (from `compressFilesFromRelativePath`);
571
+ * it's written to a temp file so the exact same disk-streaming uploaders the
572
+ * binary path uses can be reused unchanged. `supabaseSuccess`/`backblazeSuccess`
573
+ * are reported honestly based on which uploads succeeded.
574
+ *
575
+ * Errors from `getFlowUploadUrl` propagate untouched so callers can detect a
576
+ * 404 from an older API and fall back to the legacy multipart endpoint.
577
+ */
578
+ export const uploadFlowZip = async (config) => {
579
+ const { apiUrl, auth, buffer, debug = false } = config;
580
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-flow-zip-'));
581
+ const diskPath = path.join(tempDir, 'flowFile.zip');
582
+ try {
583
+ await writeFile(diskPath, buffer);
584
+ const source = {
585
+ contentType: 'application/zip',
586
+ diskPath,
587
+ name: 'flowFile.zip',
588
+ size: buffer.length,
589
+ };
590
+ // Same response shape as getBinaryUploadUrl. A 404 here means the API
591
+ // predates the client-direct flow path — let it propagate so the caller
592
+ // falls back to the multipart endpoint.
593
+ const { id, tempPath, finalPath, b2 } = await ApiGateway.getFlowUploadUrl(apiUrl, auth, buffer.length);
594
+ if (!tempPath)
595
+ throw new Error('No upload path provided by API');
596
+ const env = inferEnvFromApiUrl(apiUrl);
597
+ // Upload to Backblaze first (primary, if configured)
598
+ const backblazeResult = await handleBackblazeUpload({
599
+ auth,
600
+ apiUrl,
601
+ b2: b2,
602
+ debug,
603
+ finalPath,
604
+ source,
605
+ });
606
+ let lastError = backblazeResult.error;
607
+ // Always upload to Supabase (always-on alongside Backblaze)
608
+ const supabaseResult = await uploadToSupabase(env, tempPath, source, debug);
609
+ if (!supabaseResult.success && supabaseResult.error) {
610
+ lastError = supabaseResult.error;
611
+ }
612
+ validateUploadResults(supabaseResult.success, backblazeResult.success, lastError, b2, debug);
613
+ if (debug) {
614
+ console.log(`[DEBUG] Flow zip upload summary - Backblaze: ${backblazeResult.success ? '✓' : '✗'}, Supabase: ${supabaseResult.success ? '✓' : '✗'}`);
615
+ }
616
+ return {
617
+ backblazeSuccess: backblazeResult.success,
618
+ bytes: buffer.length,
619
+ id,
620
+ path: tempPath,
621
+ supabaseSuccess: supabaseResult.success,
622
+ useTus: true,
623
+ };
624
+ }
625
+ finally {
626
+ await rm(tempDir, { recursive: true, force: true }).catch(() => { });
627
+ }
628
+ };
540
629
  /**
541
630
  * Upload file to Backblaze using signed URL (simple upload for files < 100MB)
542
631
  * @param uploadUrl - Backblaze upload URL
543
632
  * @param authorizationToken - Authorization token for the upload
544
633
  * @param fileName - Name/path of the file
545
- * @param file - File to upload
634
+ * @param source - Upload source descriptor (streamed from disk)
546
635
  * @param debug - Whether debug logging is enabled
547
636
  * @returns Promise that resolves when upload completes or fails gracefully
548
637
  */
549
- async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, file, debug) {
638
+ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, source, debug) {
550
639
  try {
551
- const arrayBuffer = await file.arrayBuffer();
640
+ // The API only picks the simple strategy for small binaries (large files
641
+ // get the multi-part path), so one transient buffer here is bounded.
642
+ // S3 pre-signed PUTs reject chunked transfer encoding, which rules out a
643
+ // plain stream body.
644
+ const body = await readFile(source.diskPath);
552
645
  // Calculate SHA1 hash for Backblaze (B2 requires SHA1, not SHA256)
553
- const sha1 = (0, node_crypto_1.createHash)('sha1');
554
- sha1.update(Buffer.from(arrayBuffer));
646
+ const sha1 = createHash('sha1');
647
+ sha1.update(body);
555
648
  const sha1Hex = sha1.digest('hex');
556
649
  // Detect if this is an S3 pre-signed URL (authorization token is empty)
557
650
  const isS3PreSignedUrl = !authorizationToken || authorizationToken === '';
@@ -563,8 +656,8 @@ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, file,
563
656
  }
564
657
  // Build headers based on upload method
565
658
  const headers = {
566
- 'Content-Length': file.size.toString(),
567
- 'Content-Type': file.type || 'application/octet-stream',
659
+ 'Content-Length': body.length.toString(),
660
+ 'Content-Type': source.contentType || 'application/octet-stream',
568
661
  'X-Bz-Content-Sha1': sha1Hex,
569
662
  };
570
663
  // S3 pre-signed URLs have auth embedded in URL, native B2 uses Authorization header
@@ -573,7 +666,8 @@ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, file,
573
666
  headers['X-Bz-File-Name'] = encodeURIComponent(fileName);
574
667
  }
575
668
  const response = await fetch(uploadUrl, {
576
- body: arrayBuffer,
669
+ // Zero-copy view over the buffer (new Uint8Array(buffer) would clone it).
670
+ body: new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
577
671
  headers,
578
672
  method: isS3PreSignedUrl ? 'PUT' : 'POST',
579
673
  });
@@ -625,7 +719,7 @@ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, file,
625
719
  async function readFileChunk(filePath, start, end) {
626
720
  return new Promise((resolve, reject) => {
627
721
  const chunks = [];
628
- const stream = (0, node_fs_1.createReadStream)(filePath, { start, end: end - 1 }); // end is inclusive in createReadStream
722
+ const stream = createReadStream(filePath, { start, end: end - 1 }); // end is inclusive in createReadStream
629
723
  stream.on('data', (chunk) => {
630
724
  chunks.push(chunk);
631
725
  });
@@ -637,38 +731,13 @@ async function readFileChunk(filePath, start, end) {
637
731
  });
638
732
  });
639
733
  }
640
- /**
641
- * Helper function to read a chunk from a File/Blob object
642
- * @param file - File or Blob object
643
- * @param start - Start byte position
644
- * @param end - End byte position (exclusive)
645
- * @returns Promise resolving to Buffer containing the chunk
646
- */
647
- async function readFileObjectChunk(file, start, end) {
648
- const slice = file.slice(start, end);
649
- const arrayBuffer = await slice.arrayBuffer();
650
- return Buffer.from(arrayBuffer);
651
- }
652
- /**
653
- * Reads a file chunk from either a File object or disk
654
- * @param fileObject - Optional File object to read from
655
- * @param filePath - Path to file on disk
656
- * @param start - Start byte position
657
- * @param end - End byte position
658
- * @returns Promise resolving to Buffer containing the chunk
659
- */
660
- async function readChunk(fileObject, filePath, start, end) {
661
- return fileObject
662
- ? readFileObjectChunk(fileObject, start, end)
663
- : readFileChunk(filePath, start, end);
664
- }
665
734
  /**
666
735
  * Calculates SHA1 hash for a buffer
667
736
  * @param buffer - Buffer to hash
668
737
  * @returns SHA1 hash as hex string
669
738
  */
670
739
  function calculateSha1(buffer) {
671
- const sha1 = (0, node_crypto_1.createHash)('sha1');
740
+ const sha1 = createHash('sha1');
672
741
  sha1.update(buffer);
673
742
  return sha1.digest('hex');
674
743
  }
@@ -706,7 +775,9 @@ async function uploadPartToBackblaze(config) {
706
775
  if (debug) {
707
776
  console.error(`[DEBUG] Network error uploading part ${partNumber} - could be DNS, connection, or SSL issue`);
708
777
  }
709
- throw new Error(`Part ${partNumber} upload failed due to network error`);
778
+ throw new Error(`Part ${partNumber} upload failed due to network error`, {
779
+ cause: error,
780
+ });
710
781
  }
711
782
  throw error;
712
783
  }
@@ -744,14 +815,14 @@ function logBackblazeUploadError(error, debug) {
744
815
  * @returns Promise that resolves when upload completes or fails gracefully
745
816
  */
746
817
  async function uploadLargeFileToBackblaze(config) {
747
- const { apiUrl, apiKey, fileId, uploadPartUrls, filePath, fileSize, debug, fileObject } = config;
818
+ const { apiUrl, auth, fileId, uploadPartUrls, filePath, fileSize, debug } = config;
748
819
  try {
749
820
  const partSha1Array = [];
750
821
  const partSize = Math.ceil(fileSize / uploadPartUrls.length);
751
822
  if (debug) {
752
823
  console.log(`[DEBUG] Uploading large file in ${uploadPartUrls.length} parts (streaming mode)`);
753
824
  console.log(`[DEBUG] Part size: ${(partSize / 1024 / 1024).toFixed(2)} MB`);
754
- console.log(`[DEBUG] Reading from: ${fileObject ? 'in-memory File object' : filePath}`);
825
+ console.log(`[DEBUG] Reading from: ${filePath}`);
755
826
  }
756
827
  // Upload each part using streaming to avoid loading entire file into memory
757
828
  for (let i = 0; i < uploadPartUrls.length; i++) {
@@ -762,7 +833,7 @@ async function uploadLargeFileToBackblaze(config) {
762
833
  if (debug) {
763
834
  console.log(`[DEBUG] Reading part ${partNumber}/${uploadPartUrls.length} bytes ${start}-${end}`);
764
835
  }
765
- const partBuffer = await readChunk(fileObject, filePath, start, end);
836
+ const partBuffer = await readFileChunk(filePath, start, end);
766
837
  const sha1Hex = calculateSha1(partBuffer);
767
838
  partSha1Array.push(sha1Hex);
768
839
  await uploadPartToBackblaze({
@@ -776,18 +847,11 @@ async function uploadLargeFileToBackblaze(config) {
776
847
  uploadUrl: uploadPartUrls[i].uploadUrl,
777
848
  });
778
849
  }
779
- // Validate all parts were uploaded
780
- if (partSha1Array.length !== uploadPartUrls.length) {
781
- const errorMsg = `Part count mismatch: uploaded ${partSha1Array.length} parts but expected ${uploadPartUrls.length}`;
782
- if (debug)
783
- console.error(`[DEBUG] ${errorMsg}`);
784
- throw new Error(errorMsg);
785
- }
786
850
  if (debug) {
787
851
  console.log('[DEBUG] Finishing large file upload...');
788
852
  console.log(`[DEBUG] Finalizing ${partSha1Array.length} parts with fileId: ${fileId}`);
789
853
  }
790
- await api_gateway_1.ApiGateway.finishLargeFile(apiUrl, apiKey, fileId, partSha1Array);
854
+ await ApiGateway.finishLargeFile(apiUrl, auth, fileId, partSha1Array);
791
855
  if (debug)
792
856
  console.log('[DEBUG] Large file upload completed successfully');
793
857
  return true;
@@ -797,27 +861,12 @@ async function uploadLargeFileToBackblaze(config) {
797
861
  return false;
798
862
  }
799
863
  }
800
- async function getFileHashFromFile(file) {
801
- return new Promise((resolve, reject) => {
802
- const hash = (0, node_crypto_1.createHash)('sha256');
803
- const stream = file.stream();
804
- const reader = stream.getReader();
805
- const processChunks = async () => {
806
- try {
807
- let readerResult = await reader.read();
808
- while (!readerResult.done) {
809
- const { value } = readerResult;
810
- hash.update(value);
811
- readerResult = await reader.read();
812
- }
813
- resolve(hash.digest('hex'));
814
- }
815
- catch (error) {
816
- reject(error);
817
- }
818
- };
819
- processChunks();
820
- });
864
+ async function getFileHashFromPath(filePath) {
865
+ const hash = createHash('sha256');
866
+ for await (const chunk of createReadStream(filePath)) {
867
+ hash.update(chunk);
868
+ }
869
+ return hash.digest('hex');
821
870
  }
822
871
  /**
823
872
  * Writes JSON data to a file with error handling
@@ -826,33 +875,36 @@ async function getFileHashFromFile(file) {
826
875
  * @param logger - Logger object with log and warn methods
827
876
  * @returns true if successful, false if an error occurred
828
877
  */
829
- const writeJSONFile = (filePath, data, logger) => {
878
+ export const writeJSONFile = (filePath, data, logger) => {
830
879
  try {
831
- (0, node_fs_1.writeFileSync)(filePath, JSON.stringify(data, null, 2));
832
- logger.log(styling_1.colors.dim('JSON output written to: ') + styling_1.colors.highlight(path.resolve(filePath)));
880
+ const directory = path.dirname(filePath);
881
+ if (directory !== '.') {
882
+ mkdirSync(directory, { recursive: true });
883
+ }
884
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
885
+ logger.log(colors.dim('JSON output written to: ') + colors.highlight(path.resolve(filePath)));
833
886
  }
834
887
  catch (error) {
835
888
  const errorMessage = error instanceof Error ? error.message : String(error);
836
889
  const isPermissionError = errorMessage.includes('EACCES') || errorMessage.includes('EPERM');
837
890
  const isNoSuchFileError = errorMessage.includes('ENOENT');
838
- logger.warn(styling_1.colors.warning('⚠') + ' ' + styling_1.colors.error(`Failed to write JSON output to file: ${filePath}`));
891
+ logger.warn(colors.error(`Failed to write JSON output to file: ${filePath}`));
839
892
  if (isPermissionError) {
840
- logger.warn(styling_1.colors.dim(' Permission denied - check file/directory write permissions'));
841
- logger.warn(styling_1.colors.dim(' Try running with appropriate permissions or choose a different output location'));
893
+ logger.warn(colors.dim(' Permission denied - check file/directory write permissions'));
894
+ logger.warn(colors.dim(' Try running with appropriate permissions or choose a different output location'));
842
895
  }
843
896
  else if (isNoSuchFileError) {
844
- logger.warn(styling_1.colors.dim(' Directory does not exist - create the directory first or choose an existing path'));
897
+ logger.warn(colors.dim(' Directory does not exist - create the directory first or choose an existing path'));
845
898
  }
846
- logger.warn(styling_1.colors.dim(' Error details: ') + errorMessage);
899
+ logger.warn(colors.dim(' Error details: ') + errorMessage);
847
900
  }
848
901
  };
849
- exports.writeJSONFile = writeJSONFile;
850
902
  /**
851
903
  * Formats duration in seconds into a human readable string
852
904
  * @param durationSeconds - Duration in seconds
853
905
  * @returns Formatted duration string (e.g. "2m 30s" or "45s")
854
906
  */
855
- const formatDurationSeconds = (durationSeconds) => {
907
+ export const formatDurationSeconds = (durationSeconds) => {
856
908
  const minutes = Math.floor(durationSeconds / 60);
857
909
  const seconds = durationSeconds % 60;
858
910
  if (minutes > 0) {
@@ -860,4 +912,3 @@ const formatDurationSeconds = (durationSeconds) => {
860
912
  }
861
913
  return `${durationSeconds}s`;
862
914
  };
863
- exports.formatDurationSeconds = formatDurationSeconds;