@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
@@ -1,8 +1,34 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiGateway = void 0;
4
- const path = require("node:path");
5
- exports.ApiGateway = {
1
+ import { createWriteStream, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ /**
7
+ * Error thrown for non-OK API responses, carrying the HTTP status so callers
8
+ * can branch on auth failures etc. without string matching.
9
+ */
10
+ export class ApiError extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.name = 'ApiError';
15
+ this.status = status;
16
+ }
17
+ }
18
+ /**
19
+ * Parses a successful response body as JSON, wrapping parse failures in a
20
+ * descriptive error (a 200 with an HTML body otherwise surfaces as a bare
21
+ * SyntaxError with no context about which call failed).
22
+ */
23
+ async function parseJsonResponse(res, operation) {
24
+ try {
25
+ return (await res.json());
26
+ }
27
+ catch (error) {
28
+ throw new Error(`${operation}: API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`, { cause: error });
29
+ }
30
+ }
31
+ export const ApiGateway = {
6
32
  /**
7
33
  * Enhances generic "fetch failed" errors with more specific diagnostic information
8
34
  * @param error - The original TypeError from fetch
@@ -51,51 +77,82 @@ exports.ApiGateway = {
51
77
  // Add context and improve readability
52
78
  switch (res.status) {
53
79
  case 400: {
54
- throw new Error(`Invalid request: ${userMessage}`);
80
+ throw new ApiError(`Invalid request: ${userMessage}`, 400);
55
81
  }
56
82
  case 401: {
57
- throw new Error(`Authentication failed. Please check your API key.`);
83
+ // Auth-mode-neutral: the CLI accepts both API keys and Bearer sessions.
84
+ // status.ts matches on the "Authentication failed" / "API key" substrings.
85
+ let message = `Authentication failed — your credentials are invalid or expired. ` +
86
+ `Re-run \`dcd login\` or check your API key. (${operation})`;
87
+ if (userMessage) {
88
+ message += `\nServer response: ${userMessage}`;
89
+ }
90
+ throw new ApiError(message, 401);
58
91
  }
59
92
  case 403: {
60
93
  // For 403, use the server's error message directly as it's now detailed
61
94
  // If the message suggests an API key issue, provide additional guidance
62
95
  if (userMessage.toLowerCase().includes('api key')) {
63
- throw new Error(`${userMessage}\n\nTroubleshooting steps:\n` +
96
+ throw new ApiError(`${userMessage}\n\nTroubleshooting steps:\n` +
64
97
  ` 1. Verify DEVICE_CLOUD_API_KEY environment variable is set\n` +
65
98
  ` 2. Check you're using the correct API key for this environment\n` +
66
99
  ` 3. Ensure the API key hasn't been deleted or revoked\n` +
67
- ` 4. Confirm you're connecting to the correct API URL`);
100
+ ` 4. Confirm you're connecting to the correct API URL`, 403);
68
101
  }
69
- throw new Error(`Access denied. ${userMessage}`);
102
+ throw new ApiError(`Access denied. ${userMessage}`, 403);
70
103
  }
71
104
  case 404: {
72
- throw new Error(`Resource not found. ${userMessage}`);
105
+ throw new ApiError(`Resource not found. ${userMessage}`, 404);
73
106
  }
74
107
  case 429: {
75
- throw new Error(`Rate limit exceeded. Please try again later.`);
108
+ throw new ApiError(`Rate limit exceeded. Please try again later. (${operation})`, 429);
76
109
  }
77
110
  case 500: {
78
- throw new Error(`Server error occurred. Please try again or contact support.`);
111
+ throw new ApiError(`Server error occurred. Please try again or contact support. (${operation})`, 500);
79
112
  }
80
113
  default: {
81
- throw new Error(`${operation} failed: ${userMessage} (HTTP ${res.status})`);
114
+ // `operation` is already phrased as "Failed to …", so don't append
115
+ // another "failed" here (avoids "Failed to execute test failed: …").
116
+ throw new ApiError(`${operation}: ${userMessage} (HTTP ${res.status})`, res.status);
82
117
  }
83
118
  }
84
119
  },
85
- async checkForExistingUpload(baseUrl, apiKey, sha) {
120
+ /**
121
+ * Streams a fetch response body to disk, expanding a leading tilde and
122
+ * creating the destination directory if needed. Internal helper shared by
123
+ * the download methods.
124
+ */
125
+ async streamResponseToFile(res, destinationPath, operation) {
126
+ if (res.body === null) {
127
+ throw new Error(`${operation}: server response contained no body to download`);
128
+ }
129
+ // Handle tilde expansion for home directory
130
+ const expandedPath = destinationPath.replace(/^~(?=$|\/|\\)/, homedir());
131
+ // Create directory structure if it doesn't exist
132
+ const directory = path.dirname(expandedPath);
133
+ if (directory !== '.') {
134
+ mkdirSync(directory, { recursive: true });
135
+ }
136
+ // Use 'w' flag to overwrite existing files instead of failing.
137
+ // pipeline (unlike .pipe) propagates source-stream errors, so a
138
+ // mid-download network failure rejects instead of hanging forever.
139
+ const fileStream = createWriteStream(expandedPath, { flags: 'w' });
140
+ await pipeline(Readable.fromWeb(res.body), fileStream);
141
+ },
142
+ async checkForExistingUpload(baseUrl, auth, sha) {
86
143
  try {
87
144
  const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
88
145
  body: JSON.stringify({ sha }),
89
146
  headers: {
90
147
  'content-type': 'application/json',
91
- 'x-app-api-key': apiKey,
148
+ ...auth.headers,
92
149
  },
93
150
  method: 'POST',
94
151
  });
95
152
  if (!res.ok) {
96
153
  await this.handleApiError(res, 'Failed to check for existing upload');
97
154
  }
98
- return res.json();
155
+ return await parseJsonResponse(res, 'Failed to check for existing upload');
99
156
  }
100
157
  catch (error) {
101
158
  // Handle network-level errors (DNS, connection refused, timeout, etc.)
@@ -105,48 +162,20 @@ exports.ApiGateway = {
105
162
  throw error;
106
163
  }
107
164
  },
108
- async downloadArtifactsZip(baseUrl, apiKey, uploadId, results, artifactsPath = './artifacts.zip') {
165
+ async downloadArtifactsZip(baseUrl, auth, uploadId, results, artifactsPath = './artifacts.zip') {
109
166
  try {
110
167
  const res = await fetch(`${baseUrl}/results/${uploadId}/download`, {
111
168
  body: JSON.stringify({ results }),
112
169
  headers: {
113
170
  'content-type': 'application/json',
114
- 'x-app-api-key': apiKey,
171
+ ...auth.headers,
115
172
  },
116
173
  method: 'POST',
117
174
  });
118
175
  if (!res.ok) {
119
176
  await this.handleApiError(res, 'Failed to download artifacts');
120
177
  }
121
- // Handle tilde expansion for home directory
122
- if (artifactsPath.startsWith('~/') || artifactsPath === '~') {
123
- artifactsPath = artifactsPath.replace(/^~(?=$|\/|\\)/,
124
- // eslint-disable-next-line unicorn/prefer-module
125
- require('node:os').homedir());
126
- }
127
- // Create directory structure if it doesn't exist
128
- // eslint-disable-next-line unicorn/prefer-module
129
- const { dirname } = require('node:path');
130
- // eslint-disable-next-line unicorn/prefer-module
131
- const { createWriteStream, mkdirSync } = require('node:fs');
132
- // eslint-disable-next-line unicorn/prefer-module
133
- const { finished } = require('node:stream/promises');
134
- // eslint-disable-next-line unicorn/prefer-module
135
- const { Readable } = require('node:stream');
136
- const directory = dirname(artifactsPath);
137
- if (directory !== '.') {
138
- try {
139
- mkdirSync(directory, { recursive: true });
140
- }
141
- catch (error) {
142
- // Ignore if directory already exists
143
- if (error.code !== 'EEXIST') {
144
- throw error;
145
- }
146
- }
147
- }
148
- const fileStream = createWriteStream(artifactsPath, { flags: 'w' });
149
- await finished(Readable.fromWeb(res.body).pipe(fileStream));
178
+ await this.streamResponseToFile(res, artifactsPath, 'Failed to download artifacts');
150
179
  }
151
180
  catch (error) {
152
181
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -156,7 +185,7 @@ exports.ApiGateway = {
156
185
  }
157
186
  },
158
187
  async finaliseUpload(config) {
159
- const { baseUrl, apiKey, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
188
+ const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
160
189
  try {
161
190
  const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, {
162
191
  body: JSON.stringify({
@@ -165,19 +194,19 @@ exports.ApiGateway = {
165
194
  id,
166
195
  metadata,
167
196
  path, // This is tempPath for TUS uploads
168
- sha,
197
+ ...(sha ? { sha } : {}),
169
198
  supabaseSuccess,
170
199
  }),
171
200
  headers: {
172
201
  'content-type': 'application/json',
173
- 'x-app-api-key': apiKey,
202
+ ...auth.headers,
174
203
  },
175
204
  method: 'POST',
176
205
  });
177
206
  if (!res.ok) {
178
207
  await this.handleApiError(res, 'Failed to finalize upload');
179
208
  }
180
- return res.json();
209
+ return await parseJsonResponse(res, 'Failed to finalize upload');
181
210
  }
182
211
  catch (error) {
183
212
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -186,20 +215,20 @@ exports.ApiGateway = {
186
215
  throw error;
187
216
  }
188
217
  },
189
- async getBinaryUploadUrl(baseUrl, apiKey, platform, fileSize) {
218
+ async getBinaryUploadUrl(baseUrl, auth, platform, fileSize) {
190
219
  try {
191
220
  const res = await fetch(`${baseUrl}/uploads/getBinaryUploadUrl`, {
192
221
  body: JSON.stringify({ platform, fileSize }),
193
222
  headers: {
194
223
  'content-type': 'application/json',
195
- 'x-app-api-key': apiKey,
224
+ ...auth.headers,
196
225
  },
197
226
  method: 'POST',
198
227
  });
199
228
  if (!res.ok) {
200
229
  await this.handleApiError(res, 'Failed to get upload URL');
201
230
  }
202
- return res.json();
231
+ return await parseJsonResponse(res, 'Failed to get upload URL');
203
232
  }
204
233
  catch (error) {
205
234
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -208,20 +237,20 @@ exports.ApiGateway = {
208
237
  throw error;
209
238
  }
210
239
  },
211
- async finishLargeFile(baseUrl, apiKey, fileId, partSha1Array) {
240
+ async finishLargeFile(baseUrl, auth, fileId, partSha1Array) {
212
241
  try {
213
242
  const res = await fetch(`${baseUrl}/uploads/finishLargeFile`, {
214
243
  body: JSON.stringify({ fileId, partSha1Array }),
215
244
  headers: {
216
245
  'content-type': 'application/json',
217
- 'x-app-api-key': apiKey,
246
+ ...auth.headers,
218
247
  },
219
248
  method: 'POST',
220
249
  });
221
250
  if (!res.ok) {
222
251
  await this.handleApiError(res, 'Failed to finish large file');
223
252
  }
224
- return res.json();
253
+ return await parseJsonResponse(res, 'Failed to finish large file');
225
254
  }
226
255
  catch (error) {
227
256
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -230,16 +259,16 @@ exports.ApiGateway = {
230
259
  throw error;
231
260
  }
232
261
  },
233
- async getResultsForUpload(baseUrl, apiKey, uploadId) {
262
+ async getResultsForUpload(baseUrl, auth, uploadId) {
234
263
  // TODO: merge with getUploadStatus
235
264
  try {
236
265
  const res = await fetch(`${baseUrl}/results/${uploadId}`, {
237
- headers: { 'x-app-api-key': apiKey },
266
+ headers: { ...auth.headers },
238
267
  });
239
268
  if (!res.ok) {
240
269
  await this.handleApiError(res, 'Failed to get results');
241
270
  }
242
- return res.json();
271
+ return await parseJsonResponse(res, 'Failed to get results');
243
272
  }
244
273
  catch (error) {
245
274
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -248,7 +277,7 @@ exports.ApiGateway = {
248
277
  throw error;
249
278
  }
250
279
  },
251
- async getUploadStatus(baseUrl, apiKey, options) {
280
+ async getUploadStatus(baseUrl, auth, options) {
252
281
  const queryParams = new URLSearchParams();
253
282
  if (options.uploadId) {
254
283
  queryParams.append('uploadId', options.uploadId);
@@ -259,13 +288,13 @@ exports.ApiGateway = {
259
288
  try {
260
289
  const response = await fetch(`${baseUrl}/uploads/status?${queryParams}`, {
261
290
  headers: {
262
- 'x-app-api-key': apiKey,
291
+ ...auth.headers,
263
292
  },
264
293
  });
265
294
  if (!response.ok) {
266
295
  await this.handleApiError(response, 'Failed to get upload status');
267
296
  }
268
- return response.json();
297
+ return await parseJsonResponse(response, 'Failed to get upload status');
269
298
  }
270
299
  catch (error) {
271
300
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -274,7 +303,7 @@ exports.ApiGateway = {
274
303
  throw error;
275
304
  }
276
305
  },
277
- async listUploads(baseUrl, apiKey, options = {}) {
306
+ async listUploads(baseUrl, auth, options = {}) {
278
307
  const queryParams = new URLSearchParams();
279
308
  if (options.name) {
280
309
  queryParams.append('name', options.name);
@@ -295,13 +324,13 @@ exports.ApiGateway = {
295
324
  try {
296
325
  const response = await fetch(url, {
297
326
  headers: {
298
- 'x-app-api-key': apiKey,
327
+ ...auth.headers,
299
328
  },
300
329
  });
301
330
  if (!response.ok) {
302
331
  await this.handleApiError(response, 'Failed to list uploads');
303
332
  }
304
- return response.json();
333
+ return await parseJsonResponse(response, 'Failed to list uploads');
305
334
  }
306
335
  catch (error) {
307
336
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -310,19 +339,19 @@ exports.ApiGateway = {
310
339
  throw error;
311
340
  }
312
341
  },
313
- async uploadFlow(baseUrl, apiKey, testFormData) {
342
+ async uploadFlow(baseUrl, auth, testFormData) {
314
343
  try {
315
344
  const res = await fetch(`${baseUrl}/uploads/flow`, {
316
345
  body: testFormData,
317
346
  headers: {
318
- 'x-app-api-key': apiKey,
347
+ ...auth.headers,
319
348
  },
320
349
  method: 'POST',
321
350
  });
322
351
  if (!res.ok) {
323
352
  await this.handleApiError(res, 'Failed to upload test flows');
324
353
  }
325
- return res.json();
354
+ return await parseJsonResponse(res, 'Failed to upload test flows');
326
355
  }
327
356
  catch (error) {
328
357
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -331,16 +360,74 @@ exports.ApiGateway = {
331
360
  throw error;
332
361
  }
333
362
  },
363
+ /**
364
+ * Requests a storage URL for a client-direct flow zip upload. Mirrors
365
+ * `getBinaryUploadUrl` (same response shape) but stages the zip under
366
+ * `<orgId>/tests/` instead of the app-binary path. A 404 here means the API
367
+ * predates the client-direct flow path — callers fall back to `uploadFlow`.
368
+ */
369
+ async getFlowUploadUrl(baseUrl, auth, fileSize) {
370
+ try {
371
+ const res = await fetch(`${baseUrl}/uploads/getFlowUploadUrl`, {
372
+ body: JSON.stringify({ fileSize, useTus: true }),
373
+ headers: {
374
+ 'content-type': 'application/json',
375
+ ...auth.headers,
376
+ },
377
+ method: 'POST',
378
+ });
379
+ if (!res.ok) {
380
+ await this.handleApiError(res, 'Failed to get flow upload URL');
381
+ }
382
+ // Same response shape as getBinaryUploadUrl: { id, tempPath, finalPath, path, b2?, token? }.
383
+ return await parseJsonResponse(res, 'Failed to get flow upload URL');
384
+ }
385
+ catch (error) {
386
+ if (error instanceof TypeError && error.message === 'fetch failed') {
387
+ throw this.enhanceFetchError(error, `${baseUrl}/uploads/getFlowUploadUrl`);
388
+ }
389
+ throw error;
390
+ }
391
+ },
392
+ /**
393
+ * Submits a flow test that references an already-uploaded zip (JSON body, no
394
+ * multipart). The response is identical to the legacy `POST /uploads/flow`.
395
+ * A 404 means the API predates this endpoint — callers fall back to
396
+ * `uploadFlow`.
397
+ */
398
+ async submitFlowTest(baseUrl, auth, body) {
399
+ try {
400
+ const res = await fetch(`${baseUrl}/uploads/submitFlowTest`, {
401
+ body: JSON.stringify(body),
402
+ headers: {
403
+ 'content-type': 'application/json',
404
+ ...auth.headers,
405
+ },
406
+ method: 'POST',
407
+ });
408
+ if (!res.ok) {
409
+ await this.handleApiError(res, 'Failed to submit test flows');
410
+ }
411
+ // Identical response to the legacy multipart POST /uploads/flow.
412
+ return await parseJsonResponse(res, 'Failed to submit test flows');
413
+ }
414
+ catch (error) {
415
+ if (error instanceof TypeError && error.message === 'fetch failed') {
416
+ throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`);
417
+ }
418
+ throw error;
419
+ }
420
+ },
334
421
  /**
335
422
  * Generic report download method that handles both junit and allure reports
336
423
  * @param baseUrl - API base URL
337
- * @param apiKey - API key for authentication
424
+ * @param auth - AuthContext (API key or Bearer session) for authentication
338
425
  * @param uploadId - Upload ID to download report for
339
426
  * @param reportType - Type of report to download ('junit' or 'allure')
340
427
  * @param reportPath - Optional custom path for the downloaded report
341
428
  * @returns Promise that resolves when download is complete
342
429
  */
343
- async downloadReportGeneric(baseUrl, apiKey, uploadId, reportType, reportPath) {
430
+ async downloadReportGeneric(baseUrl, auth, uploadId, reportType, reportPath) {
344
431
  // Define endpoint and default filename mappings
345
432
  const config = {
346
433
  junit: {
@@ -369,7 +456,7 @@ exports.ApiGateway = {
369
456
  // Make the download request
370
457
  const res = await fetch(url, {
371
458
  headers: {
372
- 'x-app-api-key': apiKey,
459
+ ...auth.headers,
373
460
  },
374
461
  method: 'GET',
375
462
  });
@@ -380,38 +467,145 @@ exports.ApiGateway = {
380
467
  }
381
468
  throw new Error(`${errorPrefix}: ${res.status} ${errorText}`);
382
469
  }
383
- // Handle tilde expansion for home directory (applies to all report types)
384
- let expandedPath = finalReportPath;
385
- if (finalReportPath.startsWith('~/') || finalReportPath === '~') {
386
- expandedPath = finalReportPath.replace(/^~/,
387
- // eslint-disable-next-line unicorn/prefer-module
388
- require('node:os').homedir());
389
- }
390
- // Create directory structure if it doesn't exist
391
- // eslint-disable-next-line unicorn/prefer-module
392
- const { dirname } = require('node:path');
393
- // eslint-disable-next-line unicorn/prefer-module
394
- const { createWriteStream, mkdirSync } = require('node:fs');
395
- // eslint-disable-next-line unicorn/prefer-module
396
- const { finished } = require('node:stream/promises');
397
- // eslint-disable-next-line unicorn/prefer-module
398
- const { Readable } = require('node:stream');
399
- const directory = dirname(expandedPath);
400
- if (directory !== '.') {
401
- try {
402
- mkdirSync(directory, { recursive: true });
403
- }
404
- catch (error) {
405
- // Ignore EEXIST errors (directory already exists)
406
- if (error.code !== 'EEXIST') {
407
- throw error;
408
- }
409
- }
470
+ await this.streamResponseToFile(res, finalReportPath, errorPrefix);
471
+ }
472
+ catch (error) {
473
+ if (error instanceof TypeError && error.message === 'fetch failed') {
474
+ throw this.enhanceFetchError(error, url);
475
+ }
476
+ throw error;
477
+ }
478
+ },
479
+ async startLiveSession(baseUrl, auth, params) {
480
+ try {
481
+ const res = await fetch(`${baseUrl}/live`, {
482
+ body: JSON.stringify({
483
+ binaryUploadId: params.binaryUploadId,
484
+ deviceLocale: params.deviceLocale,
485
+ platform: params.platform,
486
+ androidDevice: params.androidDevice,
487
+ androidApiLevel: params.androidApiLevel,
488
+ }),
489
+ headers: {
490
+ 'content-type': 'application/json',
491
+ ...auth.headers,
492
+ },
493
+ method: 'POST',
494
+ });
495
+ if (!res.ok) {
496
+ await this.handleApiError(res, 'Failed to start live session');
497
+ }
498
+ return await parseJsonResponse(res, 'Failed to start live session');
499
+ }
500
+ catch (error) {
501
+ if (error instanceof TypeError && error.message === 'fetch failed') {
502
+ throw this.enhanceFetchError(error, `${baseUrl}/live`);
503
+ }
504
+ throw error;
505
+ }
506
+ },
507
+ async installLiveBinary(baseUrl, auth, sessionName, binaryUploadId) {
508
+ const url = `${baseUrl}/live/${sessionName}/install`;
509
+ try {
510
+ const res = await fetch(url, {
511
+ body: JSON.stringify({ binaryUploadId }),
512
+ headers: {
513
+ 'content-type': 'application/json',
514
+ ...auth.headers,
515
+ },
516
+ method: 'POST',
517
+ });
518
+ if (!res.ok) {
519
+ await this.handleApiError(res, 'Failed to install binary');
520
+ }
521
+ }
522
+ catch (error) {
523
+ if (error instanceof TypeError && error.message === 'fetch failed') {
524
+ throw this.enhanceFetchError(error, url);
525
+ }
526
+ throw error;
527
+ }
528
+ },
529
+ async execLiveYaml(baseUrl, auth, sessionName, yaml, opts = {}) {
530
+ const url = `${baseUrl}/live/${sessionName}/exec`;
531
+ try {
532
+ const res = await fetch(url, {
533
+ body: JSON.stringify({ yaml, async: opts.async }),
534
+ headers: {
535
+ 'content-type': 'application/json',
536
+ ...auth.headers,
537
+ },
538
+ method: 'POST',
539
+ });
540
+ if (!res.ok) {
541
+ await this.handleApiError(res, 'Failed to execute test');
542
+ }
543
+ return await parseJsonResponse(res, 'Failed to execute test');
544
+ }
545
+ catch (error) {
546
+ if (error instanceof TypeError && error.message === 'fetch failed') {
547
+ throw this.enhanceFetchError(error, url);
548
+ }
549
+ throw error;
550
+ }
551
+ },
552
+ async getLiveCommand(baseUrl, auth, sessionName, commandId) {
553
+ const url = `${baseUrl}/live/${sessionName}/commands/${commandId}`;
554
+ try {
555
+ const res = await fetch(url, { headers: { ...auth.headers }, method: 'GET' });
556
+ if (!res.ok) {
557
+ await this.handleApiError(res, 'Failed to get command status');
558
+ }
559
+ return await parseJsonResponse(res, 'Failed to get command status');
560
+ }
561
+ catch (error) {
562
+ if (error instanceof TypeError && error.message === 'fetch failed') {
563
+ throw this.enhanceFetchError(error, url);
564
+ }
565
+ throw error;
566
+ }
567
+ },
568
+ async keepaliveLiveSession(baseUrl, auth, sessionName) {
569
+ const url = `${baseUrl}/live/${sessionName}/keepalive`;
570
+ try {
571
+ const res = await fetch(url, { headers: { ...auth.headers }, method: 'POST' });
572
+ // A keepalive failure shouldn't kill a long-running poll; swallow non-OK.
573
+ if (!res.ok)
574
+ return;
575
+ }
576
+ catch {
577
+ // best effort
578
+ }
579
+ },
580
+ async stopLiveSession(baseUrl, auth, sessionName) {
581
+ const url = `${baseUrl}/live/${sessionName}`;
582
+ try {
583
+ const res = await fetch(url, {
584
+ headers: { ...auth.headers },
585
+ method: 'DELETE',
586
+ });
587
+ if (!res.ok) {
588
+ await this.handleApiError(res, 'Failed to stop session');
589
+ }
590
+ }
591
+ catch (error) {
592
+ if (error instanceof TypeError && error.message === 'fetch failed') {
593
+ throw this.enhanceFetchError(error, url);
594
+ }
595
+ throw error;
596
+ }
597
+ },
598
+ async getLiveSession(baseUrl, auth, sessionName) {
599
+ const url = `${baseUrl}/live/${sessionName}`;
600
+ try {
601
+ const res = await fetch(url, {
602
+ headers: { ...auth.headers },
603
+ method: 'GET',
604
+ });
605
+ if (!res.ok) {
606
+ await this.handleApiError(res, 'Failed to get session status');
410
607
  }
411
- // Write the file using streaming for better memory efficiency
412
- // Use 'w' flag to overwrite existing files instead of failing
413
- const fileStream = createWriteStream(expandedPath, { flags: 'w' });
414
- await finished(Readable.fromWeb(res.body).pipe(fileStream));
608
+ return await parseJsonResponse(res, 'Failed to get session status');
415
609
  }
416
610
  catch (error) {
417
611
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -0,0 +1,13 @@
1
+ import type { StoredSession } from '../utils/config-store.js';
2
+ export interface RefreshedSession {
3
+ access_token: string;
4
+ refresh_token: string;
5
+ /** Unix epoch seconds. */
6
+ expires_at: number;
7
+ user_email: string;
8
+ user_id: string;
9
+ }
10
+ export declare const CliAuthGateway: {
11
+ refresh(supabaseUrl: string, supabaseAnonKey: string, session: StoredSession): Promise<RefreshedSession>;
12
+ signOut(supabaseUrl: string, supabaseAnonKey: string, session: StoredSession): Promise<void>;
13
+ };