@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +40 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +68 -60
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +389 -288
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +122 -127
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +513 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +250 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +32 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +162 -173
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +78 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +122 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +62 -67
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +34 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +58 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +12 -10
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +13 -14
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +41 -33
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +23 -25
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +30 -37
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +18 -11
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +47 -43
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +2 -2
  42. package/dist/gateways/api-gateway.d.ts +43 -12
  43. package/dist/gateways/api-gateway.js +240 -100
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +57 -0
  46. package/dist/gateways/supabase-gateway.d.ts +11 -11
  47. package/dist/gateways/supabase-gateway.js +15 -39
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +93 -2
  50. package/dist/methods.d.ts +3 -5
  51. package/dist/methods.js +170 -178
  52. package/dist/services/device-validation.service.d.ts +8 -0
  53. package/dist/services/device-validation.service.js +55 -35
  54. package/dist/services/execution-plan.service.js +27 -15
  55. package/dist/services/execution-plan.utils.d.ts +3 -0
  56. package/dist/services/execution-plan.utils.js +10 -32
  57. package/dist/services/metadata-extractor.service.d.ts +0 -2
  58. package/dist/services/metadata-extractor.service.js +57 -57
  59. package/dist/services/moropo.service.js +25 -24
  60. package/dist/services/report-download.service.d.ts +12 -1
  61. package/dist/services/report-download.service.js +31 -20
  62. package/dist/services/results-polling.service.d.ts +6 -7
  63. package/dist/services/results-polling.service.js +80 -33
  64. package/dist/services/telemetry.service.d.ts +40 -0
  65. package/dist/services/telemetry.service.js +230 -0
  66. package/dist/services/test-submission.service.js +2 -1
  67. package/dist/services/version.service.d.ts +3 -2
  68. package/dist/services/version.service.js +27 -11
  69. package/dist/types/domain/auth.types.d.ts +12 -0
  70. package/dist/types/{schema.types.js → domain/auth.types.js} +0 -1
  71. package/dist/types/domain/live.types.d.ts +76 -0
  72. package/dist/types/domain/live.types.js +4 -0
  73. package/dist/utils/auth.d.ts +13 -0
  74. package/dist/utils/auth.js +142 -0
  75. package/dist/utils/cli.d.ts +35 -0
  76. package/dist/utils/cli.js +127 -0
  77. package/dist/utils/compatibility.d.ts +2 -1
  78. package/dist/utils/compatibility.js +2 -2
  79. package/dist/utils/config-store.d.ts +35 -0
  80. package/dist/utils/config-store.js +125 -0
  81. package/dist/utils/connectivity.js +7 -3
  82. package/dist/utils/expo.js +14 -3
  83. package/dist/utils/orgs.d.ts +11 -0
  84. package/dist/utils/orgs.js +40 -0
  85. package/dist/utils/paths.d.ts +11 -0
  86. package/dist/utils/paths.js +24 -0
  87. package/dist/utils/progress.d.ts +13 -0
  88. package/dist/utils/progress.js +50 -0
  89. package/dist/utils/styling.d.ts +13 -5
  90. package/dist/utils/styling.js +37 -7
  91. package/package.json +26 -38
  92. package/bin/dev.cmd +0 -3
  93. package/bin/dev.js +0 -6
  94. package/bin/run.cmd +0 -3
  95. package/bin/run.js +0 -7
  96. package/dist/types/schema.types.d.ts +0 -2702
  97. package/oclif.manifest.json +0 -884
@@ -1,7 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApiGateway = void 0;
3
+ exports.ApiGateway = exports.ApiError = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_os_1 = require("node:os");
4
6
  const path = require("node:path");
7
+ const node_stream_1 = require("node:stream");
8
+ const promises_1 = require("node:stream/promises");
9
+ /**
10
+ * Error thrown for non-OK API responses, carrying the HTTP status so callers
11
+ * can branch on auth failures etc. without string matching.
12
+ */
13
+ class ApiError extends Error {
14
+ status;
15
+ constructor(message, status) {
16
+ super(message);
17
+ this.name = 'ApiError';
18
+ this.status = status;
19
+ }
20
+ }
21
+ exports.ApiError = ApiError;
22
+ /**
23
+ * Parses a successful response body as JSON, wrapping parse failures in a
24
+ * descriptive error (a 200 with an HTML body otherwise surfaces as a bare
25
+ * SyntaxError with no context about which call failed).
26
+ */
27
+ async function parseJsonResponse(res, operation) {
28
+ try {
29
+ return (await res.json());
30
+ }
31
+ catch (error) {
32
+ throw new Error(`${operation}: API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`);
33
+ }
34
+ }
5
35
  exports.ApiGateway = {
6
36
  /**
7
37
  * Enhances generic "fetch failed" errors with more specific diagnostic information
@@ -51,51 +81,82 @@ exports.ApiGateway = {
51
81
  // Add context and improve readability
52
82
  switch (res.status) {
53
83
  case 400: {
54
- throw new Error(`Invalid request: ${userMessage}`);
84
+ throw new ApiError(`Invalid request: ${userMessage}`, 400);
55
85
  }
56
86
  case 401: {
57
- throw new Error(`Authentication failed. Please check your API key.`);
87
+ // Auth-mode-neutral: the CLI accepts both API keys and Bearer sessions.
88
+ // status.ts matches on the "Authentication failed" / "API key" substrings.
89
+ let message = `Authentication failed — your credentials are invalid or expired. ` +
90
+ `Re-run \`dcd login\` or check your API key. (${operation})`;
91
+ if (userMessage) {
92
+ message += `\nServer response: ${userMessage}`;
93
+ }
94
+ throw new ApiError(message, 401);
58
95
  }
59
96
  case 403: {
60
97
  // For 403, use the server's error message directly as it's now detailed
61
98
  // If the message suggests an API key issue, provide additional guidance
62
99
  if (userMessage.toLowerCase().includes('api key')) {
63
- throw new Error(`${userMessage}\n\nTroubleshooting steps:\n` +
100
+ throw new ApiError(`${userMessage}\n\nTroubleshooting steps:\n` +
64
101
  ` 1. Verify DEVICE_CLOUD_API_KEY environment variable is set\n` +
65
102
  ` 2. Check you're using the correct API key for this environment\n` +
66
103
  ` 3. Ensure the API key hasn't been deleted or revoked\n` +
67
- ` 4. Confirm you're connecting to the correct API URL`);
104
+ ` 4. Confirm you're connecting to the correct API URL`, 403);
68
105
  }
69
- throw new Error(`Access denied. ${userMessage}`);
106
+ throw new ApiError(`Access denied. ${userMessage}`, 403);
70
107
  }
71
108
  case 404: {
72
- throw new Error(`Resource not found. ${userMessage}`);
109
+ throw new ApiError(`Resource not found. ${userMessage}`, 404);
73
110
  }
74
111
  case 429: {
75
- throw new Error(`Rate limit exceeded. Please try again later.`);
112
+ throw new ApiError(`Rate limit exceeded. Please try again later. (${operation})`, 429);
76
113
  }
77
114
  case 500: {
78
- throw new Error(`Server error occurred. Please try again or contact support.`);
115
+ throw new ApiError(`Server error occurred. Please try again or contact support. (${operation})`, 500);
79
116
  }
80
117
  default: {
81
- throw new Error(`${operation} failed: ${userMessage} (HTTP ${res.status})`);
118
+ // `operation` is already phrased as "Failed to …", so don't append
119
+ // another "failed" here (avoids "Failed to execute test failed: …").
120
+ throw new ApiError(`${operation}: ${userMessage} (HTTP ${res.status})`, res.status);
82
121
  }
83
122
  }
84
123
  },
85
- async checkForExistingUpload(baseUrl, apiKey, sha) {
124
+ /**
125
+ * Streams a fetch response body to disk, expanding a leading tilde and
126
+ * creating the destination directory if needed. Internal helper shared by
127
+ * the download methods.
128
+ */
129
+ async streamResponseToFile(res, destinationPath, operation) {
130
+ if (res.body === null) {
131
+ throw new Error(`${operation}: server response contained no body to download`);
132
+ }
133
+ // Handle tilde expansion for home directory
134
+ const expandedPath = destinationPath.replace(/^~(?=$|\/|\\)/, (0, node_os_1.homedir)());
135
+ // Create directory structure if it doesn't exist
136
+ const directory = path.dirname(expandedPath);
137
+ if (directory !== '.') {
138
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true });
139
+ }
140
+ // Use 'w' flag to overwrite existing files instead of failing.
141
+ // pipeline (unlike .pipe) propagates source-stream errors, so a
142
+ // mid-download network failure rejects instead of hanging forever.
143
+ const fileStream = (0, node_fs_1.createWriteStream)(expandedPath, { flags: 'w' });
144
+ await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(res.body), fileStream);
145
+ },
146
+ async checkForExistingUpload(baseUrl, auth, sha) {
86
147
  try {
87
148
  const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
88
149
  body: JSON.stringify({ sha }),
89
150
  headers: {
90
151
  'content-type': 'application/json',
91
- 'x-app-api-key': apiKey,
152
+ ...auth.headers,
92
153
  },
93
154
  method: 'POST',
94
155
  });
95
156
  if (!res.ok) {
96
157
  await this.handleApiError(res, 'Failed to check for existing upload');
97
158
  }
98
- return res.json();
159
+ return await parseJsonResponse(res, 'Failed to check for existing upload');
99
160
  }
100
161
  catch (error) {
101
162
  // Handle network-level errors (DNS, connection refused, timeout, etc.)
@@ -105,48 +166,20 @@ exports.ApiGateway = {
105
166
  throw error;
106
167
  }
107
168
  },
108
- async downloadArtifactsZip(baseUrl, apiKey, uploadId, results, artifactsPath = './artifacts.zip') {
169
+ async downloadArtifactsZip(baseUrl, auth, uploadId, results, artifactsPath = './artifacts.zip') {
109
170
  try {
110
171
  const res = await fetch(`${baseUrl}/results/${uploadId}/download`, {
111
172
  body: JSON.stringify({ results }),
112
173
  headers: {
113
174
  'content-type': 'application/json',
114
- 'x-app-api-key': apiKey,
175
+ ...auth.headers,
115
176
  },
116
177
  method: 'POST',
117
178
  });
118
179
  if (!res.ok) {
119
180
  await this.handleApiError(res, 'Failed to download artifacts');
120
181
  }
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));
182
+ await this.streamResponseToFile(res, artifactsPath, 'Failed to download artifacts');
150
183
  }
151
184
  catch (error) {
152
185
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -156,7 +189,7 @@ exports.ApiGateway = {
156
189
  }
157
190
  },
158
191
  async finaliseUpload(config) {
159
- const { baseUrl, apiKey, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
192
+ const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
160
193
  try {
161
194
  const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, {
162
195
  body: JSON.stringify({
@@ -165,19 +198,19 @@ exports.ApiGateway = {
165
198
  id,
166
199
  metadata,
167
200
  path, // This is tempPath for TUS uploads
168
- sha,
201
+ ...(sha ? { sha } : {}),
169
202
  supabaseSuccess,
170
203
  }),
171
204
  headers: {
172
205
  'content-type': 'application/json',
173
- 'x-app-api-key': apiKey,
206
+ ...auth.headers,
174
207
  },
175
208
  method: 'POST',
176
209
  });
177
210
  if (!res.ok) {
178
211
  await this.handleApiError(res, 'Failed to finalize upload');
179
212
  }
180
- return res.json();
213
+ return await parseJsonResponse(res, 'Failed to finalize upload');
181
214
  }
182
215
  catch (error) {
183
216
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -186,20 +219,20 @@ exports.ApiGateway = {
186
219
  throw error;
187
220
  }
188
221
  },
189
- async getBinaryUploadUrl(baseUrl, apiKey, platform, fileSize) {
222
+ async getBinaryUploadUrl(baseUrl, auth, platform, fileSize) {
190
223
  try {
191
224
  const res = await fetch(`${baseUrl}/uploads/getBinaryUploadUrl`, {
192
225
  body: JSON.stringify({ platform, fileSize }),
193
226
  headers: {
194
227
  'content-type': 'application/json',
195
- 'x-app-api-key': apiKey,
228
+ ...auth.headers,
196
229
  },
197
230
  method: 'POST',
198
231
  });
199
232
  if (!res.ok) {
200
233
  await this.handleApiError(res, 'Failed to get upload URL');
201
234
  }
202
- return res.json();
235
+ return await parseJsonResponse(res, 'Failed to get upload URL');
203
236
  }
204
237
  catch (error) {
205
238
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -208,20 +241,20 @@ exports.ApiGateway = {
208
241
  throw error;
209
242
  }
210
243
  },
211
- async finishLargeFile(baseUrl, apiKey, fileId, partSha1Array) {
244
+ async finishLargeFile(baseUrl, auth, fileId, partSha1Array) {
212
245
  try {
213
246
  const res = await fetch(`${baseUrl}/uploads/finishLargeFile`, {
214
247
  body: JSON.stringify({ fileId, partSha1Array }),
215
248
  headers: {
216
249
  'content-type': 'application/json',
217
- 'x-app-api-key': apiKey,
250
+ ...auth.headers,
218
251
  },
219
252
  method: 'POST',
220
253
  });
221
254
  if (!res.ok) {
222
255
  await this.handleApiError(res, 'Failed to finish large file');
223
256
  }
224
- return res.json();
257
+ return await parseJsonResponse(res, 'Failed to finish large file');
225
258
  }
226
259
  catch (error) {
227
260
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -230,16 +263,16 @@ exports.ApiGateway = {
230
263
  throw error;
231
264
  }
232
265
  },
233
- async getResultsForUpload(baseUrl, apiKey, uploadId) {
266
+ async getResultsForUpload(baseUrl, auth, uploadId) {
234
267
  // TODO: merge with getUploadStatus
235
268
  try {
236
269
  const res = await fetch(`${baseUrl}/results/${uploadId}`, {
237
- headers: { 'x-app-api-key': apiKey },
270
+ headers: { ...auth.headers },
238
271
  });
239
272
  if (!res.ok) {
240
273
  await this.handleApiError(res, 'Failed to get results');
241
274
  }
242
- return res.json();
275
+ return await parseJsonResponse(res, 'Failed to get results');
243
276
  }
244
277
  catch (error) {
245
278
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -248,7 +281,7 @@ exports.ApiGateway = {
248
281
  throw error;
249
282
  }
250
283
  },
251
- async getUploadStatus(baseUrl, apiKey, options) {
284
+ async getUploadStatus(baseUrl, auth, options) {
252
285
  const queryParams = new URLSearchParams();
253
286
  if (options.uploadId) {
254
287
  queryParams.append('uploadId', options.uploadId);
@@ -259,13 +292,13 @@ exports.ApiGateway = {
259
292
  try {
260
293
  const response = await fetch(`${baseUrl}/uploads/status?${queryParams}`, {
261
294
  headers: {
262
- 'x-app-api-key': apiKey,
295
+ ...auth.headers,
263
296
  },
264
297
  });
265
298
  if (!response.ok) {
266
299
  await this.handleApiError(response, 'Failed to get upload status');
267
300
  }
268
- return response.json();
301
+ return await parseJsonResponse(response, 'Failed to get upload status');
269
302
  }
270
303
  catch (error) {
271
304
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -274,7 +307,7 @@ exports.ApiGateway = {
274
307
  throw error;
275
308
  }
276
309
  },
277
- async listUploads(baseUrl, apiKey, options = {}) {
310
+ async listUploads(baseUrl, auth, options = {}) {
278
311
  const queryParams = new URLSearchParams();
279
312
  if (options.name) {
280
313
  queryParams.append('name', options.name);
@@ -295,13 +328,13 @@ exports.ApiGateway = {
295
328
  try {
296
329
  const response = await fetch(url, {
297
330
  headers: {
298
- 'x-app-api-key': apiKey,
331
+ ...auth.headers,
299
332
  },
300
333
  });
301
334
  if (!response.ok) {
302
335
  await this.handleApiError(response, 'Failed to list uploads');
303
336
  }
304
- return response.json();
337
+ return await parseJsonResponse(response, 'Failed to list uploads');
305
338
  }
306
339
  catch (error) {
307
340
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -310,19 +343,19 @@ exports.ApiGateway = {
310
343
  throw error;
311
344
  }
312
345
  },
313
- async uploadFlow(baseUrl, apiKey, testFormData) {
346
+ async uploadFlow(baseUrl, auth, testFormData) {
314
347
  try {
315
348
  const res = await fetch(`${baseUrl}/uploads/flow`, {
316
349
  body: testFormData,
317
350
  headers: {
318
- 'x-app-api-key': apiKey,
351
+ ...auth.headers,
319
352
  },
320
353
  method: 'POST',
321
354
  });
322
355
  if (!res.ok) {
323
356
  await this.handleApiError(res, 'Failed to upload test flows');
324
357
  }
325
- return res.json();
358
+ return await parseJsonResponse(res, 'Failed to upload test flows');
326
359
  }
327
360
  catch (error) {
328
361
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -334,13 +367,13 @@ exports.ApiGateway = {
334
367
  /**
335
368
  * Generic report download method that handles both junit and allure reports
336
369
  * @param baseUrl - API base URL
337
- * @param apiKey - API key for authentication
370
+ * @param auth - AuthContext (API key or Bearer session) for authentication
338
371
  * @param uploadId - Upload ID to download report for
339
372
  * @param reportType - Type of report to download ('junit' or 'allure')
340
373
  * @param reportPath - Optional custom path for the downloaded report
341
374
  * @returns Promise that resolves when download is complete
342
375
  */
343
- async downloadReportGeneric(baseUrl, apiKey, uploadId, reportType, reportPath) {
376
+ async downloadReportGeneric(baseUrl, auth, uploadId, reportType, reportPath) {
344
377
  // Define endpoint and default filename mappings
345
378
  const config = {
346
379
  junit: {
@@ -369,7 +402,7 @@ exports.ApiGateway = {
369
402
  // Make the download request
370
403
  const res = await fetch(url, {
371
404
  headers: {
372
- 'x-app-api-key': apiKey,
405
+ ...auth.headers,
373
406
  },
374
407
  method: 'GET',
375
408
  });
@@ -380,38 +413,145 @@ exports.ApiGateway = {
380
413
  }
381
414
  throw new Error(`${errorPrefix}: ${res.status} ${errorText}`);
382
415
  }
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
- }
416
+ await this.streamResponseToFile(res, finalReportPath, errorPrefix);
417
+ }
418
+ catch (error) {
419
+ if (error instanceof TypeError && error.message === 'fetch failed') {
420
+ throw this.enhanceFetchError(error, url);
421
+ }
422
+ throw error;
423
+ }
424
+ },
425
+ async startLiveSession(baseUrl, auth, params) {
426
+ try {
427
+ const res = await fetch(`${baseUrl}/live`, {
428
+ body: JSON.stringify({
429
+ binaryUploadId: params.binaryUploadId,
430
+ deviceLocale: params.deviceLocale,
431
+ platform: params.platform,
432
+ androidDevice: params.androidDevice,
433
+ androidApiLevel: params.androidApiLevel,
434
+ }),
435
+ headers: {
436
+ 'content-type': 'application/json',
437
+ ...auth.headers,
438
+ },
439
+ method: 'POST',
440
+ });
441
+ if (!res.ok) {
442
+ await this.handleApiError(res, 'Failed to start live session');
443
+ }
444
+ return await parseJsonResponse(res, 'Failed to start live session');
445
+ }
446
+ catch (error) {
447
+ if (error instanceof TypeError && error.message === 'fetch failed') {
448
+ throw this.enhanceFetchError(error, `${baseUrl}/live`);
449
+ }
450
+ throw error;
451
+ }
452
+ },
453
+ async installLiveBinary(baseUrl, auth, sessionName, binaryUploadId) {
454
+ const url = `${baseUrl}/live/${sessionName}/install`;
455
+ try {
456
+ const res = await fetch(url, {
457
+ body: JSON.stringify({ binaryUploadId }),
458
+ headers: {
459
+ 'content-type': 'application/json',
460
+ ...auth.headers,
461
+ },
462
+ method: 'POST',
463
+ });
464
+ if (!res.ok) {
465
+ await this.handleApiError(res, 'Failed to install binary');
466
+ }
467
+ }
468
+ catch (error) {
469
+ if (error instanceof TypeError && error.message === 'fetch failed') {
470
+ throw this.enhanceFetchError(error, url);
471
+ }
472
+ throw error;
473
+ }
474
+ },
475
+ async execLiveYaml(baseUrl, auth, sessionName, yaml, opts = {}) {
476
+ const url = `${baseUrl}/live/${sessionName}/exec`;
477
+ try {
478
+ const res = await fetch(url, {
479
+ body: JSON.stringify({ yaml, async: opts.async }),
480
+ headers: {
481
+ 'content-type': 'application/json',
482
+ ...auth.headers,
483
+ },
484
+ method: 'POST',
485
+ });
486
+ if (!res.ok) {
487
+ await this.handleApiError(res, 'Failed to execute test');
488
+ }
489
+ return await parseJsonResponse(res, 'Failed to execute test');
490
+ }
491
+ catch (error) {
492
+ if (error instanceof TypeError && error.message === 'fetch failed') {
493
+ throw this.enhanceFetchError(error, url);
494
+ }
495
+ throw error;
496
+ }
497
+ },
498
+ async getLiveCommand(baseUrl, auth, sessionName, commandId) {
499
+ const url = `${baseUrl}/live/${sessionName}/commands/${commandId}`;
500
+ try {
501
+ const res = await fetch(url, { headers: { ...auth.headers }, method: 'GET' });
502
+ if (!res.ok) {
503
+ await this.handleApiError(res, 'Failed to get command status');
504
+ }
505
+ return await parseJsonResponse(res, 'Failed to get command status');
506
+ }
507
+ catch (error) {
508
+ if (error instanceof TypeError && error.message === 'fetch failed') {
509
+ throw this.enhanceFetchError(error, url);
510
+ }
511
+ throw error;
512
+ }
513
+ },
514
+ async keepaliveLiveSession(baseUrl, auth, sessionName) {
515
+ const url = `${baseUrl}/live/${sessionName}/keepalive`;
516
+ try {
517
+ const res = await fetch(url, { headers: { ...auth.headers }, method: 'POST' });
518
+ // A keepalive failure shouldn't kill a long-running poll; swallow non-OK.
519
+ if (!res.ok)
520
+ return;
521
+ }
522
+ catch {
523
+ // best effort
524
+ }
525
+ },
526
+ async stopLiveSession(baseUrl, auth, sessionName) {
527
+ const url = `${baseUrl}/live/${sessionName}`;
528
+ try {
529
+ const res = await fetch(url, {
530
+ headers: { ...auth.headers },
531
+ method: 'DELETE',
532
+ });
533
+ if (!res.ok) {
534
+ await this.handleApiError(res, 'Failed to stop session');
535
+ }
536
+ }
537
+ catch (error) {
538
+ if (error instanceof TypeError && error.message === 'fetch failed') {
539
+ throw this.enhanceFetchError(error, url);
540
+ }
541
+ throw error;
542
+ }
543
+ },
544
+ async getLiveSession(baseUrl, auth, sessionName) {
545
+ const url = `${baseUrl}/live/${sessionName}`;
546
+ try {
547
+ const res = await fetch(url, {
548
+ headers: { ...auth.headers },
549
+ method: 'GET',
550
+ });
551
+ if (!res.ok) {
552
+ await this.handleApiError(res, 'Failed to get session status');
410
553
  }
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));
554
+ return await parseJsonResponse(res, 'Failed to get session status');
415
555
  }
416
556
  catch (error) {
417
557
  if (error instanceof TypeError && error.message === 'fetch failed') {
@@ -0,0 +1,13 @@
1
+ import type { StoredSession } from '../utils/config-store';
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
+ };
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CliAuthGateway = void 0;
4
+ /**
5
+ * Supabase session operations for the CLI: refresh an expired access token
6
+ * and sign-out (best-effort revocation on the Supabase side).
7
+ *
8
+ * Does not talk to the dcd API — the dcd-side session exchange lives in the
9
+ * login command's loopback flow, where the frontend POSTs ciphertext back.
10
+ */
11
+ const supabase_js_1 = require("@supabase/supabase-js");
12
+ function client(supabaseUrl, supabaseAnonKey) {
13
+ return (0, supabase_js_1.createClient)(supabaseUrl, supabaseAnonKey, {
14
+ auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
15
+ });
16
+ }
17
+ exports.CliAuthGateway = {
18
+ async refresh(supabaseUrl, supabaseAnonKey, session) {
19
+ const sb = client(supabaseUrl, supabaseAnonKey);
20
+ const { data, error } = await sb.auth.refreshSession({
21
+ refresh_token: session.refresh_token,
22
+ });
23
+ if (error || !data.session || !data.user) {
24
+ throw new Error(`Failed to refresh session: ${error?.message ?? 'no session returned'}. ` +
25
+ `Run \`dcd login\` again.`);
26
+ }
27
+ const s = data.session;
28
+ const u = data.user;
29
+ if (!s.expires_at)
30
+ throw new Error('Refreshed session is missing expires_at');
31
+ return {
32
+ access_token: s.access_token,
33
+ refresh_token: s.refresh_token,
34
+ expires_at: s.expires_at,
35
+ user_email: u.email ?? session.user_email,
36
+ user_id: u.id,
37
+ };
38
+ },
39
+ async signOut(supabaseUrl, supabaseAnonKey, session) {
40
+ // `scope: 'local'` clears this client's SDK state only — no server call,
41
+ // no revocation. supabase-js defaults to `scope: 'global'`, which revokes
42
+ // EVERY session for the user (browser, mobile, other CLIs), so
43
+ // `dcd logout` would kick the user out of the web app. We don't want
44
+ // that — logging out locally should be local.
45
+ const sb = client(supabaseUrl, supabaseAnonKey);
46
+ try {
47
+ await sb.auth.setSession({
48
+ access_token: session.access_token,
49
+ refresh_token: session.refresh_token,
50
+ });
51
+ await sb.auth.signOut({ scope: 'local' });
52
+ }
53
+ catch {
54
+ // Best effort — the local config will be wiped regardless.
55
+ }
56
+ },
57
+ };
@@ -1,25 +1,25 @@
1
+ import { type DcdEnvName } from '../config/environments';
2
+ /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */
3
+ export interface ResumableUploadSource {
4
+ contentType: string;
5
+ diskPath: string;
6
+ name: string;
7
+ size: number;
8
+ }
1
9
  export declare class SupabaseGateway {
2
- private static SB;
3
- static getSupabaseKeys(env: 'dev' | 'prod'): {
4
- SUPABASE_PUBLIC_KEY: string;
5
- SUPABASE_URL: string;
6
- } | {
7
- SUPABASE_PUBLIC_KEY: string;
8
- SUPABASE_URL: string;
9
- };
10
10
  /**
11
11
  * Upload to Supabase using resumable uploads (TUS protocol)
12
12
  * Uploads to staging location (uploads/{id}/) using anon key
13
13
  * File is later moved to final location by API after finalization
14
14
  * @param env - Environment (dev or prod)
15
15
  * @param path - Staging storage path (uploads/{id}/file.ext)
16
- * @param file - File to upload
16
+ * @param source - Upload source descriptor; the file is streamed from disk
17
17
  * @param debug - Enable debug logging
18
18
  * @param onProgress - Optional callback for upload progress (bytesUploaded, bytesTotal)
19
19
  * @returns Promise that resolves when upload completes
20
20
  */
21
- static uploadResumable(env: 'dev' | 'prod', path: string, file: File, debug?: boolean, onProgress?: (bytesUploaded: number, bytesTotal: number) => void): Promise<void>;
22
- static uploadToSignedUrl(env: 'dev' | 'prod', path: string, token: string, file: File, debug?: boolean): Promise<void>;
21
+ static uploadResumable(env: DcdEnvName, path: string, source: ResumableUploadSource, debug?: boolean, onProgress?: (bytesUploaded: number, bytesTotal: number) => void): Promise<void>;
22
+ static uploadToSignedUrl(env: DcdEnvName, path: string, token: string, file: File, debug?: boolean): Promise<void>;
23
23
  /**
24
24
  * Logs network error details for debugging
25
25
  * @param error - Error object to analyze for network-related issues