@devicecloud.dev/dcd 5.6.0-beta.3 → 5.6.0-beta.4

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 (39) hide show
  1. package/README.md +4 -2
  2. package/dist/commands/artifacts.d.ts +1 -1
  3. package/dist/commands/artifacts.js +1 -1
  4. package/dist/commands/cloud.d.ts +5 -5
  5. package/dist/commands/cloud.js +14 -6
  6. package/dist/commands/switch-org.js +5 -1
  7. package/dist/commands/upload.js +5 -1
  8. package/dist/commands/whoami.d.ts +7 -1
  9. package/dist/commands/whoami.js +26 -2
  10. package/dist/config/flags/device.flags.d.ts +3 -3
  11. package/dist/config/flags/device.flags.js +5 -3
  12. package/dist/config/flags/execution.flags.d.ts +1 -1
  13. package/dist/config/flags/execution.flags.js +1 -1
  14. package/dist/config/flags/output.flags.d.ts +1 -1
  15. package/dist/config/flags/output.flags.js +1 -1
  16. package/dist/constants.d.ts +5 -5
  17. package/dist/gateways/api-gateway.js +2 -1
  18. package/dist/gateways/realtime-gateway.d.ts +7 -0
  19. package/dist/gateways/realtime-gateway.js +12 -1
  20. package/dist/mcp/context.d.ts +14 -3
  21. package/dist/mcp/context.js +15 -3
  22. package/dist/mcp/tools/download-artifacts.js +9 -6
  23. package/dist/mcp/tools/list-devices.js +1 -1
  24. package/dist/mcp/tools/run-cloud-test.js +19 -3
  25. package/dist/services/device-validation.service.d.ts +17 -2
  26. package/dist/services/device-validation.service.js +93 -12
  27. package/dist/services/notices.service.js +3 -0
  28. package/dist/services/report-download.service.js +9 -3
  29. package/dist/services/results-polling.service.d.ts +21 -0
  30. package/dist/services/results-polling.service.js +55 -3
  31. package/dist/services/test-submission.service.d.ts +11 -0
  32. package/dist/services/test-submission.service.js +12 -5
  33. package/dist/types/domain/auth.types.d.ts +6 -0
  34. package/dist/types/domain/device.types.d.ts +18 -4
  35. package/dist/types/domain/device.types.js +17 -3
  36. package/dist/types/generated/schema.types.d.ts +127 -167
  37. package/dist/utils/auth.d.ts +33 -0
  38. package/dist/utils/auth.js +61 -2
  39. package/package.json +2 -2
package/README.md CHANGED
@@ -45,13 +45,15 @@ Add it to your MCP client config:
45
45
  "mcpServers": {
46
46
  "devicecloud": {
47
47
  "command": "npx",
48
- "args": ["-y", "@devicecloud.dev/dcd", "dcd-mcp"],
48
+ "args": ["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp"],
49
49
  "env": { "DEVICE_CLOUD_API_KEY": "<your-api-key>" }
50
50
  }
51
51
  }
52
52
  }
53
53
  ```
54
54
 
55
+ `--package` matters: the package's default bin is `dcd`, so `npx @devicecloud.dev/dcd dcd-mcp` would run `dcd dcd-mcp` (an unknown command) instead of the server.
56
+
55
57
  Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`.
56
58
 
57
59
  **Tools**
@@ -64,7 +66,7 @@ Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd
64
66
  | `dcd_download_artifacts` | Download a run's artifacts/report to disk |
65
67
  | `dcd_run_cloud_test` | Submit a flow to run on the cloud (**billable**) |
66
68
 
67
- **Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` in `args`, or set `DCD_MCP_READONLY=1` in `env`.
69
+ **Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` at the end of `args` (`["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp", "--read-only"]`), or set `DCD_MCP_READONLY=1` in `env`.
68
70
 
69
71
  By default `dcd_run_cloud_test` is async: it returns an `uploadId` immediately, which you poll with `dcd_get_status`. Pass `wait: true` (bounded by `waitTimeoutSeconds`) to block until completion, or `dryRun: true` to preview the flows without submitting.
70
72
 
@@ -27,7 +27,7 @@ export declare const artifactsCommand: import("citty").CommandDef<{
27
27
  };
28
28
  readonly 'html-path': {
29
29
  readonly type: "string";
30
- readonly description: "Custom file path for downloaded HTML report (default: ./report.html)";
30
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)";
31
31
  };
32
32
  readonly 'junit-path': {
33
33
  readonly type: "string";
@@ -41,7 +41,7 @@ export const artifactsCommand = defineCommand({
41
41
  },
42
42
  'html-path': {
43
43
  type: 'string',
44
- description: 'Custom file path for downloaded HTML report (default: ./report.html)',
44
+ description: 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)',
45
45
  },
46
46
  'junit-path': {
47
47
  type: 'string',
@@ -35,7 +35,7 @@ export declare const cloudCommand: import("citty").CommandDef<{
35
35
  };
36
36
  readonly 'html-path': {
37
37
  readonly type: "string";
38
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
38
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
39
39
  };
40
40
  readonly async: {
41
41
  readonly type: "boolean";
@@ -101,7 +101,7 @@ export declare const cloudCommand: import("citty").CommandDef<{
101
101
  readonly 'cancel-previous': {
102
102
  readonly type: "boolean";
103
103
  readonly default: false;
104
- readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
104
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
105
105
  };
106
106
  readonly config: {
107
107
  readonly type: "string";
@@ -211,12 +211,12 @@ export declare const cloudCommand: import("citty").CommandDef<{
211
211
  readonly 'android-no-snapshot': {
212
212
  readonly type: "boolean";
213
213
  readonly default: false;
214
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
214
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
215
215
  };
216
216
  readonly 'disable-animations': {
217
217
  readonly type: "boolean";
218
- readonly default: false;
219
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
218
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
219
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
220
220
  };
221
221
  readonly 'app-binary-id': {
222
222
  readonly type: "string";
@@ -14,7 +14,7 @@ import { deviceFromResultRow, ResultsPollingService, RunFailedError, } from '../
14
14
  import { telemetry } from '../services/telemetry.service.js';
15
15
  import { TestSubmissionService } from '../services/test-submission.service.js';
16
16
  import { VersionService } from '../services/version.service.js';
17
- import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, isIosMatrixConfig, } from '../types/domain/device.types.js';
17
+ import { isIosMatrixConfig } from '../types/domain/device.types.js';
18
18
  import { resolveAuth } from '../utils/auth.js';
19
19
  import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
20
20
  import { isEncryptionEnabled } from '../utils/envelope.js';
@@ -140,10 +140,14 @@ export const cloudCommand = defineCommand({
140
140
  // (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
141
141
  const encrypt = isEncryptionEnabled(args['encrypt'] ? true : undefined);
142
142
  const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
143
- const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
144
- const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
145
- const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
146
- const androidDevice = validateEnum(args['android-device'], Object.values(EAndroidDevices), 'android-device');
143
+ // Devices and OS versions are checked against the API's live
144
+ // compatibility data further down (DeviceValidationService), not the
145
+ // CLI's enums, which only feed --help: a device the API adds must not
146
+ // need a CLI release, and one it drops must fail with the API's list.
147
+ const iOSDevice = args['ios-device'] || undefined;
148
+ const iOSVersion = args['ios-version'] || undefined;
149
+ const androidApiLevel = args['android-api-level'] || undefined;
150
+ const androidDevice = args['android-device'] || undefined;
147
151
  const androidNoSnapshot = Boolean(args['android-no-snapshot']);
148
152
  // Repeatable device-matrix flags: one validated cell each, no cross-product.
149
153
  const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']);
@@ -164,7 +168,9 @@ export const cloudCommand = defineCommand({
164
168
  const renderEngine = validateEnum(args['render-engine'], RENDER_ENGINE_OPTIONS, 'render-engine');
165
169
  const showCrosshairs = Boolean(args['show-crosshairs']);
166
170
  const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']);
167
- const disableAnimations = Boolean(args['disable-animations']);
171
+ // Left undefined when not passed, so config.yaml's per-platform value
172
+ // applies; --no-disable-animations is an explicit false that beats it.
173
+ const disableAnimations = args['disable-animations'];
168
174
  const ghBranch = args.branch;
169
175
  const ghCommitSha = args['commit-sha'];
170
176
  const ghRepoName = args['repo-name'];
@@ -557,6 +563,8 @@ export const cloudCommand = defineCommand({
557
563
  androidNoSnapshot,
558
564
  apiUrl,
559
565
  appBinaryId: finalBinaryId,
566
+ // With --app-binary-id the binary's platform is unknown here.
567
+ appPlatform: appBinaryId ? undefined : platformFromAppFile(finalAppFile),
560
568
  cancelPrevious,
561
569
  cliVersion,
562
570
  commonRoot,
@@ -6,7 +6,7 @@
6
6
  * Without an argument: fetch orgs and prompt the user to pick one.
7
7
  */
8
8
  import { defineCommand } from 'citty';
9
- import { resolveAuth } from '../utils/auth.js';
9
+ import { apiKeyOverride, resolveAuth } from '../utils/auth.js';
10
10
  import { CliError, logger } from '../utils/cli.js';
11
11
  import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js';
12
12
  import { fetchOrgs, pickOrg } from '../utils/orgs.js';
@@ -54,6 +54,10 @@ export const switchOrgCommand = defineCommand({
54
54
  current_org_name: chosen.name,
55
55
  });
56
56
  logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`));
57
+ // The switch only changes the stored session; an exported key still wins.
58
+ if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') {
59
+ logger.log(ui.warn(`${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`));
60
+ }
57
61
  },
58
62
  });
59
63
  /**
@@ -7,6 +7,7 @@ import { uploadBinary, verifyAppZip } from '../methods.js';
7
7
  import { resolveAuth } from '../utils/auth.js';
8
8
  import { CliError, logger } from '../utils/cli.js';
9
9
  import { resolveApiUrl } from '../utils/config-store.js';
10
+ import { isEncryptionEnabled } from '../utils/envelope.js';
10
11
  import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
11
12
  import { colors, formatId } from '../utils/styling.js';
12
13
  import { ui } from '../utils/ui.js';
@@ -41,7 +42,10 @@ export const uploadCommand = defineCommand({
41
42
  const apiUrl = resolveApiUrl(args['api-url']);
42
43
  const appUrl = args['app-url'];
43
44
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
44
- const encryptBinary = Boolean(args['encrypt']);
45
+ // Same rule as `dcd cloud`: the flag turns encryption on, and without it
46
+ // DCD_ENCRYPT decides. Passing `false` here used to mean "explicitly
47
+ // off", which silently beat DCD_ENCRYPT=1 for every `dcd upload`.
48
+ const encryptBinary = isEncryptionEnabled(args['encrypt'] ? true : undefined);
45
49
  const debug = Boolean(args.debug);
46
50
  const positional = args.appFile;
47
51
  const auth = await resolveAuth({ apiKeyFlag });
@@ -1,2 +1,8 @@
1
- export declare const whoamiCommand: import("citty").CommandDef<import("citty").ArgsDef>;
1
+ export declare const whoamiCommand: import("citty").CommandDef<{
2
+ readonly 'api-key': {
3
+ readonly description: "An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session";
4
+ readonly type: "string";
5
+ readonly alias: ["apiKey"];
6
+ };
7
+ }>;
2
8
  export default whoamiCommand;
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * `dcd whoami` — prints the logged-in user + active org from stored config.
3
+ *
4
+ * It reads only the stored `dcd login` session, while every other command
5
+ * prefers an API key from --api-key or DEVICE_CLOUD_API_KEY — so when one is
6
+ * present it says so, rather than implying the session is what they use.
3
7
  */
4
8
  import { defineCommand } from 'citty';
9
+ import { apiFlags } from '../config/flags/api.flags.js';
10
+ import { apiKeyOverride } from '../utils/auth.js';
5
11
  import { logger } from '../utils/cli.js';
6
12
  import { readConfig } from '../utils/config-store.js';
7
13
  import { colors, formatId } from '../utils/styling.js';
@@ -11,10 +17,22 @@ export const whoamiCommand = defineCommand({
11
17
  name: 'whoami',
12
18
  description: 'Show the logged-in user and active organization',
13
19
  },
14
- run() {
20
+ args: {
21
+ 'api-key': {
22
+ ...apiFlags['api-key'],
23
+ description: 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session',
24
+ },
25
+ },
26
+ run({ args }) {
15
27
  const config = readConfig();
28
+ const keySource = apiKeyOverride(args['api-key']);
29
+ const keyName = colors.highlight(keySource ?? '');
16
30
  if (!config?.session) {
17
- logger.log(ui.info(`Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`));
31
+ logger.log(ui.info(keySource === 'DEVICE_CLOUD_API_KEY'
32
+ ? `Not logged in. ${keyName} is set, so dcd commands authenticate with that API key.`
33
+ : keySource === '--api-key'
34
+ ? `Not logged in. Commands given ${keyName} authenticate with that API key.`
35
+ : `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`));
18
36
  return;
19
37
  }
20
38
  // Fallback for sessions stored before we persisted org name alongside id.
@@ -26,6 +44,12 @@ export const whoamiCommand = defineCommand({
26
44
  fields.push(['env', config.env]);
27
45
  logger.log(ui.section('devicecloud.dev'));
28
46
  logger.log(ui.branch(ui.fields(fields)));
47
+ if (keySource === 'DEVICE_CLOUD_API_KEY') {
48
+ logger.log(ui.warn(`${keyName} is set, so other dcd commands authenticate with that API key and its org, not this session. Unset it to use the session.`));
49
+ }
50
+ else if (keySource === '--api-key') {
51
+ logger.log(ui.warn(`Commands given ${keyName} authenticate with that API key and its org, not this session.`));
52
+ }
29
53
  },
30
54
  });
31
55
  export default whoamiCommand;
@@ -56,11 +56,11 @@ export declare const deviceFlags: {
56
56
  readonly 'android-no-snapshot': {
57
57
  readonly type: "boolean";
58
58
  readonly default: false;
59
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
59
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
60
60
  };
61
61
  readonly 'disable-animations': {
62
62
  readonly type: "boolean";
63
- readonly default: false;
64
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
63
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
64
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
65
65
  };
66
66
  };
@@ -61,11 +61,13 @@ export const deviceFlags = {
61
61
  'android-no-snapshot': {
62
62
  type: 'boolean',
63
63
  default: false,
64
- description: '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.',
64
+ description: '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.',
65
65
  },
66
+ // No default: an unset flag must be distinguishable from --no-disable-animations
67
+ // so the platform's disableAnimations in config.yaml applies only when unset.
66
68
  'disable-animations': {
67
69
  type: 'boolean',
68
- default: false,
69
- description: 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.',
70
+ description: 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.',
71
+ negativeDescription: 'Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations',
70
72
  },
71
73
  };
@@ -5,7 +5,7 @@ export declare const executionFlags: {
5
5
  readonly 'cancel-previous': {
6
6
  readonly type: "boolean";
7
7
  readonly default: false;
8
- readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
8
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
9
9
  };
10
10
  readonly config: {
11
11
  readonly type: "string";
@@ -5,7 +5,7 @@ export const executionFlags = {
5
5
  'cancel-previous': {
6
6
  type: 'boolean',
7
7
  default: false,
8
- description: 'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.',
8
+ description: 'Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.',
9
9
  },
10
10
  config: {
11
11
  type: 'string',
@@ -16,7 +16,7 @@ export declare const outputFlags: {
16
16
  };
17
17
  readonly 'html-path': {
18
18
  readonly type: "string";
19
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
19
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
20
20
  };
21
21
  readonly async: {
22
22
  readonly type: "boolean";
@@ -16,7 +16,7 @@ export const outputFlags = {
16
16
  },
17
17
  'html-path': {
18
18
  type: 'string',
19
- description: 'Custom file path for downloaded HTML report (requires --report html, default: ./report.html)',
19
+ description: 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)',
20
20
  },
21
21
  async: {
22
22
  type: 'boolean',
@@ -21,7 +21,7 @@ export declare const flags: {
21
21
  };
22
22
  readonly 'html-path': {
23
23
  readonly type: "string";
24
- readonly description: "Custom file path for downloaded HTML report (requires --report html, default: ./report.html)";
24
+ readonly description: "Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)";
25
25
  };
26
26
  readonly async: {
27
27
  readonly type: "boolean";
@@ -87,7 +87,7 @@ export declare const flags: {
87
87
  readonly 'cancel-previous': {
88
88
  readonly type: "boolean";
89
89
  readonly default: false;
90
- readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
90
+ readonly description: "Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.";
91
91
  };
92
92
  readonly config: {
93
93
  readonly type: "string";
@@ -197,12 +197,12 @@ export declare const flags: {
197
197
  readonly 'android-no-snapshot': {
198
198
  readonly type: "boolean";
199
199
  readonly default: false;
200
- readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.";
200
+ readonly description: "[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.";
201
201
  };
202
202
  readonly 'disable-animations': {
203
203
  readonly type: "boolean";
204
- readonly default: false;
205
- readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.";
204
+ readonly description: "Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.";
205
+ readonly negativeDescription: "Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations";
206
206
  };
207
207
  readonly 'app-binary-id': {
208
208
  readonly type: "string";
@@ -566,7 +566,8 @@ export const ApiGateway = {
566
566
  },
567
567
  html: {
568
568
  endpoint: `/results/${uploadId}/html-report`,
569
- defaultFilename: `report-${uploadId}.html`,
569
+ // A ZIP of report.html plus its assets, not a bare HTML file.
570
+ defaultFilename: `report-${uploadId}.zip`,
570
571
  notFoundMessage: `Upload ID '${uploadId}' not found or no HTML report available for this upload`,
571
572
  errorPrefix: 'Failed to download HTML report',
572
573
  },
@@ -8,6 +8,13 @@ export interface RealtimeResultsSubscription {
8
8
  isConnected(): boolean;
9
9
  /** Tear down the channel and close the socket. Best-effort, never throws. */
10
10
  unsubscribe(): Promise<void>;
11
+ /**
12
+ * Hand the socket a refreshed JWT. RLS authorises the channel with the token
13
+ * it joined with, and the server drops the channel once that expires — so a
14
+ * poll that outlives the token passes the new one on. Best-effort, never
15
+ * throws.
16
+ */
17
+ updateAccessToken(accessToken: string): void;
11
18
  }
12
19
  export interface RealtimeSubscribeOptions {
13
20
  /** Supabase JWT (AuthContext.accessToken) — authorises the socket for RLS. */
@@ -86,6 +86,13 @@ export class RealtimeResultsGateway {
86
86
  /* best effort — process is exiting anyway */
87
87
  }
88
88
  },
89
+ updateAccessToken(nextToken) {
90
+ // setAuth updates the join payload and pushes the token to joined
91
+ // channels. A failure only costs realtime; the backstop poll stays.
92
+ activeClient.realtime.setAuth(nextToken).catch((error) => {
93
+ dbg(`failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`);
94
+ });
95
+ },
89
96
  };
90
97
  }
91
98
  catch (error) {
@@ -97,7 +104,11 @@ export class RealtimeResultsGateway {
97
104
  catch {
98
105
  /* ignore */
99
106
  }
100
- return { isConnected: () => false, async unsubscribe() { } };
107
+ return {
108
+ isConnected: () => false,
109
+ async unsubscribe() { },
110
+ updateAccessToken() { },
111
+ };
101
112
  }
102
113
  }
103
114
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Per-process MCP context: a single resolved AuthContext + API URL, plus the
3
- * stderr logger every tool must use.
2
+ * Per-process MCP context: the resolved AuthContext + API URL (re-resolved
3
+ * when a `dcd login` session nears expiry), plus the stderr logger every tool
4
+ * must use.
4
5
  *
5
6
  * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything
6
7
  * a tool prints there corrupts the protocol. Tools therefore call the services
@@ -15,17 +16,27 @@ export interface McpContext {
15
16
  auth: AuthContext;
16
17
  }
17
18
  /**
18
- * Resolve auth + API URL once per process, lazily on first tool invocation.
19
+ * Resolve auth + API URL lazily on first tool invocation, and again whenever a
20
+ * `dcd login` session is near expiry.
19
21
  *
20
22
  * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
21
23
  * credential (clients enumerate tools on connect), and so an auth failure
22
24
  * surfaces as a tool error rather than crashing the server at boot.
23
25
  *
26
+ * Re-resolved near expiry because a session's access token lasts about an
27
+ * hour while an MCP server can run for days: resolving once left every tool
28
+ * call failing after the first hour. resolveAuth refreshes the stored session
29
+ * (or picks up one another `dcd` process already refreshed). A failed
30
+ * refresh leaves the cache alone, so the next call retries — including after
31
+ * the user runs `dcd login` again. API keys never expire and are resolved once.
32
+ *
24
33
  * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
25
34
  * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
26
35
  * dev/staging), then the logged-in env, then the prod default.
27
36
  */
28
37
  export declare function getContext(): Promise<McpContext>;
38
+ /** Forget the resolved context. For tests. */
39
+ export declare function clearContextCache(): void;
29
40
  /**
30
41
  * Read-only mode hides the only state-changing / billable tool
31
42
  * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
@@ -1,4 +1,4 @@
1
- import { resolveAuth } from '../utils/auth.js';
1
+ import { isAuthExpiring, resolveAuth } from '../utils/auth.js';
2
2
  import { resolveApiUrl } from '../utils/config-store.js';
3
3
  /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */
4
4
  export function logStderr(message) {
@@ -6,24 +6,36 @@ export function logStderr(message) {
6
6
  }
7
7
  let cached = null;
8
8
  /**
9
- * Resolve auth + API URL once per process, lazily on first tool invocation.
9
+ * Resolve auth + API URL lazily on first tool invocation, and again whenever a
10
+ * `dcd login` session is near expiry.
10
11
  *
11
12
  * Lazy so `initialize` / `tools/list` succeed before the user has supplied a
12
13
  * credential (clients enumerate tools on connect), and so an auth failure
13
14
  * surfaces as a tool error rather than crashing the server at boot.
14
15
  *
16
+ * Re-resolved near expiry because a session's access token lasts about an
17
+ * hour while an MCP server can run for days: resolving once left every tool
18
+ * call failing after the first hour. resolveAuth refreshes the stored session
19
+ * (or picks up one another `dcd` process already refreshed). A failed
20
+ * refresh leaves the cache alone, so the next call retries — including after
21
+ * the user runs `dcd login` again. API keys never expire and are resolved once.
22
+ *
15
23
  * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login`
16
24
  * session. The API URL honors `DCD_API_URL` (handy for pointing the server at
17
25
  * dev/staging), then the logged-in env, then the prod default.
18
26
  */
19
27
  export async function getContext() {
20
- if (cached)
28
+ if (cached && !isAuthExpiring(cached.auth))
21
29
  return cached;
22
30
  const auth = await resolveAuth({ apiKeyFlag: undefined });
23
31
  const apiUrl = resolveApiUrl(process.env.DCD_API_URL);
24
32
  cached = { apiUrl, auth };
25
33
  return cached;
26
34
  }
35
+ /** Forget the resolved context. For tests. */
36
+ export function clearContextCache() {
37
+ cached = null;
38
+ }
27
39
  /**
28
40
  * Read-only mode hides the only state-changing / billable tool
29
41
  * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents.
@@ -34,7 +34,7 @@ export function registerDownloadArtifacts(server) {
34
34
  reportPath: z
35
35
  .string()
36
36
  .optional()
37
- .describe('Local path for the report file (defaults depend on report type)'),
37
+ .describe('Local path for the report file (default ./report.xml for junit, ./report.html for allure, ./report.zip for html, which is a ZIP of report.html and its assets)'),
38
38
  },
39
39
  annotations: { readOnlyHint: true, openWorldHint: true },
40
40
  }, async (args) => runTool('dcd_download_artifacts', async () => {
@@ -57,11 +57,14 @@ export function registerDownloadArtifacts(server) {
57
57
  });
58
58
  let reportPath;
59
59
  if (args.report) {
60
- reportPath = args.reportPath
61
- ? path.resolve(args.reportPath)
62
- : path.resolve(args.report === 'junit'
63
- ? 'report.xml'
64
- : 'report.html');
60
+ // The HTML report is a ZIP of report.html plus assets; Allure's is
61
+ // a single HTML file.
62
+ const defaultReportPath = {
63
+ allure: 'report.html',
64
+ html: 'report.zip',
65
+ junit: 'report.xml',
66
+ }[args.report];
67
+ reportPath = path.resolve(args.reportPath || defaultReportPath);
65
68
  await service.downloadReports({
66
69
  apiUrl,
67
70
  auth,
@@ -11,7 +11,7 @@ export function registerListDevices(server) {
11
11
  title: 'List available devices',
12
12
  description: 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' +
13
13
  'Use this to discover valid device/version values before submitting a test run. ' +
14
- 'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.',
14
+ 'Returns: { ios, android, androidPlay, maestro }, where each platform maps a device slug to the OS versions it runs.',
15
15
  inputSchema: {},
16
16
  annotations: { readOnlyHint: true, openWorldHint: true },
17
17
  }, async () => runTool('dcd_list_devices', async () => {
@@ -4,9 +4,11 @@ import { ApiError, ApiGateway } from '../../gateways/api-gateway.js';
4
4
  import { plan } from '../../services/execution-plan.service.js';
5
5
  import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
6
6
  import { DeviceValidationService } from '../../services/device-validation.service.js';
7
+ import { platformFromAppFile } from '../../services/notices.service.js';
7
8
  import { TestSubmissionService } from '../../services/test-submission.service.js';
8
9
  import { VersionService } from '../../services/version.service.js';
9
10
  import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
11
+ import { refreshAuth } from '../../utils/auth.js';
10
12
  import { getCliVersion } from '../../utils/cli.js';
11
13
  import { fetchCompatibilityData } from '../../utils/compatibility.js';
12
14
  import { isEncryptionEnabled } from '../../utils/envelope.js';
@@ -100,8 +102,15 @@ export function registerRunCloudTest(server) {
100
102
  const cliVersion = getCliVersion();
101
103
  const compatibilityData = await fetchCompatibilityData(apiUrl, auth);
102
104
  const deviceValidation = new DeviceValidationService();
103
- deviceValidation.validateiOSDevice(args.iosVersion, args.iosDevice, compatibilityData, { logger: logStderr });
104
- deviceValidation.validateAndroidDevice(args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, { logger: logStderr });
105
+ // Hints in validation errors name this tool's parameters, not CLI flags.
106
+ const argNames = {
107
+ androidApiLevel: 'androidApiLevel',
108
+ androidDevice: 'androidDevice',
109
+ iOSDevice: 'iosDevice',
110
+ iOSVersion: 'iosVersion',
111
+ };
112
+ deviceValidation.validateiOSDevice(args.iosVersion, args.iosDevice, compatibilityData, { argNames, logger: logStderr });
113
+ deviceValidation.validateAndroidDevice(args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, { argNames, logger: logStderr });
105
114
  const resolvedMaestroVersion = new VersionService().resolveMaestroVersion(args.maestroVersion, compatibilityData, { logger: logStderr });
106
115
  // Directory inputs must end in a separator so the planner treats them
107
116
  // as a workspace rather than a single file.
@@ -162,6 +171,8 @@ export function registerRunCloudTest(server) {
162
171
  const { buffer, fields } = await testSubmissionService.buildTestPayload({
163
172
  apiUrl,
164
173
  appBinaryId,
174
+ // Picks the workspace config's per-platform disableAnimations.
175
+ appPlatform: args.appBinaryId ? undefined : platformFromAppFile(args.appFile),
165
176
  cliVersion,
166
177
  commonRoot,
167
178
  continueOnFailure,
@@ -221,8 +232,13 @@ export function registerRunCloudTest(server) {
221
232
  // Bounded poll. The run continues in the cloud regardless of timeout —
222
233
  // the caller can resume with dcd_get_status using the returned uploadId.
223
234
  const deadline = Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000;
235
+ let pollAuth = auth;
224
236
  for (;;) {
225
- const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId });
237
+ // A wait of up to an hour can outlast a `dcd login` access token.
238
+ pollAuth = await refreshAuth(pollAuth);
239
+ const status = await ApiGateway.getUploadStatus(apiUrl, pollAuth, {
240
+ uploadId,
241
+ });
226
242
  if (TERMINAL_STATUSES.has(status.status)) {
227
243
  return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests });
228
244
  }
@@ -1,10 +1,25 @@
1
- import { CompatibilityData } from '../utils/compatibility.js';
1
+ import type { CompatibilityData } from '../utils/compatibility.js';
2
+ /** The device / OS inputs a caller exposes, named for "pass it explicitly" hints. */
3
+ export interface DeviceArgNames {
4
+ androidApiLevel: string;
5
+ androidDevice: string;
6
+ iOSDevice: string;
7
+ iOSVersion: string;
8
+ }
2
9
  export interface DeviceValidationOptions {
10
+ /** How the caller names its inputs in error hints. Defaults to the CLI flags. */
11
+ argNames?: DeviceArgNames;
3
12
  debug?: boolean;
4
13
  logger?: (message: string) => void;
5
14
  }
6
15
  /**
7
- * Service for validating device configurations against compatibility data
16
+ * Validates requested devices against the compatibility matrix the API serves
17
+ * from `GET /results/compatibility/data`, which is the only authority on what
18
+ * can run. Nothing here consults the CLI's device and OS enums
19
+ * (src/types/domain/device.types.ts) — those only feed `--help` — so a device
20
+ * or OS version the API adds is accepted without a CLI release, and one it
21
+ * withdraws is refused, before anything is uploaded, with the list the API
22
+ * offers today.
8
23
  */
9
24
  export declare class DeviceValidationService {
10
25
  /**