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

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/commands/artifacts.d.ts +28 -28
  4. package/dist/commands/artifacts.js +20 -23
  5. package/dist/commands/cloud.d.ts +57 -57
  6. package/dist/commands/cloud.js +224 -192
  7. package/dist/commands/list.d.ts +22 -22
  8. package/dist/commands/list.js +43 -40
  9. package/dist/commands/live.js +134 -127
  10. package/dist/commands/login.d.ts +11 -11
  11. package/dist/commands/login.js +46 -44
  12. package/dist/commands/logout.js +16 -18
  13. package/dist/commands/status.d.ts +11 -11
  14. package/dist/commands/status.js +53 -44
  15. package/dist/commands/switch-org.d.ts +7 -7
  16. package/dist/commands/switch-org.js +19 -21
  17. package/dist/commands/upgrade.js +41 -33
  18. package/dist/commands/upload.d.ts +10 -10
  19. package/dist/commands/upload.js +42 -43
  20. package/dist/commands/whoami.js +17 -20
  21. package/dist/config/environments.js +6 -12
  22. package/dist/config/flags/api.flags.js +1 -4
  23. package/dist/config/flags/binary.flags.js +1 -4
  24. package/dist/config/flags/device.flags.js +6 -9
  25. package/dist/config/flags/environment.flags.js +1 -4
  26. package/dist/config/flags/execution.flags.js +1 -4
  27. package/dist/config/flags/github.flags.js +1 -4
  28. package/dist/config/flags/output.flags.js +1 -4
  29. package/dist/constants.js +15 -18
  30. package/dist/gateways/api-gateway.d.ts +31 -6
  31. package/dist/gateways/api-gateway.js +70 -16
  32. package/dist/gateways/cli-auth-gateway.d.ts +1 -1
  33. package/dist/gateways/cli-auth-gateway.js +3 -6
  34. package/dist/gateways/realtime-gateway.d.ts +32 -0
  35. package/dist/gateways/realtime-gateway.js +103 -0
  36. package/dist/gateways/supabase-gateway.d.ts +1 -1
  37. package/dist/gateways/supabase-gateway.js +10 -14
  38. package/dist/index.js +41 -38
  39. package/dist/mcp/context.d.ts +33 -0
  40. package/dist/mcp/context.js +33 -0
  41. package/dist/mcp/helpers.d.ts +16 -0
  42. package/dist/mcp/helpers.js +34 -0
  43. package/dist/mcp/index.d.ts +2 -0
  44. package/dist/mcp/index.js +24 -0
  45. package/dist/mcp/server.d.ts +7 -0
  46. package/dist/mcp/server.js +27 -0
  47. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  48. package/dist/mcp/tools/download-artifacts.js +84 -0
  49. package/dist/mcp/tools/get-status.d.ts +7 -0
  50. package/dist/mcp/tools/get-status.js +39 -0
  51. package/dist/mcp/tools/list-devices.d.ts +7 -0
  52. package/dist/mcp/tools/list-devices.js +27 -0
  53. package/dist/mcp/tools/list-runs.d.ts +3 -0
  54. package/dist/mcp/tools/list-runs.js +60 -0
  55. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  56. package/dist/mcp/tools/run-cloud-test.js +233 -0
  57. package/dist/methods.d.ts +32 -1
  58. package/dist/methods.js +133 -79
  59. package/dist/services/device-validation.service.d.ts +1 -1
  60. package/dist/services/device-validation.service.js +1 -5
  61. package/dist/services/execution-plan.service.js +14 -17
  62. package/dist/services/execution-plan.utils.js +15 -23
  63. package/dist/services/flow-paths.d.ts +17 -0
  64. package/dist/services/flow-paths.js +52 -0
  65. package/dist/services/metadata-extractor.service.js +22 -25
  66. package/dist/services/moropo.service.js +18 -20
  67. package/dist/services/report-download.service.d.ts +1 -1
  68. package/dist/services/report-download.service.js +5 -9
  69. package/dist/services/results-polling.service.d.ts +18 -3
  70. package/dist/services/results-polling.service.js +211 -108
  71. package/dist/services/telemetry.service.d.ts +10 -1
  72. package/dist/services/telemetry.service.js +40 -18
  73. package/dist/services/test-submission.service.d.ts +21 -4
  74. package/dist/services/test-submission.service.js +51 -34
  75. package/dist/services/version.service.d.ts +30 -7
  76. package/dist/services/version.service.js +88 -32
  77. package/dist/types/domain/auth.types.d.ts +8 -0
  78. package/dist/types/domain/auth.types.js +1 -2
  79. package/dist/types/domain/device.types.js +8 -11
  80. package/dist/types/domain/live.types.js +1 -2
  81. package/dist/types/generated/schema.types.js +1 -2
  82. package/dist/types/index.d.ts +2 -2
  83. package/dist/types/index.js +2 -18
  84. package/dist/types.js +1 -2
  85. package/dist/utils/auth.d.ts +1 -1
  86. package/dist/utils/auth.js +27 -28
  87. package/dist/utils/ci.d.ts +12 -0
  88. package/dist/utils/ci.js +39 -0
  89. package/dist/utils/cli.d.ts +16 -2
  90. package/dist/utils/cli.js +57 -29
  91. package/dist/utils/compatibility.d.ts +1 -1
  92. package/dist/utils/compatibility.js +5 -7
  93. package/dist/utils/config-store.js +33 -43
  94. package/dist/utils/connectivity.js +1 -4
  95. package/dist/utils/expo.js +15 -21
  96. package/dist/utils/orgs.js +8 -12
  97. package/dist/utils/paths.js +2 -5
  98. package/dist/utils/progress.d.ts +3 -0
  99. package/dist/utils/progress.js +47 -8
  100. package/dist/utils/styling.d.ts +35 -37
  101. package/dist/utils/styling.js +52 -86
  102. package/dist/utils/ui.d.ts +41 -0
  103. package/dist/utils/ui.js +95 -0
  104. package/package.json +27 -24
@@ -1,28 +1,28 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.cloudCommand = void 0;
4
1
  /* eslint-disable complexity */
5
- const citty_1 = require("citty");
6
- const path = require("node:path");
7
- const constants_1 = require("../constants");
8
- const api_gateway_1 = require("../gateways/api-gateway");
9
- const methods_1 = require("../methods");
10
- const device_validation_service_1 = require("../services/device-validation.service");
11
- const execution_plan_service_1 = require("../services/execution-plan.service");
12
- const moropo_service_1 = require("../services/moropo.service");
13
- const report_download_service_1 = require("../services/report-download.service");
14
- const results_polling_service_1 = require("../services/results-polling.service");
15
- const telemetry_service_1 = require("../services/telemetry.service");
16
- const test_submission_service_1 = require("../services/test-submission.service");
17
- const version_service_1 = require("../services/version.service");
18
- const device_types_1 = require("../types/domain/device.types");
19
- const auth_1 = require("../utils/auth");
20
- const cli_1 = require("../utils/cli");
21
- const compatibility_1 = require("../utils/compatibility");
22
- const config_store_1 = require("../utils/config-store");
23
- const expo_1 = require("../utils/expo");
24
- const paths_1 = require("../utils/paths");
25
- const styling_1 = require("../utils/styling");
2
+ import { defineCommand } from 'citty';
3
+ import { existsSync } from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { flags as allFlags } from '../constants.js';
6
+ import { ApiError, ApiGateway } from '../gateways/api-gateway.js';
7
+ import { uploadBinary, uploadFlowZip, verifyAppZip, writeJSONFile, } from '../methods.js';
8
+ import { DeviceValidationService } from '../services/device-validation.service.js';
9
+ import { plan } from '../services/execution-plan.service.js';
10
+ import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
11
+ import { MoropoService } from '../services/moropo.service.js';
12
+ import { ReportDownloadService } from '../services/report-download.service.js';
13
+ import { ResultsPollingService, RunFailedError, } from '../services/results-polling.service.js';
14
+ import { telemetry } from '../services/telemetry.service.js';
15
+ import { TestSubmissionService } from '../services/test-submission.service.js';
16
+ import { VersionService } from '../services/version.service.js';
17
+ import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, } from '../types/domain/device.types.js';
18
+ import { resolveAuth } from '../utils/auth.js';
19
+ import { isCI } from '../utils/ci.js';
20
+ import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
21
+ import { fetchCompatibilityData, } from '../utils/compatibility.js';
22
+ import { resolveApiUrl } from '../utils/config-store.js';
23
+ import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
24
+ import { colors, formatId, formatUrl, getConsoleUrl, } from '../utils/styling.js';
25
+ import { ui } from '../utils/ui.js';
26
26
  // Suppress punycode deprecation warning (caused by whatwg, supabase dependency).
27
27
  // Every other warning must still reach the user — removeAllListeners drops
28
28
  // Node's default printer, so re-emit manually.
@@ -51,13 +51,13 @@ const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'];
51
51
  *
52
52
  * Replaces `maestro cloud` with DeviceCloud-specific functionality.
53
53
  */
54
- exports.cloudCommand = (0, citty_1.defineCommand)({
54
+ export const cloudCommand = defineCommand({
55
55
  meta: {
56
56
  name: 'cloud',
57
57
  description: 'Test a Flow or set of Flows on devicecloud.dev (https://devicecloud.dev). Provide your application file and a folder with Maestro flows to run them in parallel on multiple devices. The command will block until all analyses have completed.',
58
58
  },
59
59
  args: {
60
- ...constants_1.flags,
60
+ ...allFlags,
61
61
  firstFile: {
62
62
  type: 'positional',
63
63
  required: false,
@@ -70,24 +70,24 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
70
70
  },
71
71
  },
72
72
  // eslint-disable-next-line complexity
73
- async run({ args }) {
74
- const cliVersion = (0, cli_1.getCliVersion)();
75
- const deviceValidationService = new device_validation_service_1.DeviceValidationService();
76
- const moropoService = new moropo_service_1.MoropoService();
77
- const reportDownloadService = new report_download_service_1.ReportDownloadService();
78
- const resultsPollingService = new results_polling_service_1.ResultsPollingService();
79
- const testSubmissionService = new test_submission_service_1.TestSubmissionService();
80
- const versionService = new version_service_1.VersionService();
73
+ async run({ args, rawArgs }) {
74
+ const cliVersion = getCliVersion();
75
+ const deviceValidationService = new DeviceValidationService();
76
+ const moropoService = new MoropoService();
77
+ const reportDownloadService = new ReportDownloadService();
78
+ const resultsPollingService = new ResultsPollingService();
79
+ const testSubmissionService = new TestSubmissionService();
80
+ const versionService = new VersionService();
81
81
  const versionCheck = async () => {
82
- const latestVersion = await versionService.checkLatestCliVersion();
83
- if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) {
84
- const body = `${styling_1.symbols.warning} ${styling_1.colors.bold('Update Available')}\n` +
85
- styling_1.colors.dim('A new version of the DeviceCloud CLI is available: ') +
86
- styling_1.colors.highlight(latestVersion) +
87
- '\n' +
88
- styling_1.colors.dim('Run: ') +
89
- styling_1.colors.info((0, cli_1.getUpgradeCommand)());
90
- out(`\n${(0, styling_1.box)(body)}\n`);
82
+ const result = await versionService.checkLatestCliVersion(cliVersion);
83
+ if (result.ok &&
84
+ result.version &&
85
+ versionService.isOutdated(cliVersion, result.version)) {
86
+ out(ui.warn(colors.bold('Update available')));
87
+ out(ui.branch([
88
+ `A new version of the DeviceCloud CLI is available: ${colors.highlight(result.version)}`,
89
+ `${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`,
90
+ ]));
91
91
  }
92
92
  };
93
93
  let output = null;
@@ -100,15 +100,15 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
100
100
  // Chatty progress logs are suppressed when --json is active so stdout stays parseable.
101
101
  const out = (m) => {
102
102
  if (!jsonFlag)
103
- cli_1.logger.log(m);
103
+ logger.log(m);
104
104
  };
105
105
  const warnOut = (m) => {
106
106
  if (!jsonFlag)
107
- cli_1.logger.warn(m);
107
+ logger.warn(m);
108
108
  };
109
109
  try {
110
110
  const apiKeyFlag = args['api-key'];
111
- const apiUrl = (0, config_store_1.resolveApiUrl)(args['api-url']);
111
+ const apiUrl = resolveApiUrl(args['api-url']);
112
112
  const appBinaryId = args['app-binary-id'];
113
113
  const appFile = args['app-file'];
114
114
  const appUrl = args['app-url'];
@@ -120,34 +120,36 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
120
120
  const configFile = args.config;
121
121
  const debug = Boolean(args.debug);
122
122
  const deviceLocale = args['device-locale'];
123
- const downloadArtifacts = (0, cli_1.validateEnum)(args['download-artifacts'], DOWNLOAD_OPTIONS, 'download-artifacts');
123
+ const downloadArtifacts = validateEnum(args['download-artifacts'], DOWNLOAD_OPTIONS, 'download-artifacts');
124
124
  const dryRun = Boolean(args['dry-run']);
125
- const env = (0, cli_1.coerceArray)(args.env, false);
126
- const excludeFlows = (0, cli_1.coerceArray)(args['exclude-flows']);
127
- const excludeTags = (0, cli_1.coerceArray)(args['exclude-tags']);
125
+ // Repeatable flags are collected from rawArgs: citty/parseArgs only keeps
126
+ // the last occurrence, so reading args.* directly drops earlier values.
127
+ const env = coerceArray(collectRepeatedFlag(rawArgs, ['--env', '-e']), false);
128
+ const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
129
+ const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
128
130
  let flows = args.flows;
129
131
  const googlePlay = Boolean(args['google-play']);
130
132
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
131
- const includeTags = (0, cli_1.coerceArray)(args['include-tags']);
132
- const iOSDevice = (0, cli_1.validateEnum)(args['ios-device'], Object.values(device_types_1.EiOSDevices), 'ios-device');
133
- const iOSVersion = (0, cli_1.validateEnum)(args['ios-version'], Object.values(device_types_1.EiOSVersions), 'ios-version');
134
- const androidApiLevel = (0, cli_1.validateEnum)(args['android-api-level'], Object.values(device_types_1.EAndroidApiLevels), 'android-api-level');
135
- const androidDevice = (0, cli_1.validateEnum)(args['android-device'], Object.values(device_types_1.EAndroidDevices), 'android-device');
133
+ const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
134
+ const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
135
+ const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
136
+ const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
137
+ const androidDevice = validateEnum(args['android-device'], Object.values(EAndroidDevices), 'android-device');
136
138
  const androidNoSnapshot = Boolean(args['android-no-snapshot']);
137
139
  const json = Boolean(args.json);
138
140
  const jsonFileFlag = Boolean(args['json-file']);
139
141
  const jsonFileName = args['json-file-name'];
140
142
  const maestroVersion = args['maestro-version'];
141
- const metadata = (0, cli_1.coerceArray)(args.metadata, false);
143
+ const metadata = coerceArray(collectRepeatedFlag(rawArgs, ['--metadata', '-m']), false);
142
144
  const mitmHost = args.mitmHost;
143
145
  const mitmPath = args.mitmPath;
144
146
  const moropoApiKey = args['moropo-v1-api-key'];
145
147
  const name = args.name;
146
- const orientation = (0, cli_1.validateEnum)(args.orientation, ORIENTATION_OPTIONS, 'orientation');
148
+ const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
147
149
  let quiet = Boolean(args.quiet);
148
- const report = (0, cli_1.validateEnum)(args.report, REPORT_OPTIONS, 'report');
149
- let retry = (0, cli_1.parseIntFlag)(args.retry, 'retry');
150
- const runnerType = (0, cli_1.validateEnum)(args['runner-type'], RUNNER_TYPE_OPTIONS, 'runner-type') ?? 'default';
150
+ const report = validateEnum(args.report, REPORT_OPTIONS, 'report');
151
+ let retry = parseIntFlag(args.retry, 'retry');
152
+ const runnerType = validateEnum(args['runner-type'], RUNNER_TYPE_OPTIONS, 'runner-type') ?? 'default';
151
153
  const showCrosshairs = Boolean(args['show-crosshairs']);
152
154
  const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']);
153
155
  const disableAnimations = Boolean(args['disable-animations']);
@@ -158,7 +160,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
158
160
  const ghPrUrl = args['pr-url'];
159
161
  debugFlag = debug;
160
162
  jsonFile = jsonFileFlag;
161
- out(styling_1.colors.dim(`dcd v${cliVersion}`));
163
+ out(colors.dim(`dcd v${cliVersion}`));
162
164
  if (debug) {
163
165
  out('[DEBUG] Starting command execution with debug logging enabled');
164
166
  out(`[DEBUG] Node version: ${process.versions.node}`);
@@ -169,19 +171,19 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
169
171
  out('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output');
170
172
  }
171
173
  if (mitmPath && !mitmHost) {
172
- throw new cli_1.CliError('--mitmPath requires --mitmHost to be set');
174
+ throw new CliError('--mitmPath requires --mitmHost to be set');
173
175
  }
174
176
  if (jsonFileName && !jsonFileFlag) {
175
- throw new cli_1.CliError('--json-file-name requires --json-file');
177
+ throw new CliError('--json-file-name requires --json-file');
176
178
  }
177
179
  if (artifactsPath && !downloadArtifacts) {
178
- throw new cli_1.CliError('--artifacts-path requires --download-artifacts');
180
+ throw new CliError('--artifacts-path requires --download-artifacts');
179
181
  }
180
182
  if ((junitPath || allurePath || htmlPath) && !report) {
181
- throw new cli_1.CliError('Report path flags (--junit-path/--allure-path/--html-path) require --report');
183
+ throw new CliError('Report path flags (--junit-path/--allure-path/--html-path) require --report');
182
184
  }
183
185
  if (appFile && appUrl) {
184
- throw new cli_1.CliError('--app-file and --app-url are mutually exclusive');
186
+ throw new CliError('--app-file and --app-url are mutually exclusive');
185
187
  }
186
188
  if (json) {
187
189
  const originalStdoutWrite = process.stdout.write;
@@ -197,7 +199,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
197
199
  if (major < 20) {
198
200
  warnOut(`WARNING: You are using node version ${major}. DeviceCloud requires node version 20 or later`);
199
201
  if (major < 18) {
200
- throw new cli_1.CliError('Invalid node version');
202
+ throw new CliError('Invalid node version');
201
203
  }
202
204
  }
203
205
  await versionCheck();
@@ -212,10 +214,15 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
212
214
  });
213
215
  tempFiles.push(flows);
214
216
  }
215
- const auth = await (0, auth_1.resolveAuth)({ apiKeyFlag });
217
+ const auth = await resolveAuth({ apiKeyFlag });
218
+ // Nudge interactive api-key users toward `dcd login`, which unlocks live
219
+ // (realtime) status updates. Suppressed in CI and non-interactive output.
220
+ if (auth.mode === 'apiKey' && !json && !quiet && !isCI()) {
221
+ out(colors.dim('Tip: run `dcd login` for live test updates and a smoother experience than passing --api-key.'));
222
+ }
216
223
  let compatibilityData;
217
224
  try {
218
- compatibilityData = await (0, compatibility_1.fetchCompatibilityData)(apiUrl, auth);
225
+ compatibilityData = await fetchCompatibilityData(apiUrl, auth);
219
226
  if (debug) {
220
227
  out('[DEBUG] Successfully fetched compatibility data from API');
221
228
  }
@@ -225,7 +232,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
225
232
  if (debug) {
226
233
  out(`[DEBUG] Failed to fetch compatibility data from API: ${errorMessage}`);
227
234
  }
228
- throw new cli_1.CliError(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`);
235
+ throw new CliError(`Failed to fetch device compatibility data: ${errorMessage}. Please check your API key and connection.`);
229
236
  }
230
237
  if (debug) {
231
238
  out(`[DEBUG] API URL: ${apiUrl}`);
@@ -234,27 +241,29 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
234
241
  debug,
235
242
  logger: (m) => out(m),
236
243
  });
237
- const REMOVED_MAESTRO_VERSIONS = ['1.39.1', '1.39.2', '1.39.7', '2.0.3', '2.4.0'];
238
- if (REMOVED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
239
- throw new cli_1.CliError(`Maestro version ${resolvedMaestroVersion} is no longer supported. ` +
240
- `Please upgrade to a newer version. See: https://docs.devicecloud.dev/configuration/maestro-versions`);
244
+ // Soft deprecation notice for Maestro versions slated for removal on
245
+ // 26 June 2026. Non-fatal — these still run during the grace period.
246
+ const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0'];
247
+ if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
248
+ warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`)));
249
+ warnOut(ui.branch([
250
+ `Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`,
251
+ 'Upgrade to Maestro 2.6.0 or above.',
252
+ `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`,
253
+ ]));
241
254
  }
242
255
  if (retry !== undefined && retry > 2) {
243
- out(`${styling_1.symbols.warning} ` +
244
- styling_1.colors.dim('Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.'));
256
+ out(ui.warn('Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.'));
245
257
  retry = 2;
246
258
  }
247
259
  if (runnerType === 'm4') {
248
- out(`${styling_1.symbols.info} ` +
249
- styling_1.colors.dim('Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
260
+ out(ui.info('runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
250
261
  }
251
262
  if (runnerType === 'm1') {
252
- out(`${styling_1.symbols.info} ` +
253
- styling_1.colors.dim('Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
263
+ out(ui.info('runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
254
264
  }
255
265
  if (runnerType === 'gpu1') {
256
- out(`${styling_1.symbols.info} ` +
257
- styling_1.colors.dim('Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
266
+ out(ui.info('runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
258
267
  }
259
268
  const firstFile = args.firstFile;
260
269
  const secondFile = args.secondFile;
@@ -262,21 +271,28 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
262
271
  let finalAppFile = appFile ?? (appUrl ?? firstFile);
263
272
  let flowFile = flows ?? secondFile;
264
273
  if (finalAppFile && !appBinaryId) {
265
- if ((0, expo_1.isUrl)(finalAppFile)) {
266
- out(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
267
- const tarPath = await (0, expo_1.downloadExpoUrl)(finalAppFile, debug);
274
+ if (isUrl(finalAppFile)) {
275
+ out(` ${colors.dim('→ Downloading Expo build from URL...')}`);
276
+ const tarPath = await downloadExpoUrl(finalAppFile, debug);
268
277
  tempFiles.push(tarPath);
269
278
  finalAppFile = tarPath;
270
279
  }
271
280
  if (finalAppFile.endsWith('.tar.gz')) {
272
- out(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
273
- const extractDir = await (0, expo_1.extractTarGz)(finalAppFile, debug);
281
+ out(` ${colors.dim('→ Extracting Expo archive...')}`);
282
+ const extractDir = await extractTarGz(finalAppFile, debug);
274
283
  tempFiles.push(extractDir);
275
- finalAppFile = await (0, expo_1.findAppBundle)(extractDir);
284
+ finalAppFile = await findAppBundle(extractDir);
276
285
  if (debug) {
277
286
  out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
278
287
  }
279
288
  }
289
+ // Validate the resolved local app file early — dry-run otherwise skips
290
+ // the upload that would surface a missing file, so a typo'd path would
291
+ // pass a dry-run and only fail on the real run. (URL/.tar.gz inputs are
292
+ // already resolved to existing temp paths by this point.)
293
+ if (!existsSync(finalAppFile)) {
294
+ throw new CliError(`App file does not exist: ${finalAppFile}`);
295
+ }
280
296
  }
281
297
  if (debug) {
282
298
  out(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
@@ -287,7 +303,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
287
303
  }
288
304
  if (appBinaryId) {
289
305
  if (secondFile) {
290
- throw new cli_1.CliError('You cannot provide both an appBinaryId and a binary file');
306
+ throw new CliError('You cannot provide both an appBinaryId and a binary file');
291
307
  }
292
308
  flowFile = flows ?? firstFile;
293
309
  }
@@ -297,12 +313,24 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
297
313
  flowFile = firstFile;
298
314
  }
299
315
  if (!flowFile) {
300
- throw new cli_1.CliError('You must provide a flow file');
316
+ throw new CliError('You must provide a flow file');
301
317
  }
302
318
  deviceValidationService.validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, {
303
319
  debug,
304
320
  logger: (m) => out(m),
305
321
  });
322
+ // iOS 16 deprecation notice (soft warning during the grace period;
323
+ // removed on 23 August 2026). Only fires on an explicit --ios-version 16 —
324
+ // when omitted the API defaults to iOS 17, so no false warning.
325
+ const DEPRECATED_IOS_VERSIONS = ['16'];
326
+ if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) {
327
+ warnOut(ui.warn(colors.bold('iOS 16 is deprecated')));
328
+ warnOut(ui.branch([
329
+ 'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.',
330
+ 'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.',
331
+ `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`,
332
+ ]));
333
+ }
306
334
  deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
307
335
  if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
308
336
  warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
@@ -321,7 +349,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
321
349
  if (debug) {
322
350
  out('[DEBUG] Generating execution plan...');
323
351
  }
324
- executionPlan = await (0, execution_plan_service_1.plan)({
352
+ executionPlan = await plan({
325
353
  input: flowFile,
326
354
  includeTags,
327
355
  excludeTags,
@@ -349,42 +377,11 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
349
377
  out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
350
378
  out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
351
379
  }
352
- const pathsShortestToLongest = [
353
- ...testFileNames,
354
- ...referencedFiles,
355
- ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
356
- // Longest whole-segment directory prefix shared by every path. Segment
357
- // comparison (not startsWith) so sibling dirs like `flows`/`flows-extra`
358
- // can't merge, and the file segment itself is never consumed. '' when
359
- // the paths share no root at all.
360
- const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep));
361
- const shortestSegments = splitPaths[0];
362
- let matchedSegments = 0;
363
- for (let i = 0; i < shortestSegments.length - 1; i++) {
364
- if (splitPaths.every((segments) => segments[i] === shortestSegments[i])) {
365
- matchedSegments = i + 1;
366
- }
367
- else {
368
- break;
369
- }
370
- }
371
- const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep);
380
+ const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
372
381
  if (debug) {
373
382
  out(`[DEBUG] Common root directory: ${commonRoot}`);
374
383
  }
375
- const testMetadataMap = {};
376
- for (const [absolutePath, meta] of Object.entries(flowMetadata)) {
377
- const normalizedPath = (0, paths_1.toPortableRelativePath)(absolutePath, commonRoot);
378
- const metadataRecord = meta;
379
- const flowName = metadataRecord?.name || path.parse(absolutePath).name;
380
- const rawTags = metadataRecord?.tags;
381
- const tags = Array.isArray(rawTags)
382
- ? rawTags.map(String)
383
- : rawTags
384
- ? [String(rawTags)]
385
- : [];
386
- testMetadataMap[normalizedPath] = { flowName, tags };
387
- }
384
+ const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
388
385
  if (debug) {
389
386
  out(`[DEBUG] Built testMetadataMap for ${Object.keys(testMetadataMap).length} flows`);
390
387
  }
@@ -395,16 +392,16 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
395
392
  }
396
393
  if (!appBinaryId) {
397
394
  if (!(flowFile && finalAppFile)) {
398
- throw new cli_1.CliError('You must provide a flow file and an app binary id');
395
+ throw new CliError('You must provide a flow file and an app binary id');
399
396
  }
400
397
  if (!['.apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
401
- throw new cli_1.CliError('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
398
+ throw new CliError('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
402
399
  }
403
400
  if (finalAppFile.endsWith('.zip')) {
404
401
  if (debug) {
405
402
  out(`[DEBUG] Verifying iOS app zip file: ${finalAppFile}`);
406
403
  }
407
- await (0, methods_1.verifyAppZip)(finalAppFile);
404
+ await verifyAppZip(finalAppFile);
408
405
  }
409
406
  }
410
407
  const flagLogs = [];
@@ -417,10 +414,21 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
417
414
  'appUrl',
418
415
  ]);
419
416
  // Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl).
420
- const canonicalFlagKeys = new Set(Object.keys(constants_1.flags));
417
+ const canonicalFlagKeys = new Set(Object.keys(allFlags));
418
+ // Repeatable flags are recovered from rawArgs (args.* only holds the last
419
+ // occurrence), so echo the fully-collected values rather than args.*.
420
+ const repeatableDisplay = {
421
+ env,
422
+ metadata,
423
+ 'include-tags': includeTags,
424
+ 'exclude-tags': excludeTags,
425
+ 'exclude-flows': excludeFlows,
426
+ };
421
427
  for (const [k, v] of Object.entries(args)) {
422
428
  if (!canonicalFlagKeys.has(k))
423
429
  continue;
430
+ if (k in repeatableDisplay)
431
+ continue;
424
432
  if (v === undefined || v === null || v === false)
425
433
  continue;
426
434
  const asString = String(v);
@@ -428,61 +436,53 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
428
436
  flagLogs.push(`${k}: ${asString}`);
429
437
  }
430
438
  }
439
+ for (const [k, values] of Object.entries(repeatableDisplay)) {
440
+ if (values.length > 0)
441
+ flagLogs.push(`${k}: ${values.join(', ')}`);
442
+ }
431
443
  const overridesEntries = Object.entries(flowOverrides);
432
444
  const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
433
- let overridesLog = '';
434
- if (hasOverrides) {
435
- overridesLog = '\n\n ' + styling_1.colors.bold('With overrides');
436
- for (const [flowPath, overrides] of overridesEntries) {
437
- if (Object.keys(overrides).length > 0) {
438
- const relativePath = flowPath.replace(process.cwd(), '.');
439
- overridesLog += `\n ${styling_1.colors.dim('→')} ${relativePath}:`;
440
- for (const [key, value] of Object.entries(overrides)) {
441
- overridesLog += `\n ${styling_1.colors.dim(key + ':')} ${styling_1.colors.highlight(String(value))}`;
442
- }
443
- }
444
- }
445
- }
446
- out((0, styling_1.sectionHeader)('Submitting new job'));
447
- out(` ${styling_1.colors.dim('→ Flow(s):')} ${styling_1.colors.highlight(flowFile)}`);
448
- out(` ${styling_1.colors.dim('→ App:')} ${styling_1.colors.highlight(appBinaryId || finalAppFile || '')}`);
445
+ const submitRows = ui.fields([
446
+ ['flow(s)', colors.highlight(flowFile)],
447
+ ['app', colors.highlight(appBinaryId || finalAppFile || '')],
448
+ ]);
449
449
  if (flagLogs.length > 0) {
450
- out(`\n ${styling_1.colors.bold('With options')}`);
451
- for (const flagLog of flagLogs) {
450
+ submitRows.push('', colors.bold('Options'));
451
+ submitRows.push(...ui.fields(flagLogs.map((flagLog) => {
452
452
  const [key, ...valueParts] = flagLog.split(': ');
453
- const value = valueParts.join(': ');
454
- out(` ${styling_1.colors.dim('→ ' + key + ':')} ${styling_1.colors.highlight(value)}`);
455
- }
453
+ return [key, colors.highlight(valueParts.join(': '))];
454
+ })));
456
455
  }
457
456
  if (hasOverrides) {
458
- out(overridesLog);
457
+ submitRows.push('', colors.bold('Overrides'));
458
+ for (const [flowPath, overrides] of overridesEntries) {
459
+ if (Object.keys(overrides).length === 0) {
460
+ continue;
461
+ }
462
+ submitRows.push(colors.dim(`${flowPath.replace(process.cwd(), '.')}:`));
463
+ submitRows.push(...ui.fields(Object.entries(overrides).map(([key, value]) => [key, colors.highlight(String(value))])));
464
+ }
459
465
  }
460
- out('');
466
+ out(ui.section('Submitting new job'));
467
+ out(ui.branch(submitRows));
461
468
  if (dryRun) {
462
- out(`\n${styling_1.symbols.warning} ${styling_1.colors.bold('Dry run mode')} ${styling_1.colors.dim('- no tests were actually triggered')}\n`);
463
- out(styling_1.colors.bold('The following tests would have been run:'));
464
- out(styling_1.dividers.light);
465
- for (const test of testFileNames) {
466
- out((0, styling_1.listItem)(test));
467
- }
469
+ out(ui.warn(`${colors.bold('Dry run mode')} ${colors.dim(' no tests were actually triggered')}`));
470
+ out(ui.section('The following tests would have been run'));
471
+ out(ui.branch(testFileNames));
468
472
  if (sequentialFlows.length > 0) {
469
- out(`\n${styling_1.colors.bold('Sequential flows:')}`);
470
- out(styling_1.dividers.short);
471
- for (const flow of sequentialFlows) {
472
- out((0, styling_1.listItem)(flow));
473
- }
473
+ out(ui.section('Sequential flows'));
474
+ out(ui.branch(sequentialFlows));
474
475
  }
475
- out('\n');
476
476
  return;
477
477
  }
478
478
  if (!finalBinaryId) {
479
479
  if (!finalAppFile) {
480
- throw new cli_1.CliError('You must provide either an app binary id or an app file');
480
+ throw new CliError('You must provide either an app binary id or an app file');
481
481
  }
482
482
  if (debug) {
483
483
  out(`[DEBUG] Uploading binary file: ${finalAppFile}`);
484
484
  }
485
- const binaryId = await (0, methods_1.uploadBinary)({
485
+ const binaryId = await uploadBinary({
486
486
  auth,
487
487
  apiUrl,
488
488
  debug,
@@ -496,7 +496,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
496
496
  }
497
497
  }
498
498
  if (!finalBinaryId) {
499
- throw new cli_1.CliError('Internal error: finalBinaryId should be defined after validation');
499
+ throw new CliError('Internal error: finalBinaryId should be defined after validation');
500
500
  }
501
501
  const ghMetadataOverrides = [];
502
502
  if (ghBranch)
@@ -510,7 +510,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
510
510
  if (ghPrUrl)
511
511
  ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`);
512
512
  const mergedMetadata = [...ghMetadataOverrides, ...metadata];
513
- const testFormData = await testSubmissionService.buildTestFormData({
513
+ const { buffer, fields } = await testSubmissionService.buildTestPayload({
514
514
  androidApiLevel,
515
515
  androidDevice,
516
516
  androidNoSnapshot,
@@ -541,31 +541,63 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
541
541
  maestroChromeOnboarding,
542
542
  disableAnimations,
543
543
  });
544
- if (debug) {
545
- out(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`);
544
+ // New path: upload the zip directly to storage, then submit a JSON test
545
+ // referencing it. Older API deployments lack these endpoints — a real API
546
+ // 404s (route undefined), some proxies 405 (path/method not allowed); in
547
+ // either case fall back to the legacy multipart POST /uploads/flow, which
548
+ // is byte-identical bar the storage reference.
549
+ let response;
550
+ try {
551
+ const storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
552
+ if (debug) {
553
+ out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
554
+ out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
555
+ }
556
+ response = await ApiGateway.submitFlowTest(apiUrl, auth, {
557
+ ...fields,
558
+ ...storageRef,
559
+ });
560
+ }
561
+ catch (error) {
562
+ if (error instanceof ApiError &&
563
+ (error.status === 404 || error.status === 405)) {
564
+ if (debug) {
565
+ out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
566
+ }
567
+ const testFormData = testSubmissionService.buildFormData(fields, buffer);
568
+ response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
569
+ }
570
+ else {
571
+ throw error;
572
+ }
546
573
  }
547
- const { message, results } = await api_gateway_1.ApiGateway.uploadFlow(apiUrl, auth, testFormData);
574
+ const { message, results } = response;
548
575
  if (debug) {
549
- out(`[DEBUG] Flow upload response received`);
576
+ out(`[DEBUG] Flow submission response received`);
550
577
  out(`[DEBUG] Message: ${message}`);
551
578
  out(`[DEBUG] Results count: ${results?.length || 0}`);
552
579
  }
553
580
  if (!results?.length) {
554
- throw new cli_1.CliError('No tests created: ' + message);
581
+ throw new CliError('No tests created: ' + message);
555
582
  }
556
- out(`${styling_1.symbols.success} ${styling_1.colors.bold('Submitted')} ${styling_1.colors.dim(message)}`);
583
+ out(`${ui.success('Submitted')} ${colors.dim(message)}`);
557
584
  const testNames = results
558
585
  .map((r) => r.test_file_name)
559
586
  .sort((a, b) => a.localeCompare(b))
560
- .join(styling_1.colors.dim(', '));
561
- out(`\n${styling_1.colors.bold(`Created ${results.length} test${results.length === 1 ? '' : 's'}:`)} ${testNames}\n`);
562
- const url = (0, styling_1.getConsoleUrl)(apiUrl, results[0].test_upload_id, results[0].id);
563
- out(styling_1.colors.bold('Run triggered') + styling_1.colors.dim(', you can access the results at:'));
564
- out((0, styling_1.formatUrl)(url));
565
- out('');
566
- out(styling_1.colors.dim('Your upload ID is: ') + (0, styling_1.formatId)(results[0].test_upload_id));
567
- out(styling_1.colors.dim('Poll upload status using: ') +
568
- styling_1.colors.info(`dcd status --api-key ... --upload-id ${results[0].test_upload_id}`));
587
+ .join(colors.dim(', '));
588
+ const url = getConsoleUrl(apiUrl, results[0].test_upload_id, results[0].id);
589
+ out(ui.section(`Created ${results.length} test${results.length === 1 ? '' : 's'}`));
590
+ out(ui.branch([
591
+ testNames,
592
+ ...ui.fields([
593
+ ['results', formatUrl(url)],
594
+ ['upload id', formatId(results[0].test_upload_id)],
595
+ [
596
+ 'poll status',
597
+ colors.info(`dcd status --upload-id ${results[0].test_upload_id}`),
598
+ ],
599
+ ]),
600
+ ]));
569
601
  if (async) {
570
602
  if (debug) {
571
603
  out(`[DEBUG] Async flag is set, not waiting for results`);
@@ -585,7 +617,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
585
617
  };
586
618
  if (jsonFileFlag) {
587
619
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
588
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
620
+ writeJSONFile(jsonFilePath, jsonOutput, {
589
621
  log: (m) => out(m),
590
622
  warn: (m) => warnOut(m),
591
623
  });
@@ -595,7 +627,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
595
627
  console.log(JSON.stringify(jsonOutput, null, 2));
596
628
  return;
597
629
  }
598
- out(`\n${styling_1.symbols.info} ${styling_1.colors.dim('Not waiting for results as async flag is set to true')}\n`);
630
+ out(ui.info('Not waiting for results as async flag is set to true'));
599
631
  return;
600
632
  }
601
633
  const pollingResult = await resultsPollingService
@@ -610,11 +642,11 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
610
642
  uploadId: results[0].test_upload_id,
611
643
  }, testMetadataMap)
612
644
  .catch(async (error) => {
613
- if (error instanceof results_polling_service_1.RunFailedError) {
645
+ if (error instanceof RunFailedError) {
614
646
  const jsonOutput = error.result;
615
647
  if (jsonFileFlag) {
616
648
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
617
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
649
+ writeJSONFile(jsonFilePath, jsonOutput, {
618
650
  log: (m) => out(m),
619
651
  warn: (m) => warnOut(m),
620
652
  });
@@ -690,7 +722,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
690
722
  const jsonOutput = pollingResult;
691
723
  if (jsonFileFlag) {
692
724
  const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`;
693
- (0, methods_1.writeJSONFile)(jsonFilePath, jsonOutput, {
725
+ writeJSONFile(jsonFilePath, jsonOutput, {
694
726
  log: (m) => out(m),
695
727
  warn: (m) => warnOut(m),
696
728
  });
@@ -709,7 +741,7 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
709
741
  caughtError = error;
710
742
  }
711
743
  finally {
712
- const fsp = await Promise.resolve().then(() => require('node:fs/promises'));
744
+ const fsp = await import('node:fs/promises');
713
745
  for (const p of tempFiles) {
714
746
  await fsp.rm(p, { recursive: true, force: true }).catch(() => { });
715
747
  }
@@ -723,12 +755,12 @@ exports.cloudCommand = (0, citty_1.defineCommand)({
723
755
  // --json-file keeps exit 0 on a failed run (documented contract);
724
756
  // otherwise 2 distinguishes test failure from infra errors (1).
725
757
  const exitCode = jsonFile ? 0 : 2;
726
- telemetry_service_1.telemetry.recordCommandFailure({ error: 'RUN_FAILED', exitCode });
727
- telemetry_service_1.telemetry.flushSync();
758
+ telemetry.recordCommandFailure({ error: 'RUN_FAILED', exitCode });
759
+ telemetry.flushSync();
728
760
  process.exit(exitCode);
729
761
  }
730
- cli_1.logger.error(caughtError, { exit: 1, json: jsonFlag });
762
+ logger.error(caughtError, { exit: 1, json: jsonFlag });
731
763
  }
732
764
  },
733
765
  });
734
- exports.default = exports.cloudCommand;
766
+ export default cloudCommand;