@devicecloud.dev/dcd 4.4.8 → 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 -282
  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
@@ -13,12 +13,12 @@ class ReportDownloadService {
13
13
  * @returns Promise that resolves when download is complete
14
14
  */
15
15
  async downloadArtifacts(options) {
16
- const { apiUrl, apiKey, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
16
+ const { apiUrl, auth, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
17
17
  try {
18
18
  if (debug && logger) {
19
19
  logger(`[DEBUG] Downloading artifacts: ${downloadType}`);
20
20
  }
21
- await api_gateway_1.ApiGateway.downloadArtifactsZip(apiUrl, apiKey, uploadId, downloadType, artifactsPath);
21
+ await api_gateway_1.ApiGateway.downloadArtifactsZip(apiUrl, auth, uploadId, downloadType, artifactsPath);
22
22
  if (logger) {
23
23
  logger('\n');
24
24
  logger(`Test artifacts have been downloaded to ${artifactsPath}`);
@@ -28,9 +28,7 @@ class ReportDownloadService {
28
28
  if (debug && logger) {
29
29
  logger(`[DEBUG] Error downloading artifacts: ${error}`);
30
30
  }
31
- if (warnLogger) {
32
- warnLogger('Failed to download artifacts');
33
- }
31
+ this.warnDownloadFailure(warnLogger, 'artifacts', 'No artifacts found for this upload. Make sure your tests generated results.', error);
34
32
  }
35
33
  }
36
34
  /**
@@ -81,12 +79,12 @@ class ReportDownloadService {
81
79
  * @returns Promise that resolves when download is complete
82
80
  */
83
81
  async downloadReport(type, filePath, options) {
84
- const { apiUrl, apiKey, uploadId, debug = false, logger, warnLogger } = options;
82
+ const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options;
85
83
  try {
86
84
  if (debug && logger) {
87
85
  logger(`[DEBUG] Downloading ${type.toUpperCase()} report`);
88
86
  }
89
- await api_gateway_1.ApiGateway.downloadReportGeneric(apiUrl, apiKey, uploadId, type, filePath);
87
+ await api_gateway_1.ApiGateway.downloadReportGeneric(apiUrl, auth, uploadId, type, filePath);
90
88
  if (logger) {
91
89
  logger(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
92
90
  }
@@ -95,19 +93,32 @@ class ReportDownloadService {
95
93
  if (debug && logger) {
96
94
  logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`);
97
95
  }
98
- const errorMessage = error instanceof Error ? error.message : String(error);
99
- if (warnLogger) {
100
- warnLogger(`Failed to download ${type.toUpperCase()} report: ${errorMessage}`);
101
- if (errorMessage.includes('404')) {
102
- warnLogger(`No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`);
103
- }
104
- else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) {
105
- warnLogger('Permission denied. Check write permissions for the current directory.');
106
- }
107
- else if (errorMessage.includes('ENOENT')) {
108
- warnLogger('Directory does not exist. Make sure you have write access to the current directory.');
109
- }
110
- }
96
+ this.warnDownloadFailure(warnLogger, `${type.toUpperCase()} report`, `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error);
97
+ }
98
+ }
99
+ /**
100
+ * Warn about a failed download with the underlying cause plus hints for
101
+ * common error classes (missing results, permissions, bad paths)
102
+ * @param warnLogger Warning logger, if configured
103
+ * @param subject What was being downloaded, e.g. 'artifacts' or 'JUNIT report'
104
+ * @param notFoundHint Message to show when the error looks like a 404
105
+ * @param error The error that occurred
106
+ * @returns void
107
+ */
108
+ warnDownloadFailure(warnLogger, subject, notFoundHint, error) {
109
+ if (!warnLogger) {
110
+ return;
111
+ }
112
+ const errorMessage = error instanceof Error ? error.message : String(error);
113
+ warnLogger(`Failed to download ${subject}: ${errorMessage}`);
114
+ if (errorMessage.includes('404')) {
115
+ warnLogger(notFoundHint);
116
+ }
117
+ else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) {
118
+ warnLogger('Permission denied. Check write permissions for the current directory.');
119
+ }
120
+ else if (errorMessage.includes('ENOENT')) {
121
+ warnLogger('Directory does not exist. Make sure you have write access to the current directory.');
111
122
  }
112
123
  }
113
124
  }
@@ -1,5 +1,4 @@
1
- import { paths } from '../types/generated/schema.types';
2
- type TestResult = paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json']['results'][number];
1
+ import type { AuthContext } from '../types/domain/auth.types';
3
2
  /**
4
3
  * Custom error for run failures that includes the polling result
5
4
  */
@@ -8,7 +7,7 @@ export declare class RunFailedError extends Error {
8
7
  constructor(result: PollingResult);
9
8
  }
10
9
  export interface PollingOptions {
11
- apiKey: string;
10
+ auth: AuthContext;
12
11
  apiUrl: string;
13
12
  consoleUrl: string;
14
13
  debug?: boolean;
@@ -48,21 +47,22 @@ export interface PollingResult {
48
47
  export declare class ResultsPollingService {
49
48
  private readonly MAX_SEQUENTIAL_FAILURES;
50
49
  private readonly POLL_INTERVAL_MS;
50
+ private readonly MAX_ERROR_BACKOFF_MS;
51
51
  /**
52
52
  * Poll for test results until all tests complete
53
- * @param results Initial test results from submission
54
53
  * @param options Polling configuration
55
54
  * @param testMetadata Optional metadata map for each test (flowName, tags)
56
55
  * @returns Promise that resolves with final test results or rejects if tests fail
57
56
  */
58
- pollUntilComplete(results: TestResult[], options: PollingOptions, testMetadata?: Record<string, TestMetadata>): Promise<PollingResult>;
57
+ pollUntilComplete(options: PollingOptions, testMetadata?: Record<string, TestMetadata>): Promise<PollingResult>;
58
+ private pollLoop;
59
59
  private buildPollingResult;
60
60
  private calculateStatusSummary;
61
61
  private displayFinalResults;
62
62
  /**
63
63
  * Fetch results from API and log debug information
64
64
  * @param apiUrl API base URL
65
- * @param apiKey API authentication key
65
+ * @param auth AuthContext carrying request headers
66
66
  * @param uploadId Upload ID to fetch results for
67
67
  * @param debug Whether debug logging is enabled
68
68
  * @param logger Optional logger function
@@ -93,4 +93,3 @@ export declare class ResultsPollingService {
93
93
  private sleep;
94
94
  private updateDisplayStatus;
95
95
  }
96
- export {};
@@ -1,12 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResultsPollingService = exports.RunFailedError = void 0;
4
- const core_1 = require("@oclif/core");
5
- const cli_ux_1 = require("@oclif/core/lib/cli-ux");
6
4
  const path = require("node:path");
7
5
  const api_gateway_1 = require("../gateways/api-gateway");
8
6
  const methods_1 = require("../methods");
9
7
  const connectivity_1 = require("../utils/connectivity");
8
+ const progress_1 = require("../utils/progress");
10
9
  const styling_1 = require("../utils/styling");
11
10
  /**
12
11
  * Custom error for run failures that includes the polling result
@@ -24,17 +23,36 @@ exports.RunFailedError = RunFailedError;
24
23
  * Service for polling test results from the API
25
24
  */
26
25
  class ResultsPollingService {
27
- MAX_SEQUENTIAL_FAILURES = 10;
26
+ // The run keeps executing in the cloud regardless of whether the CLI can
27
+ // poll, so tolerate a long stretch of transient API/network blips (~5 min at
28
+ // the 10s base interval) before giving up. Losing a run to a brief hiccup is
29
+ // far more costly than waiting a bit longer.
30
+ MAX_SEQUENTIAL_FAILURES = 30;
28
31
  POLL_INTERVAL_MS = 10_000;
32
+ // Cap for the backoff applied between failed polls.
33
+ MAX_ERROR_BACKOFF_MS = 30_000;
29
34
  /**
30
35
  * Poll for test results until all tests complete
31
- * @param results Initial test results from submission
32
36
  * @param options Polling configuration
33
37
  * @param testMetadata Optional metadata map for each test (flowName, tags)
34
38
  * @returns Promise that resolves with final test results or rejects if tests fail
35
39
  */
36
- async pollUntilComplete(results, options, testMetadata) {
37
- const { apiUrl, apiKey, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
40
+ async pollUntilComplete(options, testMetadata) {
41
+ try {
42
+ return await this.pollLoop(options, testMetadata);
43
+ }
44
+ catch (error) {
45
+ // RunFailedError is a completed run — the spinner was already stopped by
46
+ // displayFinalResults. Anything else aborts mid-poll with the spinner
47
+ // still live, which would corrupt the terminal under the error output.
48
+ if (!options.json && !(error instanceof RunFailedError)) {
49
+ progress_1.ux.action.stop(styling_1.colors.error('failed'));
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+ async pollLoop(options, testMetadata) {
55
+ const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
38
56
  this.initializePollingDisplay(json, logger);
39
57
  let sequentialPollFailures = 0;
40
58
  let previousSummary = '';
@@ -45,7 +63,7 @@ class ResultsPollingService {
45
63
  // eslint-disable-next-line no-constant-condition
46
64
  while (true) {
47
65
  try {
48
- const updatedResults = await this.fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger);
66
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
49
67
  const { summary } = this.calculateStatusSummary(updatedResults);
50
68
  previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
51
69
  const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
@@ -71,9 +89,10 @@ class ResultsPollingService {
71
89
  }
72
90
  sequentialPollFailures++;
73
91
  // Handle polling errors (network issues, etc.)
74
- await this.handlePollingError(error, sequentialPollFailures, debug, logger);
75
- // Wait before retrying after an error
76
- await this.sleep(this.POLL_INTERVAL_MS);
92
+ await this.handlePollingError(error, sequentialPollFailures, debug, logger, uploadId);
93
+ // Back off (capped) before retrying so a flaky API gets some breathing
94
+ // room instead of being hammered every 10s.
95
+ await this.sleep(Math.min(this.POLL_INTERVAL_MS * sequentialPollFailures, this.MAX_ERROR_BACKOFF_MS));
77
96
  }
78
97
  }
79
98
  }
@@ -81,11 +100,13 @@ class ResultsPollingService {
81
100
  const resultsWithoutEarlierTries = this.filterLatestResults(results);
82
101
  return {
83
102
  consoleUrl,
84
- status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED')
85
- ? 'FAILED'
86
- : 'PASSED',
103
+ // Anything other than an explicit pass (CANCELLED, ERROR, a status we
104
+ // don't know about yet) must fail the run — this gates CI exit codes.
105
+ status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
106
+ ? 'PASSED'
107
+ : 'FAILED',
87
108
  tests: resultsWithoutEarlierTries.map((r) => ({
88
- durationSeconds: r.duration_seconds,
109
+ durationSeconds: r.duration_seconds ?? null,
89
110
  failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
90
111
  fileName: r.test_file_name,
91
112
  flowName: testMetadata?.[r.test_file_name]?.flowName || path.parse(r.test_file_name).name,
@@ -123,12 +144,12 @@ class ResultsPollingService {
123
144
  if (json) {
124
145
  return;
125
146
  }
126
- core_1.ux.action.stop(styling_1.colors.success('completed'));
147
+ progress_1.ux.action.stop(styling_1.colors.success('completed'));
127
148
  if (logger) {
128
149
  logger('\n');
129
150
  }
130
151
  const hasFailedTests = results.some((result) => result.status === 'FAILED');
131
- (0, cli_ux_1.table)(results, {
152
+ (0, styling_1.table)(results, {
132
153
  duration: {
133
154
  get(row) {
134
155
  return row.duration_seconds
@@ -189,18 +210,19 @@ class ResultsPollingService {
189
210
  /**
190
211
  * Fetch results from API and log debug information
191
212
  * @param apiUrl API base URL
192
- * @param apiKey API authentication key
213
+ * @param auth AuthContext carrying request headers
193
214
  * @param uploadId Upload ID to fetch results for
194
215
  * @param debug Whether debug logging is enabled
195
216
  * @param logger Optional logger function
196
217
  * @returns Promise resolving to test results
197
218
  */
198
- async fetchAndLogResults(apiUrl, apiKey, uploadId, debug, logger) {
219
+ async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
199
220
  if (debug && logger) {
200
221
  logger(`[DEBUG] Polling for results: ${uploadId}`);
201
222
  }
202
- const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, apiKey, uploadId);
203
- if (!updatedResults) {
223
+ const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
224
+ // An empty array would otherwise read as "all complete, all passed".
225
+ if (!updatedResults || updatedResults.length === 0) {
204
226
  throw new Error('no results');
205
227
  }
206
228
  if (debug && logger) {
@@ -212,11 +234,31 @@ class ResultsPollingService {
212
234
  return updatedResults;
213
235
  }
214
236
  filterLatestResults(results) {
215
- return results.filter((result) => {
216
- const originalTryId = result.retry_of || result.id;
217
- const tries = results.filter((r) => r.retry_of === originalTryId || r.id === originalTryId);
218
- return result.id === Math.max(...tries.map((t) => t.id));
219
- });
237
+ // Resolve each row to the root of its retry chain (a retry's `retry_of`
238
+ // may point at the previous retry rather than the original attempt), then
239
+ // keep only the newest row per root.
240
+ const byId = new Map(results.map((result) => [result.id, result]));
241
+ const rootIdOf = (result) => {
242
+ let current = result;
243
+ const seen = new Set([current.id]);
244
+ while (current.retry_of) {
245
+ const parent = byId.get(current.retry_of);
246
+ if (!parent || seen.has(parent.id))
247
+ return current.retry_of;
248
+ seen.add(parent.id);
249
+ current = parent;
250
+ }
251
+ return current.id;
252
+ };
253
+ const latestByRoot = new Map();
254
+ for (const result of results) {
255
+ const rootId = rootIdOf(result);
256
+ const existing = latestByRoot.get(rootId);
257
+ if (!existing || result.id > existing.id) {
258
+ latestByRoot.set(rootId, result);
259
+ }
260
+ }
261
+ return results.filter((result) => latestByRoot.get(rootIdOf(result)) === result);
220
262
  }
221
263
  /**
222
264
  * Handle completed tests and return final result
@@ -242,12 +284,17 @@ class ResultsPollingService {
242
284
  }
243
285
  return output;
244
286
  }
245
- async handlePollingError(error, sequentialPollFailures, debug, logger) {
287
+ async handlePollingError(error, sequentialPollFailures, debug, logger, uploadId) {
288
+ // The run is unaffected by our inability to poll — always point the user at
289
+ // how to reconnect to it rather than leaving them thinking it died.
290
+ const resumeHint = uploadId
291
+ ? `\n\nThe test is still running in the cloud. Reconnect with:\n dcd status --upload-id ${uploadId}`
292
+ : '';
246
293
  if (debug && logger) {
247
294
  logger(`[DEBUG] Error polling for results: ${error}`);
248
295
  logger(`[DEBUG] Sequential poll failures: ${sequentialPollFailures}`);
249
296
  }
250
- if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) {
297
+ if (sequentialPollFailures >= this.MAX_SEQUENTIAL_FAILURES) {
251
298
  if (debug && logger) {
252
299
  logger('[DEBUG] Checking internet connectivity...');
253
300
  }
@@ -267,9 +314,9 @@ class ResultsPollingService {
267
314
  const endpointDetails = connectivityCheck.endpointResults
268
315
  .map((r) => ` - ${r.endpoint}: ${r.error} (${r.latencyMs}ms)`)
269
316
  .join('\n');
270
- throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.`);
317
+ throw new Error(`Unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.${resumeHint}`);
271
318
  }
272
- throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts`);
319
+ throw new Error(`unable to fetch results after ${this.MAX_SEQUENTIAL_FAILURES} attempts${resumeHint}`);
273
320
  }
274
321
  if (logger) {
275
322
  logger('unable to fetch results, trying again...');
@@ -283,7 +330,7 @@ class ResultsPollingService {
283
330
  */
284
331
  initializePollingDisplay(json, logger) {
285
332
  if (!json) {
286
- core_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
333
+ progress_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
287
334
  stdout: true,
288
335
  });
289
336
  if (logger) {
@@ -307,12 +354,12 @@ class ResultsPollingService {
307
354
  }
308
355
  if (quiet) {
309
356
  if (summary !== previousSummary) {
310
- core_1.ux.action.status = summary;
357
+ progress_1.ux.action.status = summary;
311
358
  return summary;
312
359
  }
313
360
  }
314
361
  else {
315
- core_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
362
+ progress_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
316
363
  for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
317
364
  const statusFormatted = status.toUpperCase() === 'PASSED'
318
365
  ? styling_1.colors.success(status.padEnd(10, ' '))
@@ -324,7 +371,7 @@ class ResultsPollingService {
324
371
  ? styling_1.colors.dim(status.padEnd(10, ' '))
325
372
  : styling_1.colors.warning(status.padEnd(10, ' '));
326
373
  const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
327
- core_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
374
+ progress_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
328
375
  }
329
376
  }
330
377
  return previousSummary;
@@ -0,0 +1,40 @@
1
+ import type { AuthContext } from '../types/domain/auth.types';
2
+ export type TelemetryLevel = 'log' | 'info' | 'warn' | 'error';
3
+ declare class Telemetry {
4
+ private buffer;
5
+ private config;
6
+ private command;
7
+ private sessionId;
8
+ private startedAt;
9
+ private readonly disabled;
10
+ private readonly release;
11
+ /**
12
+ * Called once per invocation by `resolveAuth` after the credential check
13
+ * succeeds. Before this is called, lifecycle events are buffered but cannot
14
+ * be sent. Commands that never reach `resolveAuth` (`--help`, `--version`,
15
+ * `dcd login` before sign-in completes) will skip telemetry entirely — by
16
+ * design, since those flows have no identity to attach.
17
+ */
18
+ configure(opts: {
19
+ auth: AuthContext;
20
+ apiUrl?: string;
21
+ }): void;
22
+ recordCommandStart(): void;
23
+ recordCommandSuccess(): void;
24
+ recordCommandFailure(opts: {
25
+ error: Error | string;
26
+ exitCode: number;
27
+ }): void;
28
+ private enqueue;
29
+ flush(): Promise<void>;
30
+ /**
31
+ * Synchronous flush via `curl`. Used right before `process.exit` (which
32
+ * bypasses `beforeExit`, so async `fetch` would be killed mid-flight).
33
+ * Node has no built-in sync HTTP and `curl` ships with macOS, Linux, and
34
+ * Windows ≥ 1803 — that's the supported surface for the CLI.
35
+ */
36
+ flushSync(): void;
37
+ private buildMeta;
38
+ }
39
+ export declare const telemetry: Telemetry;
40
+ export {};
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.telemetry = void 0;
4
+ /**
5
+ * Ships CLI lifecycle + error events to Axiom via the API's `/cli/logs`
6
+ * proxy (forwards to `cli-dev` / `cli-prod`). The proxy authenticates with
7
+ * the same `auth.headers` every other gateway uses (`x-app-api-key` or
8
+ * `Authorization: Bearer ...` + `x-dcd-org`), so we never need the Axiom
9
+ * token client-side.
10
+ *
11
+ * Two flush paths:
12
+ * - `flush()` — async, used after `runMain` returns naturally
13
+ * - `flushSync()` — sync via `curl`, used inside `logger.error` right before
14
+ * `process.exit` (no other way to send HTTP after exit is called)
15
+ *
16
+ * Opt out: `DCD_TELEMETRY_DISABLED=1` in the environment.
17
+ */
18
+ const node_child_process_1 = require("node:child_process");
19
+ const node_crypto_1 = require("node:crypto");
20
+ const node_fs_1 = require("node:fs");
21
+ const node_os_1 = require("node:os");
22
+ const node_path_1 = require("node:path");
23
+ const cli_1 = require("../utils/cli");
24
+ const DEFAULT_API_URL = 'https://api.devicecloud.dev';
25
+ class Telemetry {
26
+ buffer = [];
27
+ config = null;
28
+ command = inferCommandFromArgv();
29
+ sessionId = (0, node_crypto_1.randomUUID)();
30
+ startedAt = Date.now();
31
+ disabled = !!process.env.DCD_TELEMETRY_DISABLED;
32
+ release = (0, cli_1.getCliVersion)();
33
+ /**
34
+ * Called once per invocation by `resolveAuth` after the credential check
35
+ * succeeds. Before this is called, lifecycle events are buffered but cannot
36
+ * be sent. Commands that never reach `resolveAuth` (`--help`, `--version`,
37
+ * `dcd login` before sign-in completes) will skip telemetry entirely — by
38
+ * design, since those flows have no identity to attach.
39
+ */
40
+ configure(opts) {
41
+ if (this.disabled)
42
+ return;
43
+ this.config = {
44
+ apiUrl: opts.apiUrl ?? inferApiUrlFromArgv(),
45
+ auth: opts.auth,
46
+ command: this.command,
47
+ };
48
+ }
49
+ recordCommandStart() {
50
+ this.startedAt = Date.now();
51
+ this.enqueue('info', 'cli.lifecycle', 'command started', {
52
+ argv: scrubArgv(process.argv.slice(2)),
53
+ });
54
+ }
55
+ recordCommandSuccess() {
56
+ this.enqueue('info', 'cli.lifecycle', 'command completed', {
57
+ duration_ms: Date.now() - this.startedAt,
58
+ exit_code: 0,
59
+ });
60
+ }
61
+ recordCommandFailure(opts) {
62
+ const message = opts.error instanceof Error ? opts.error.message : String(opts.error);
63
+ this.enqueue('error', 'cli.lifecycle', 'command failed', {
64
+ duration_ms: Date.now() - this.startedAt,
65
+ exit_code: opts.exitCode,
66
+ error_message: message,
67
+ error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
68
+ error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
69
+ });
70
+ }
71
+ enqueue(level, context, message, extra) {
72
+ if (this.disabled)
73
+ return;
74
+ this.buffer.push({
75
+ timestamp: new Date().toISOString(),
76
+ level,
77
+ context,
78
+ message,
79
+ extra,
80
+ });
81
+ }
82
+ async flush() {
83
+ if (this.disabled || !this.config || this.buffer.length === 0)
84
+ return;
85
+ const events = this.buffer.splice(0, this.buffer.length);
86
+ const body = JSON.stringify({ events, meta: this.buildMeta() });
87
+ try {
88
+ await fetch(`${this.config.apiUrl}/cli/logs`, {
89
+ method: 'POST',
90
+ headers: {
91
+ 'content-type': 'application/json',
92
+ ...this.config.auth.headers,
93
+ },
94
+ body,
95
+ });
96
+ }
97
+ catch {
98
+ // Telemetry failures must never surface — silently drop.
99
+ }
100
+ }
101
+ /**
102
+ * Synchronous flush via `curl`. Used right before `process.exit` (which
103
+ * bypasses `beforeExit`, so async `fetch` would be killed mid-flight).
104
+ * Node has no built-in sync HTTP and `curl` ships with macOS, Linux, and
105
+ * Windows ≥ 1803 — that's the supported surface for the CLI.
106
+ */
107
+ flushSync() {
108
+ if (this.disabled || !this.config || this.buffer.length === 0)
109
+ return;
110
+ const events = this.buffer.splice(0, this.buffer.length);
111
+ const body = JSON.stringify({ events, meta: this.buildMeta() });
112
+ // Headers carry the API key / Bearer token, so they must not appear in
113
+ // curl's argv (world-readable via ps//proc while curl runs). They go in a
114
+ // 0600 config file inside a fresh 0700 temp dir instead; stdin carries the
115
+ // body, so it can't double as the config channel.
116
+ let configDir;
117
+ try {
118
+ configDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'dcd-telemetry-'));
119
+ const configPath = (0, node_path_1.join)(configDir, 'curl.cfg');
120
+ const headerLines = ['header = "content-type: application/json"'];
121
+ for (const [k, v] of Object.entries(this.config.auth.headers)) {
122
+ headerLines.push(`header = "${k}: ${v}"`);
123
+ }
124
+ (0, node_fs_1.writeFileSync)(configPath, headerLines.join('\n'), { mode: 0o600 });
125
+ (0, node_child_process_1.execFileSync)('curl', [
126
+ '-sS',
127
+ '-m',
128
+ '3',
129
+ '-X',
130
+ 'POST',
131
+ '-K',
132
+ configPath,
133
+ '--data-binary',
134
+ '@-',
135
+ `${this.config.apiUrl}/cli/logs`,
136
+ ], { input: body, stdio: ['pipe', 'ignore', 'ignore'] });
137
+ }
138
+ catch {
139
+ // Telemetry failures must never surface — silently drop.
140
+ }
141
+ finally {
142
+ if (configDir)
143
+ (0, node_fs_1.rmSync)(configDir, { recursive: true, force: true });
144
+ }
145
+ }
146
+ buildMeta() {
147
+ if (!this.config) {
148
+ throw new Error('telemetry not configured');
149
+ }
150
+ return {
151
+ release: this.release,
152
+ command: this.command,
153
+ sessionId: this.sessionId,
154
+ authMode: this.config.auth.mode,
155
+ installMethod: (0, cli_1.getInstallMethod)(),
156
+ nodeVersion: process.versions.node,
157
+ platform: process.platform,
158
+ arch: process.arch,
159
+ userEmail: this.config.auth.userEmail,
160
+ orgId: this.config.auth.orgId,
161
+ };
162
+ }
163
+ }
164
+ // Flags whose values are credential material (API keys, signed URLs) or
165
+ // user-provided env pairs that routinely carry test-account secrets. Their
166
+ // values must never reach the telemetry backend.
167
+ const SENSITIVE_FLAG_NAMES = new Set([
168
+ '--api-key',
169
+ '--apiKey',
170
+ '--moropo-v1-api-key',
171
+ '--app-url',
172
+ '--appUrl',
173
+ '-e',
174
+ '--env',
175
+ ]);
176
+ function scrubArgv(args) {
177
+ const scrubbed = [];
178
+ for (let i = 0; i < args.length; i++) {
179
+ const arg = args[i];
180
+ const eqIndex = arg.indexOf('=');
181
+ const flagName = eqIndex === -1 ? arg : arg.slice(0, eqIndex);
182
+ if (arg.startsWith('-') && SENSITIVE_FLAG_NAMES.has(flagName)) {
183
+ if (eqIndex === -1) {
184
+ scrubbed.push(arg);
185
+ if (i + 1 < args.length) {
186
+ scrubbed.push('<redacted>');
187
+ i++;
188
+ }
189
+ }
190
+ else {
191
+ scrubbed.push(`${flagName}=<redacted>`);
192
+ }
193
+ }
194
+ else {
195
+ scrubbed.push(arg);
196
+ }
197
+ }
198
+ return scrubbed;
199
+ }
200
+ // argv layout: node|tsx, script, command, ...flags. The first non-flag after
201
+ // the script is the subcommand. Falls back to 'help' for `--help`/`--version`
202
+ // invocations and to 'unknown' if we can't decide.
203
+ function inferCommandFromArgv() {
204
+ const args = process.argv.slice(2);
205
+ for (const arg of args) {
206
+ if (arg.startsWith('-'))
207
+ continue;
208
+ return arg;
209
+ }
210
+ if (args.some((a) => a === '--help' || a === '-h'))
211
+ return 'help';
212
+ if (args.some((a) => a === '--version' || a === '-v'))
213
+ return 'version';
214
+ return 'unknown';
215
+ }
216
+ const API_URL_FLAGS = ['--api-url', '--apiURL', '--apiUrl'];
217
+ function inferApiUrlFromArgv() {
218
+ const args = process.argv.slice(2);
219
+ for (let i = 0; i < args.length; i++) {
220
+ const arg = args[i];
221
+ for (const flag of API_URL_FLAGS) {
222
+ if (arg === flag && args[i + 1])
223
+ return args[i + 1];
224
+ if (arg.startsWith(`${flag}=`))
225
+ return arg.slice(flag.length + 1);
226
+ }
227
+ }
228
+ return DEFAULT_API_URL;
229
+ }
230
+ exports.telemetry = new Telemetry();
@@ -4,6 +4,7 @@ exports.TestSubmissionService = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const path = require("node:path");
6
6
  const methods_1 = require("../methods");
7
+ const paths_1 = require("../utils/paths");
7
8
  const mimeTypeLookupByExtension = {
8
9
  zip: 'application/zip',
9
10
  };
@@ -116,7 +117,7 @@ class TestSubmissionService {
116
117
  }
117
118
  }
118
119
  normalizeFilePath(filePath, commonRoot) {
119
- return filePath.replaceAll(commonRoot, '.').split(path.sep).join('/');
120
+ return (0, paths_1.toPortableRelativePath)(filePath, commonRoot);
120
121
  }
121
122
  normalizePathMap(map, commonRoot) {
122
123
  return Object.fromEntries(Object.entries(map).map(([key, value]) => [