@devicecloud.dev/dcd 5.0.0-beta.0 → 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 (101) hide show
  1. package/README.md +35 -0
  2. package/dist/commands/artifacts.d.ts +28 -28
  3. package/dist/commands/artifacts.js +20 -23
  4. package/dist/commands/cloud.d.ts +57 -57
  5. package/dist/commands/cloud.js +173 -186
  6. package/dist/commands/list.d.ts +22 -22
  7. package/dist/commands/list.js +36 -38
  8. package/dist/commands/live.js +134 -127
  9. package/dist/commands/login.d.ts +11 -11
  10. package/dist/commands/login.js +46 -44
  11. package/dist/commands/logout.js +16 -18
  12. package/dist/commands/status.d.ts +11 -11
  13. package/dist/commands/status.js +45 -43
  14. package/dist/commands/switch-org.d.ts +7 -7
  15. package/dist/commands/switch-org.js +19 -21
  16. package/dist/commands/upgrade.js +29 -31
  17. package/dist/commands/upload.d.ts +10 -10
  18. package/dist/commands/upload.js +42 -43
  19. package/dist/commands/whoami.js +17 -20
  20. package/dist/config/environments.js +6 -12
  21. package/dist/config/flags/api.flags.js +1 -4
  22. package/dist/config/flags/binary.flags.js +1 -4
  23. package/dist/config/flags/device.flags.js +6 -9
  24. package/dist/config/flags/environment.flags.js +1 -4
  25. package/dist/config/flags/execution.flags.js +1 -4
  26. package/dist/config/flags/github.flags.js +1 -4
  27. package/dist/config/flags/output.flags.js +1 -4
  28. package/dist/constants.js +15 -18
  29. package/dist/gateways/api-gateway.d.ts +31 -6
  30. package/dist/gateways/api-gateway.js +70 -16
  31. package/dist/gateways/cli-auth-gateway.d.ts +1 -1
  32. package/dist/gateways/cli-auth-gateway.js +3 -6
  33. package/dist/gateways/realtime-gateway.d.ts +32 -0
  34. package/dist/gateways/realtime-gateway.js +103 -0
  35. package/dist/gateways/supabase-gateway.d.ts +1 -1
  36. package/dist/gateways/supabase-gateway.js +10 -14
  37. package/dist/index.js +41 -38
  38. package/dist/mcp/context.d.ts +33 -0
  39. package/dist/mcp/context.js +33 -0
  40. package/dist/mcp/helpers.d.ts +16 -0
  41. package/dist/mcp/helpers.js +34 -0
  42. package/dist/mcp/index.d.ts +2 -0
  43. package/dist/mcp/index.js +24 -0
  44. package/dist/mcp/server.d.ts +7 -0
  45. package/dist/mcp/server.js +27 -0
  46. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  47. package/dist/mcp/tools/download-artifacts.js +84 -0
  48. package/dist/mcp/tools/get-status.d.ts +7 -0
  49. package/dist/mcp/tools/get-status.js +39 -0
  50. package/dist/mcp/tools/list-devices.d.ts +7 -0
  51. package/dist/mcp/tools/list-devices.js +27 -0
  52. package/dist/mcp/tools/list-runs.d.ts +3 -0
  53. package/dist/mcp/tools/list-runs.js +60 -0
  54. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  55. package/dist/mcp/tools/run-cloud-test.js +233 -0
  56. package/dist/methods.d.ts +32 -1
  57. package/dist/methods.js +125 -66
  58. package/dist/services/device-validation.service.d.ts +1 -1
  59. package/dist/services/device-validation.service.js +1 -5
  60. package/dist/services/execution-plan.service.js +14 -17
  61. package/dist/services/execution-plan.utils.js +15 -23
  62. package/dist/services/flow-paths.d.ts +17 -0
  63. package/dist/services/flow-paths.js +52 -0
  64. package/dist/services/metadata-extractor.service.js +22 -25
  65. package/dist/services/moropo.service.js +18 -20
  66. package/dist/services/report-download.service.d.ts +1 -1
  67. package/dist/services/report-download.service.js +5 -9
  68. package/dist/services/results-polling.service.d.ts +18 -3
  69. package/dist/services/results-polling.service.js +195 -108
  70. package/dist/services/telemetry.service.d.ts +10 -1
  71. package/dist/services/telemetry.service.js +40 -18
  72. package/dist/services/test-submission.service.d.ts +21 -4
  73. package/dist/services/test-submission.service.js +51 -34
  74. package/dist/services/version.service.d.ts +1 -1
  75. package/dist/services/version.service.js +1 -5
  76. package/dist/types/domain/auth.types.d.ts +8 -0
  77. package/dist/types/domain/auth.types.js +1 -2
  78. package/dist/types/domain/device.types.js +8 -11
  79. package/dist/types/domain/live.types.js +1 -2
  80. package/dist/types/generated/schema.types.js +1 -2
  81. package/dist/types/index.d.ts +2 -2
  82. package/dist/types/index.js +2 -18
  83. package/dist/types.js +1 -2
  84. package/dist/utils/auth.d.ts +1 -1
  85. package/dist/utils/auth.js +27 -28
  86. package/dist/utils/ci.d.ts +12 -0
  87. package/dist/utils/ci.js +39 -0
  88. package/dist/utils/cli.js +18 -27
  89. package/dist/utils/compatibility.d.ts +1 -1
  90. package/dist/utils/compatibility.js +5 -7
  91. package/dist/utils/config-store.js +33 -43
  92. package/dist/utils/connectivity.js +1 -4
  93. package/dist/utils/expo.js +15 -21
  94. package/dist/utils/orgs.js +8 -12
  95. package/dist/utils/paths.js +2 -5
  96. package/dist/utils/progress.js +2 -5
  97. package/dist/utils/styling.d.ts +35 -37
  98. package/dist/utils/styling.js +52 -86
  99. package/dist/utils/ui.d.ts +41 -0
  100. package/dist/utils/ui.js +95 -0
  101. package/package.json +27 -24
@@ -1,16 +1,15 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ResultsPollingService = exports.RunFailedError = void 0;
4
- const path = require("node:path");
5
- const api_gateway_1 = require("../gateways/api-gateway");
6
- const methods_1 = require("../methods");
7
- const connectivity_1 = require("../utils/connectivity");
8
- const progress_1 = require("../utils/progress");
9
- const styling_1 = require("../utils/styling");
1
+ import * as path from 'node:path';
2
+ import { ApiGateway } from '../gateways/api-gateway.js';
3
+ import { RealtimeResultsGateway, } from '../gateways/realtime-gateway.js';
4
+ import { formatDurationSeconds } from '../methods.js';
5
+ import { checkInternetConnectivity } from '../utils/connectivity.js';
6
+ import { ux } from '../utils/progress.js';
7
+ import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
8
+ import { ui } from '../utils/ui.js';
10
9
  /**
11
10
  * Custom error for run failures that includes the polling result
12
11
  */
13
- class RunFailedError extends Error {
12
+ export class RunFailedError extends Error {
14
13
  result;
15
14
  constructor(result) {
16
15
  super('RUN_FAILED');
@@ -18,18 +17,22 @@ class RunFailedError extends Error {
18
17
  this.name = 'RunFailedError';
19
18
  }
20
19
  }
21
- exports.RunFailedError = RunFailedError;
22
20
  /**
23
21
  * Service for polling test results from the API
24
22
  */
25
- class ResultsPollingService {
23
+ export class ResultsPollingService {
26
24
  // 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.
25
+ // poll, so tolerate a long stretch of transient API/network blips before
26
+ // giving up. Losing a run to a brief hiccup is far more costly than waiting a
27
+ // bit longer.
30
28
  MAX_SEQUENTIAL_FAILURES = 30;
31
- POLL_INTERVAL_MS = 10_000;
32
- // Cap for the backoff applied between failed polls.
29
+ // Backstop poll cadence. Logged-in (bearer) users also get realtime pushes
30
+ // (see RealtimeResultsGateway), so they only need an occasional reconciling
31
+ // poll; api-key users have no realtime and rely on the faster interval.
32
+ BEARER_POLL_INTERVAL_MS = 60_000;
33
+ APIKEY_POLL_INTERVAL_MS = 20_000;
34
+ // Base unit for the backoff applied between *failed* polls, and its cap.
35
+ ERROR_BACKOFF_BASE_MS = 10_000;
33
36
  MAX_ERROR_BACKOFF_MS = 30_000;
34
37
  /**
35
38
  * Poll for test results until all tests complete
@@ -46,7 +49,7 @@ class ResultsPollingService {
46
49
  // displayFinalResults. Anything else aborts mid-poll with the spinner
47
50
  // still live, which would corrupt the terminal under the error output.
48
51
  if (!options.json && !(error instanceof RunFailedError)) {
49
- progress_1.ux.action.stop(styling_1.colors.error('failed'));
52
+ ux.action.stop(colors.error('failed'));
50
53
  }
51
54
  throw error;
52
55
  }
@@ -55,44 +58,137 @@ class ResultsPollingService {
55
58
  const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options;
56
59
  this.initializePollingDisplay(json, logger);
57
60
  let sequentialPollFailures = 0;
58
- let previousSummary = '';
61
+ const pollIntervalMs = auth.mode === 'bearer'
62
+ ? this.BEARER_POLL_INTERVAL_MS
63
+ : this.APIKEY_POLL_INTERVAL_MS;
64
+ // "Poke" mechanism: a realtime change resolves the current inter-poll wait
65
+ // early. If a poke lands while we're mid-fetch (not waiting) it's latched
66
+ // and consumed by the next wait, so events are never silently dropped.
67
+ let resolveWake = null;
68
+ let pendingPoke = false;
69
+ const poke = () => {
70
+ if (resolveWake) {
71
+ const r = resolveWake;
72
+ resolveWake = null;
73
+ r();
74
+ }
75
+ else {
76
+ pendingPoke = true;
77
+ }
78
+ };
79
+ const waitForNextPoll = (ms) => {
80
+ if (pendingPoke) {
81
+ pendingPoke = false;
82
+ return Promise.resolve();
83
+ }
84
+ return new Promise((resolve) => {
85
+ const timer = setTimeout(() => {
86
+ resolveWake = null;
87
+ resolve();
88
+ }, ms);
89
+ resolveWake = () => {
90
+ clearTimeout(timer);
91
+ resolve();
92
+ };
93
+ });
94
+ };
95
+ // Live display state, recomposed by renderStatus() so the footer (countdown
96
+ // to the next backstop poll + realtime connection state) stays current
97
+ // between polls without re-fetching. `nextPollAt` is an epoch-ms deadline
98
+ // while waiting, or null while a fetch is in flight.
99
+ let subscription;
100
+ let realtimeEnabled = false;
101
+ let statusBody = '';
102
+ let nextPollAt = null;
103
+ const renderStatus = () => {
104
+ if (json)
105
+ return;
106
+ const footer = this.buildStatusFooter(realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt);
107
+ ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody;
108
+ };
109
+ // Realtime is a latency optimisation over the backstop poll; only logged-in
110
+ // (bearer) users can authenticate the socket under RLS. Any failure inside
111
+ // the gateway degrades silently to pure polling.
112
+ if (auth.mode === 'bearer' && auth.accessToken && auth.orgId && auth.env) {
113
+ realtimeEnabled = true;
114
+ subscription = RealtimeResultsGateway.subscribe({
115
+ accessToken: auth.accessToken,
116
+ debug,
117
+ env: auth.env,
118
+ log: logger,
119
+ onChange: poke,
120
+ // Reflect connect/disconnect in the live footer immediately rather than
121
+ // waiting for the next ticker frame.
122
+ onConnectionChange: renderStatus,
123
+ orgId: auth.orgId,
124
+ uploadId,
125
+ });
126
+ if (debug && logger) {
127
+ logger(`[DEBUG] Realtime enabled; backstop poll every ${pollIntervalMs / 1000}s`);
128
+ }
129
+ }
59
130
  if (debug && logger) {
60
131
  logger(`[DEBUG] Starting polling loop for results`);
61
132
  }
62
- // Poll in a loop until all tests complete
63
- // eslint-disable-next-line no-constant-condition
64
- while (true) {
65
- try {
66
- const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
67
- const { summary } = this.calculateStatusSummary(updatedResults);
68
- previousSummary = this.updateDisplayStatus(updatedResults, quiet, json, summary, previousSummary);
69
- const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
70
- if (allComplete) {
71
- return await this.handleCompletedTests(updatedResults, {
72
- consoleUrl,
73
- debug,
74
- json,
75
- logger,
76
- testMetadata,
77
- uploadId,
78
- });
133
+ // Tick the live footer once a second so the countdown actually counts down
134
+ // (the spinner's own frames don't recompute our message). Unref'd so it
135
+ // never keeps the process alive on its own.
136
+ const ticker = json ? null : setInterval(renderStatus, 1000);
137
+ ticker?.unref?.();
138
+ try {
139
+ // Poll in a loop until all tests complete
140
+ // eslint-disable-next-line no-constant-condition
141
+ while (true) {
142
+ try {
143
+ nextPollAt = null;
144
+ renderStatus();
145
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
146
+ const { summary } = this.calculateStatusSummary(updatedResults);
147
+ if (!json) {
148
+ statusBody = this.buildStatusBody(updatedResults, quiet, summary);
149
+ }
150
+ const allComplete = updatedResults.every((result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status));
151
+ if (allComplete) {
152
+ return await this.handleCompletedTests(updatedResults, {
153
+ consoleUrl,
154
+ debug,
155
+ json,
156
+ logger,
157
+ testMetadata,
158
+ uploadId,
159
+ });
160
+ }
161
+ // Reset failure counter on successful poll
162
+ sequentialPollFailures = 0;
163
+ // Wait for the next backstop poll, or a realtime poke, whichever comes
164
+ // first, while the footer counts down to the deadline.
165
+ nextPollAt = Date.now() + pollIntervalMs;
166
+ renderStatus();
167
+ await waitForNextPoll(pollIntervalMs);
168
+ }
169
+ catch (error) {
170
+ // Re-throw RunFailedError immediately (test failures, not polling errors)
171
+ if (error instanceof RunFailedError) {
172
+ throw error;
173
+ }
174
+ sequentialPollFailures++;
175
+ // Handle polling errors (network issues, etc.)
176
+ await this.handlePollingError(error, sequentialPollFailures, debug, logger, uploadId);
177
+ // Back off (capped) before retrying so a flaky API gets some breathing
178
+ // room instead of being hammered on every failure.
179
+ await this.sleep(Math.min(this.ERROR_BACKOFF_BASE_MS * sequentialPollFailures, this.MAX_ERROR_BACKOFF_MS));
79
180
  }
80
- // Reset failure counter on successful poll
81
- sequentialPollFailures = 0;
82
- // Wait before next poll
83
- await this.sleep(this.POLL_INTERVAL_MS);
84
181
  }
85
- catch (error) {
86
- // Re-throw RunFailedError immediately (test failures, not polling errors)
87
- if (error instanceof RunFailedError) {
88
- throw error;
182
+ }
183
+ finally {
184
+ if (ticker) {
185
+ clearInterval(ticker);
186
+ }
187
+ if (subscription) {
188
+ if (debug && logger) {
189
+ logger('[DEBUG] Closing realtime subscription');
89
190
  }
90
- sequentialPollFailures++;
91
- // Handle polling errors (network issues, etc.)
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));
191
+ await subscription.unsubscribe();
96
192
  }
97
193
  }
98
194
  }
@@ -129,7 +225,7 @@ class ResultsPollingService {
129
225
  const running = statusCounts.RUNNING || 0;
130
226
  const total = results.length;
131
227
  const completed = passed + failed;
132
- const summary = (0, styling_1.formatTestSummary)({
228
+ const summary = formatTestSummary({
133
229
  completed,
134
230
  failed,
135
231
  passed,
@@ -144,48 +240,29 @@ class ResultsPollingService {
144
240
  if (json) {
145
241
  return;
146
242
  }
147
- progress_1.ux.action.stop(styling_1.colors.success('completed'));
243
+ ux.action.stop(colors.success('completed'));
148
244
  if (logger) {
149
245
  logger('\n');
150
246
  }
151
247
  const hasFailedTests = results.some((result) => result.status === 'FAILED');
152
- (0, styling_1.table)(results, {
248
+ table(results, {
153
249
  duration: {
154
250
  get(row) {
155
251
  return row.duration_seconds
156
- ? styling_1.colors.dim((0, methods_1.formatDurationSeconds)(Number(row.duration_seconds)))
157
- : styling_1.colors.dim('-');
252
+ ? colors.dim(formatDurationSeconds(Number(row.duration_seconds)))
253
+ : colors.dim('-');
158
254
  },
159
255
  },
160
256
  status: {
161
257
  get(row) {
162
- const statusUpper = row.status.toUpperCase();
163
- switch (statusUpper) {
164
- case 'PASSED': {
165
- return styling_1.colors.success(row.status);
166
- }
167
- case 'FAILED': {
168
- return styling_1.colors.error(row.status);
169
- }
170
- case 'RUNNING': {
171
- return styling_1.colors.info(row.status);
172
- }
173
- case 'PENDING': {
174
- return styling_1.colors.warning(row.status);
175
- }
176
- case 'QUEUED': {
177
- return styling_1.colors.dim(row.status);
178
- }
179
- default: {
180
- return styling_1.colors.dim(row.status);
181
- }
182
- }
258
+ const { color } = statusPalette(row.status);
259
+ return color(row.status.toLowerCase());
183
260
  },
184
261
  },
185
262
  test: {
186
263
  get(row) {
187
264
  const testName = row.test_file_name;
188
- const retry = row.retry_of ? styling_1.colors.dim(' (retry)') : '';
265
+ const retry = row.retry_of ? colors.dim(' (retry)') : '';
189
266
  return `${testName}${retry}`;
190
267
  },
191
268
  },
@@ -194,7 +271,7 @@ class ResultsPollingService {
194
271
  fail_reason: {
195
272
  get(row) {
196
273
  return row.status === 'FAILED' && row.fail_reason
197
- ? styling_1.colors.error(row.fail_reason)
274
+ ? colors.error(row.fail_reason)
198
275
  : '';
199
276
  },
200
277
  },
@@ -202,8 +279,8 @@ class ResultsPollingService {
202
279
  }, { printLine: logger });
203
280
  if (logger) {
204
281
  logger('\n');
205
- logger(styling_1.colors.bold('Run completed') + styling_1.colors.dim(', you can access the results at:'));
206
- logger(styling_1.colors.url(consoleUrl));
282
+ logger(ui.success('Run completed'));
283
+ logger(ui.branch(ui.fields([['results', colors.url(consoleUrl)]])));
207
284
  logger('\n');
208
285
  }
209
286
  }
@@ -220,7 +297,7 @@ class ResultsPollingService {
220
297
  if (debug && logger) {
221
298
  logger(`[DEBUG] Polling for results: ${uploadId}`);
222
299
  }
223
- const { results: updatedResults } = await api_gateway_1.ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
300
+ const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
224
301
  // An empty array would otherwise read as "all complete, all passed".
225
302
  if (!updatedResults || updatedResults.length === 0) {
226
303
  throw new Error('no results');
@@ -298,7 +375,7 @@ class ResultsPollingService {
298
375
  if (debug && logger) {
299
376
  logger('[DEBUG] Checking internet connectivity...');
300
377
  }
301
- const connectivityCheck = await (0, connectivity_1.checkInternetConnectivity)();
378
+ const connectivityCheck = await checkInternetConnectivity();
302
379
  if (debug && logger) {
303
380
  logger(`[DEBUG] ${connectivityCheck.message}`);
304
381
  for (const result of connectivityCheck.endpointResults) {
@@ -330,11 +407,11 @@ class ResultsPollingService {
330
407
  */
331
408
  initializePollingDisplay(json, logger) {
332
409
  if (!json) {
333
- progress_1.ux.action.start(styling_1.colors.bold('Waiting for results'), styling_1.colors.dim('Initializing'), {
410
+ ux.action.start(colors.bold('Waiting for results'), colors.dim('Initializing'), {
334
411
  stdout: true,
335
412
  });
336
413
  if (logger) {
337
- logger(styling_1.colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
414
+ logger(colors.dim('\nYou can safely close this terminal and the tests will continue\n'));
338
415
  }
339
416
  }
340
417
  }
@@ -348,33 +425,43 @@ class ResultsPollingService {
348
425
  setTimeout(resolve, ms);
349
426
  });
350
427
  }
351
- updateDisplayStatus(results, quiet, json, summary, previousSummary) {
352
- if (json) {
353
- return previousSummary;
354
- }
428
+ /**
429
+ * Build the body of the live status display (the per-test table, or just the
430
+ * one-line summary in quiet mode). The footer (countdown + realtime state) is
431
+ * appended separately by {@link buildStatusFooter} so it can re-render on a
432
+ * timer without re-fetching.
433
+ */
434
+ buildStatusBody(results, quiet, summary) {
355
435
  if (quiet) {
356
- if (summary !== previousSummary) {
357
- progress_1.ux.action.status = summary;
358
- return summary;
359
- }
436
+ return summary;
437
+ }
438
+ const rows = results.map(({ retry_of: isRetry, status, test_file_name: test }) => {
439
+ const label = `${ui.statusSymbol(status)} ${ui.statusWord(status)}`;
440
+ const retryText = isRetry ? colors.dim(' (retry)') : '';
441
+ return [label, `${test}${retryText}`];
442
+ });
443
+ return `\n${ui.branch(ui.fields(rows))}`;
444
+ }
445
+ /**
446
+ * Build the live footer shown under the status display: whether realtime
447
+ * updates are connected (for logged-in users) and how long until the next
448
+ * backstop poll. While a fetch is in flight (`nextPollAt` is null) the
449
+ * countdown reads "refreshing…".
450
+ */
451
+ buildStatusFooter(realtimeEnabled, realtimeConnected, nextPollAt) {
452
+ const parts = [];
453
+ if (realtimeEnabled) {
454
+ parts.push(realtimeConnected
455
+ ? colors.success('● realtime connected')
456
+ : colors.warning('○ realtime connecting…'));
457
+ }
458
+ if (nextPollAt === null) {
459
+ parts.push(colors.dim('refreshing…'));
360
460
  }
361
461
  else {
362
- progress_1.ux.action.status = styling_1.colors.dim('\nStatus Test\n─────────── ───────────────');
363
- for (const { retry_of: isRetry, status, test_file_name: test, } of results) {
364
- const statusFormatted = status.toUpperCase() === 'PASSED'
365
- ? styling_1.colors.success(status.padEnd(10, ' '))
366
- : status.toUpperCase() === 'FAILED'
367
- ? styling_1.colors.error(status.padEnd(10, ' '))
368
- : status.toUpperCase() === 'RUNNING'
369
- ? styling_1.colors.info(status.padEnd(10, ' '))
370
- : status.toUpperCase() === 'QUEUED'
371
- ? styling_1.colors.dim(status.padEnd(10, ' '))
372
- : styling_1.colors.warning(status.padEnd(10, ' '));
373
- const retryText = isRetry ? styling_1.colors.dim(' (retry)') : '';
374
- progress_1.ux.action.status += `\n${statusFormatted} ${test}${retryText}`;
375
- }
462
+ const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
463
+ parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
376
464
  }
377
- return previousSummary;
465
+ return parts.join(colors.dim(' · '));
378
466
  }
379
467
  }
380
- exports.ResultsPollingService = ResultsPollingService;
@@ -1,4 +1,4 @@
1
- import type { AuthContext } from '../types/domain/auth.types';
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
2
  export type TelemetryLevel = 'log' | 'info' | 'warn' | 'error';
3
3
  declare class Telemetry {
4
4
  private buffer;
@@ -19,7 +19,16 @@ declare class Telemetry {
19
19
  auth: AuthContext;
20
20
  apiUrl?: string;
21
21
  }): void;
22
+ /**
23
+ * Override the command label attached to telemetry meta. The MCP server is
24
+ * long-lived and isn't a citty subcommand, so `inferCommandFromArgv` can't
25
+ * name it — `src/mcp/index.ts` calls this at boot.
26
+ */
27
+ setCommand(command: string): void;
22
28
  recordCommandStart(): void;
29
+ recordMcpToolStart(tool: string): void;
30
+ recordMcpToolSuccess(tool: string, durationMs: number): void;
31
+ recordMcpToolFailure(tool: string, error: unknown, durationMs: number): void;
23
32
  recordCommandSuccess(): void;
24
33
  recordCommandFailure(opts: {
25
34
  error: Error | string;
@@ -1,6 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.telemetry = void 0;
4
1
  /**
5
2
  * Ships CLI lifecycle + error events to Axiom via the API's `/cli/logs`
6
3
  * proxy (forwards to `cli-dev` / `cli-prod`). The proxy authenticates with
@@ -15,21 +12,21 @@ exports.telemetry = void 0;
15
12
  *
16
13
  * Opt out: `DCD_TELEMETRY_DISABLED=1` in the environment.
17
14
  */
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");
15
+ import { execFileSync } from 'node:child_process';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { getCliVersion, getInstallMethod } from '../utils/cli.js';
24
21
  const DEFAULT_API_URL = 'https://api.devicecloud.dev';
25
22
  class Telemetry {
26
23
  buffer = [];
27
24
  config = null;
28
25
  command = inferCommandFromArgv();
29
- sessionId = (0, node_crypto_1.randomUUID)();
26
+ sessionId = randomUUID();
30
27
  startedAt = Date.now();
31
28
  disabled = !!process.env.DCD_TELEMETRY_DISABLED;
32
- release = (0, cli_1.getCliVersion)();
29
+ release = getCliVersion();
33
30
  /**
34
31
  * Called once per invocation by `resolveAuth` after the credential check
35
32
  * succeeds. Before this is called, lifecycle events are buffered but cannot
@@ -46,12 +43,37 @@ class Telemetry {
46
43
  command: this.command,
47
44
  };
48
45
  }
46
+ /**
47
+ * Override the command label attached to telemetry meta. The MCP server is
48
+ * long-lived and isn't a citty subcommand, so `inferCommandFromArgv` can't
49
+ * name it — `src/mcp/index.ts` calls this at boot.
50
+ */
51
+ setCommand(command) {
52
+ this.command = command;
53
+ }
49
54
  recordCommandStart() {
50
55
  this.startedAt = Date.now();
51
56
  this.enqueue('info', 'cli.lifecycle', 'command started', {
52
57
  argv: scrubArgv(process.argv.slice(2)),
53
58
  });
54
59
  }
60
+ recordMcpToolStart(tool) {
61
+ this.enqueue('info', 'cli.mcp', 'mcp tool invoked', { tool });
62
+ }
63
+ recordMcpToolSuccess(tool, durationMs) {
64
+ this.enqueue('info', 'cli.mcp', 'mcp tool completed', {
65
+ tool,
66
+ duration_ms: durationMs,
67
+ });
68
+ }
69
+ recordMcpToolFailure(tool, error, durationMs) {
70
+ this.enqueue('error', 'cli.mcp', 'mcp tool failed', {
71
+ tool,
72
+ duration_ms: durationMs,
73
+ error_message: error instanceof Error ? error.message : String(error),
74
+ error_name: error instanceof Error ? error.name : 'Error',
75
+ });
76
+ }
55
77
  recordCommandSuccess() {
56
78
  this.enqueue('info', 'cli.lifecycle', 'command completed', {
57
79
  duration_ms: Date.now() - this.startedAt,
@@ -115,14 +137,14 @@ class Telemetry {
115
137
  // body, so it can't double as the config channel.
116
138
  let configDir;
117
139
  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');
140
+ configDir = mkdtempSync(join(tmpdir(), 'dcd-telemetry-'));
141
+ const configPath = join(configDir, 'curl.cfg');
120
142
  const headerLines = ['header = "content-type: application/json"'];
121
143
  for (const [k, v] of Object.entries(this.config.auth.headers)) {
122
144
  headerLines.push(`header = "${k}: ${v}"`);
123
145
  }
124
- (0, node_fs_1.writeFileSync)(configPath, headerLines.join('\n'), { mode: 0o600 });
125
- (0, node_child_process_1.execFileSync)('curl', [
146
+ writeFileSync(configPath, headerLines.join('\n'), { mode: 0o600 });
147
+ execFileSync('curl', [
126
148
  '-sS',
127
149
  '-m',
128
150
  '3',
@@ -140,7 +162,7 @@ class Telemetry {
140
162
  }
141
163
  finally {
142
164
  if (configDir)
143
- (0, node_fs_1.rmSync)(configDir, { recursive: true, force: true });
165
+ rmSync(configDir, { recursive: true, force: true });
144
166
  }
145
167
  }
146
168
  buildMeta() {
@@ -152,7 +174,7 @@ class Telemetry {
152
174
  command: this.command,
153
175
  sessionId: this.sessionId,
154
176
  authMode: this.config.auth.mode,
155
- installMethod: (0, cli_1.getInstallMethod)(),
177
+ installMethod: getInstallMethod(),
156
178
  nodeVersion: process.versions.node,
157
179
  platform: process.platform,
158
180
  arch: process.arch,
@@ -227,4 +249,4 @@ function inferApiUrlFromArgv() {
227
249
  }
228
250
  return DEFAULT_API_URL;
229
251
  }
230
- exports.telemetry = new Telemetry();
252
+ export const telemetry = new Telemetry();
@@ -1,4 +1,4 @@
1
- import { IExecutionPlan } from './execution-plan.service';
1
+ import { IExecutionPlan } from './execution-plan.service.js';
2
2
  export interface TestSubmissionConfig {
3
3
  androidApiLevel?: string;
4
4
  androidDevice?: string;
@@ -35,11 +35,28 @@ export interface TestSubmissionConfig {
35
35
  */
36
36
  export declare class TestSubmissionService {
37
37
  /**
38
- * Build FormData for test submission
38
+ * Build the test-submission payload: the compressed flow zip plus every
39
+ * non-`file` field, each encoded exactly as it is sent today. The same
40
+ * `fields` feed both the new JSON `submitFlowTest` body and the legacy
41
+ * multipart `buildFormData`, guaranteeing byte-identical field encoding
42
+ * across both paths.
39
43
  * @param config Test submission configuration
40
- * @returns FormData ready to be submitted to the API
44
+ * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
41
45
  */
42
- buildTestFormData(config: TestSubmissionConfig): Promise<FormData>;
46
+ buildTestPayload(config: TestSubmissionConfig): Promise<{
47
+ buffer: Buffer;
48
+ fields: Record<string, string>;
49
+ sha: string;
50
+ }>;
51
+ /**
52
+ * Wraps the payload fields and flow zip into multipart FormData for the
53
+ * legacy `POST /uploads/flow` fallback. `file` is set first to preserve the
54
+ * exact part ordering the old code produced.
55
+ * @param fields String-encoded fields from {@link buildTestPayload}
56
+ * @param buffer The compressed flow zip
57
+ * @returns FormData ready to be submitted to the multipart API
58
+ */
59
+ buildFormData(fields: Record<string, string>, buffer: Buffer): FormData;
43
60
  private logDebug;
44
61
  private normalizeFilePath;
45
62
  private normalizePathMap;