@devicecloud.dev/dcd 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,7 +136,7 @@ export declare const cloudCommand: import("citty").CommandDef<{
136
136
  readonly 'runner-type': {
137
137
  readonly type: "string";
138
138
  readonly default: "default";
139
- readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers, see https://docs.devicecloud.dev/configuration/runner-type for more information.";
139
+ readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers; gpu1 is deprecated and will be removed on 9 November 2026 (gpu1 runs will then use cpu1). See https://docs.devicecloud.dev/configuration/runner-type for more information.";
140
140
  };
141
141
  readonly env: {
142
142
  readonly type: "string";
@@ -237,7 +237,8 @@ export declare const cloudCommand: import("citty").CommandDef<{
237
237
  };
238
238
  readonly encrypt: {
239
239
  readonly type: "boolean";
240
- readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
240
+ readonly hidden: true;
241
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.";
241
242
  };
242
243
  readonly 'api-key': {
243
244
  readonly type: "string";
@@ -17,7 +17,7 @@ import { VersionService } from '../services/version.service.js';
17
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
- import { isEncryptionEnabled } from '../utils/envelope.js';
20
+ import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js';
21
21
  import { detectCiContext, isCI } from '../utils/ci.js';
22
22
  import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
23
23
  import { fetchCompatibilityData, } from '../utils/compatibility.js';
@@ -135,10 +135,11 @@ export const cloudCommand = defineCommand({
135
135
  const cancelPrevious = Boolean(args['cancel-previous']);
136
136
  const googlePlay = Boolean(args['google-play']);
137
137
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
138
- // Single opt-in for client-side envelope encryption of every sensitive
139
- // artifact — the binary (#1138), the flow zip (#1151), and env vars
140
- // (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
141
- const encrypt = isEncryptionEnabled(args['encrypt'] ? true : undefined);
138
+ // Client-side envelope encryption of every sensitive artifact: the
139
+ // binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as
140
+ // given (--no-encrypt is an explicit false) and resolved once the
141
+ // compatibility data, which carries the org's default, is in.
142
+ const encryptFlag = args.encrypt;
142
143
  const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
143
144
  // Devices and OS versions are checked against the API's live
144
145
  // compatibility data further down (DeviceValidationService), not the
@@ -257,6 +258,17 @@ export const cloudCommand = defineCommand({
257
258
  if (debug) {
258
259
  out(`[DEBUG] API URL: ${apiUrl}`);
259
260
  }
261
+ const encryption = checkKekForEncryption(resolveEncryption({
262
+ flag: encryptFlag,
263
+ serverDefault: compatibilityData.encryption?.defaultOn,
264
+ }), apiUrl);
265
+ const encrypt = encryption.enabled;
266
+ if (encryption.notice)
267
+ out(ui.note(encryption.notice));
268
+ telemetry.recordEncryption({ source: encryption.source });
269
+ if (debug) {
270
+ out(`[DEBUG] Encryption: ${encrypt ? 'on' : 'off'} (${encryption.source})`);
271
+ }
260
272
  const resolvedMaestroVersion = versionService.resolveMaestroVersion(maestroVersion, compatibilityData, {
261
273
  debug,
262
274
  logger: (m) => out(m),
@@ -271,9 +283,6 @@ export const cloudCommand = defineCommand({
271
283
  if (runnerType === 'm1') {
272
284
  out(ui.info('runnerType m1 is experimental and currently supports Android only (all devices, API level 34-36).'));
273
285
  }
274
- if (runnerType === 'gpu1') {
275
- out(ui.info('runnerType gpu1 is Android-only (all devices, API level 34+), available to all users.'));
276
- }
277
286
  const firstFile = args.firstFile;
278
287
  const secondFile = args.secondFile;
279
288
  let finalBinaryId = appBinaryId;
@@ -342,6 +351,9 @@ export const cloudCommand = defineCommand({
342
351
  // customer relied on the default), so a notice can target either.
343
352
  maestro_version: resolvedMaestroVersion,
344
353
  requested_maestro_version: maestroVersion,
354
+ // As requested ('default' when not passed), not as the API resolves
355
+ // it: the gpu1 retirement notice is aimed at runs that ask for gpu1.
356
+ runner_type: runnerType,
345
357
  cli_version: cliVersion,
346
358
  ci_provider: ciContext.provider,
347
359
  ci_wrapper_version: ciContext.wrapperVersion,
@@ -521,6 +533,9 @@ export const cloudCommand = defineCommand({
521
533
  }
522
534
  return;
523
535
  }
536
+ if (encrypt && encryption.source === 'server') {
537
+ out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt'));
538
+ }
524
539
  if (!finalBinaryId) {
525
540
  if (!finalAppFile) {
526
541
  throw new CliError('You must provide either an app binary id or an app file');
@@ -538,6 +553,7 @@ export const cloudCommand = defineCommand({
538
553
  log: !json,
539
554
  });
540
555
  finalBinaryId = binaryId;
556
+ telemetry.recordEncryption({ binary: encrypt });
541
557
  if (debug) {
542
558
  out(`[DEBUG] Binary uploaded with ID: ${binaryId}`);
543
559
  }
@@ -593,6 +609,7 @@ export const cloudCommand = defineCommand({
593
609
  disableAnimations,
594
610
  renderEngine,
595
611
  });
612
+ telemetry.recordEncryption({ env: encrypt && env.length > 0, flow: encrypt });
596
613
  // Device-matrix cost preview: the server prices the exact fan-out (quote
597
614
  // == charge) so the user sees the cell count and estimated cost before the
598
615
  // flow zip is uploaded. An unsupported cell fails fast here. Skipped when
@@ -9,7 +9,8 @@ export declare const uploadCommand: import("citty").CommandDef<{
9
9
  };
10
10
  readonly encrypt: {
11
11
  readonly type: "boolean";
12
- readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
12
+ readonly hidden: true;
13
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.";
13
14
  };
14
15
  readonly debug: {
15
16
  readonly type: "boolean";
@@ -4,10 +4,13 @@ import { apiFlags } from '../config/flags/api.flags.js';
4
4
  import { binaryFlags } from '../config/flags/binary.flags.js';
5
5
  import { outputFlags } from '../config/flags/output.flags.js';
6
6
  import { uploadBinary, verifyAppZip } from '../methods.js';
7
+ import { telemetry } from '../services/telemetry.service.js';
7
8
  import { resolveAuth } from '../utils/auth.js';
8
- import { CliError, logger } from '../utils/cli.js';
9
+ import { detectCiContext } from '../utils/ci.js';
10
+ import { CliError, getCliVersion, logger } from '../utils/cli.js';
11
+ import { fetchCompatibilityData } from '../utils/compatibility.js';
9
12
  import { resolveApiUrl } from '../utils/config-store.js';
10
- import { isEncryptionEnabled } from '../utils/envelope.js';
13
+ import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js';
11
14
  import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
12
15
  import { colors, formatId } from '../utils/styling.js';
13
16
  import { ui } from '../utils/ui.js';
@@ -42,13 +45,34 @@ export const uploadCommand = defineCommand({
42
45
  const apiUrl = resolveApiUrl(args['api-url']);
43
46
  const appUrl = args['app-url'];
44
47
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
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);
48
+ // Kept as given, so --no-encrypt is an explicit false and a missing flag
49
+ // leaves DCD_ENCRYPT and then the org's default to decide.
50
+ const encryptFlag = args.encrypt;
49
51
  const debug = Boolean(args.debug);
50
52
  const positional = args.appFile;
51
53
  const auth = await resolveAuth({ apiKeyFlag });
54
+ // Same order as `dcd cloud`, whose compatibility fetch carries the org's
55
+ // encryption default. `dcd upload` never needed that data before, so a
56
+ // failed fetch leaves the default off rather than failing the upload.
57
+ let serverDefault;
58
+ try {
59
+ const ciContext = detectCiContext();
60
+ const compatibilityData = await fetchCompatibilityData(apiUrl, auth, {
61
+ cliVersion: getCliVersion(),
62
+ ciProvider: ciContext.provider,
63
+ ciWrapperVersion: ciContext.wrapperVersion,
64
+ });
65
+ serverDefault = compatibilityData.encryption?.defaultOn;
66
+ }
67
+ catch (error) {
68
+ if (debug) {
69
+ out(`[DEBUG] Failed to fetch compatibility data, so the org's encryption default is off: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ }
72
+ const encryption = checkKekForEncryption(resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl);
73
+ if (encryption.notice)
74
+ out(ui.note(encryption.notice));
75
+ telemetry.recordEncryption({ source: encryption.source });
52
76
  let resolvedFile = appUrl ?? positional;
53
77
  if (!resolvedFile) {
54
78
  throw new CliError('You must provide an app file path or a signed Expo URL via --app-url');
@@ -75,16 +99,22 @@ export const uploadCommand = defineCommand({
75
99
  await verifyAppZip(resolvedFile);
76
100
  }
77
101
  out(ui.section('Uploading app binary'));
78
- out(ui.branch(ui.fields([['file', colors.highlight(resolvedFile)]])));
102
+ out(ui.branch([
103
+ ...ui.fields([['file', colors.highlight(resolvedFile)]]),
104
+ ...(encryption.enabled && encryption.source === 'server'
105
+ ? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')]
106
+ : []),
107
+ ]));
79
108
  const appBinaryId = await uploadBinary({
80
109
  auth,
81
110
  apiUrl,
82
111
  debug,
83
- encrypt: encryptBinary,
112
+ encrypt: encryption.enabled,
84
113
  filePath: resolvedFile,
85
114
  ignoreShaCheck,
86
115
  log: !json,
87
116
  });
117
+ telemetry.recordEncryption({ binary: encryption.enabled });
88
118
  if (json) {
89
119
  // eslint-disable-next-line no-console
90
120
  console.log(JSON.stringify({ appBinaryId }, null, 2));
@@ -40,6 +40,15 @@ export declare const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment>;
40
40
  export declare function findEnvByApiUrl(apiUrl: string): DcdEnvironment | undefined;
41
41
  /** Map a caller-supplied API URL to one of the known environments. */
42
42
  export declare function inferEnvFromApiUrl(apiUrl: string): DcdEnvName;
43
+ /**
44
+ * The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138),
45
+ * or undefined when the URL isn't a known one. Stricter than
46
+ * {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is
47
+ * harmless for picking a console link, but an upload encrypted for a KEK its
48
+ * API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or
49
+ * `localhost`, which runs against dev.
50
+ */
51
+ export declare function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined;
43
52
  /**
44
53
  * Frontend URL to open for a given API URL. Same as
45
54
  * `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the
@@ -42,6 +42,27 @@ export function inferEnvFromApiUrl(apiUrl) {
42
42
  return 'dev';
43
43
  return 'prod';
44
44
  }
45
+ /**
46
+ * The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138),
47
+ * or undefined when the URL isn't a known one. Stricter than
48
+ * {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is
49
+ * harmless for picking a console link, but an upload encrypted for a KEK its
50
+ * API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or
51
+ * `localhost`, which runs against dev.
52
+ */
53
+ export function kekEnvForApiUrl(apiUrl) {
54
+ let host;
55
+ try {
56
+ host = new URL(apiUrl).hostname;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ if (host === 'localhost')
62
+ return 'dev';
63
+ const names = Object.keys(ENVIRONMENTS);
64
+ return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host);
65
+ }
45
66
  /**
46
67
  * Frontend URL to open for a given API URL. Same as
47
68
  * `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the
@@ -21,6 +21,7 @@ export declare const binaryFlags: {
21
21
  };
22
22
  readonly encrypt: {
23
23
  readonly type: "boolean";
24
- readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
24
+ readonly hidden: true;
25
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.";
25
26
  };
26
27
  };
@@ -21,6 +21,9 @@ export const binaryFlags = {
21
21
  },
22
22
  encrypt: {
23
23
  type: 'boolean',
24
- description: 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.',
24
+ // Left out of --help while encryption is a per-org beta (dcd#1138); the
25
+ // flag still parses, --no-encrypt included.
26
+ hidden: true,
27
+ description: 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.',
25
28
  },
26
29
  };
@@ -40,6 +40,6 @@ export declare const executionFlags: {
40
40
  readonly 'runner-type': {
41
41
  readonly type: "string";
42
42
  readonly default: "default";
43
- readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers, see https://docs.devicecloud.dev/configuration/runner-type for more information.";
43
+ readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers; gpu1 is deprecated and will be removed on 9 November 2026 (gpu1 runs will then use cpu1). See https://docs.devicecloud.dev/configuration/runner-type for more information.";
44
44
  };
45
45
  };
@@ -40,6 +40,6 @@ export const executionFlags = {
40
40
  'runner-type': {
41
41
  type: 'string',
42
42
  default: 'default',
43
- description: '[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers, see https://docs.devicecloud.dev/configuration/runner-type for more information.',
43
+ description: '[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers; gpu1 is deprecated and will be removed on 9 November 2026 (gpu1 runs will then use cpu1). See https://docs.devicecloud.dev/configuration/runner-type for more information.',
44
44
  },
45
45
  };
@@ -122,7 +122,7 @@ export declare const flags: {
122
122
  readonly 'runner-type': {
123
123
  readonly type: "string";
124
124
  readonly default: "default";
125
- readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers, see https://docs.devicecloud.dev/configuration/runner-type for more information.";
125
+ readonly description: "[experimental] The type of runner to use (options: default, m4, m1, gpu1, cpu1) - note: anything other than default or cpu1 will incur premium pricing tiers; gpu1 is deprecated and will be removed on 9 November 2026 (gpu1 runs will then use cpu1). See https://docs.devicecloud.dev/configuration/runner-type for more information.";
126
126
  };
127
127
  readonly env: {
128
128
  readonly type: "string";
@@ -223,7 +223,8 @@ export declare const flags: {
223
223
  };
224
224
  readonly encrypt: {
225
225
  readonly type: "boolean";
226
- readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
226
+ readonly hidden: true;
227
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.";
227
228
  };
228
229
  readonly 'api-key': {
229
230
  readonly type: "string";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { uploadCommand } from './commands/upload.js';
14
14
  import { whoamiCommand } from './commands/whoami.js';
15
15
  import { telemetry } from './services/telemetry.service.js';
16
16
  import { CliError, getCliVersion, logger } from './utils/cli.js';
17
+ import { withoutHiddenArgs } from './utils/help.js';
17
18
  // @clack/prompts ships the US spelling ("Canceled") for its built-in
18
19
  // spinner/prompt cancellation message; align it with the British spelling
19
20
  // ("Cancelled") used everywhere else in the CLI.
@@ -65,12 +66,17 @@ async function resolveSubCommand(cmd, rawArgs, parent) {
65
66
  }
66
67
  return [cmd, parent];
67
68
  }
69
+ // Usage for the (sub)command named in rawArgs, without the args marked hidden.
70
+ async function showHelp(rawArgs) {
71
+ const [cmd, parent] = await resolveSubCommand(main, rawArgs);
72
+ await showUsage(await withoutHiddenArgs(cmd), parent);
73
+ }
68
74
  async function run() {
69
75
  const rawArgs = process.argv.slice(2);
70
76
  telemetry.recordCommandStart();
71
77
  try {
72
78
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
73
- await showUsage(...(await resolveSubCommand(main, rawArgs)));
79
+ await showHelp(rawArgs);
74
80
  }
75
81
  else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
76
82
  logger.log(getCliVersion());
@@ -85,7 +91,7 @@ async function run() {
85
91
  // citty throws CLIError (by name — the class isn't exported) for usage
86
92
  // problems like unknown commands or missing required args.
87
93
  if (error instanceof Error && error.name === 'CLIError') {
88
- await showUsage(...(await resolveSubCommand(main, rawArgs)));
94
+ await showHelp(rawArgs);
89
95
  }
90
96
  const exitCode = error instanceof CliError ? error.exitCode : 1;
91
97
  // logger.error prints the message, records failure telemetry, flushes
@@ -11,6 +11,8 @@ export declare function errorResult(message: string): CallToolResult;
11
11
  /**
12
12
  * Wrap a tool handler: emit start/success/failure telemetry, flush it
13
13
  * (fire-and-forget — the process outlives the call), and translate thrown
14
- * errors into an `isError` result instead of rejecting.
14
+ * errors into an `isError` result instead of rejecting. Fields the handler
15
+ * puts on `telemetryExtra` ride on that call's completed or failed event; it
16
+ * is per call, since one long-lived server can run several tools at once.
15
17
  */
16
- export declare function runTool(name: string, fn: () => Promise<CallToolResult>): Promise<CallToolResult>;
18
+ export declare function runTool(name: string, fn: (telemetryExtra: Record<string, unknown>) => Promise<CallToolResult>): Promise<CallToolResult>;
@@ -15,19 +15,22 @@ export function errorResult(message) {
15
15
  /**
16
16
  * Wrap a tool handler: emit start/success/failure telemetry, flush it
17
17
  * (fire-and-forget — the process outlives the call), and translate thrown
18
- * errors into an `isError` result instead of rejecting.
18
+ * errors into an `isError` result instead of rejecting. Fields the handler
19
+ * puts on `telemetryExtra` ride on that call's completed or failed event; it
20
+ * is per call, since one long-lived server can run several tools at once.
19
21
  */
20
22
  export async function runTool(name, fn) {
21
23
  const start = Date.now();
24
+ const telemetryExtra = {};
22
25
  telemetry.recordMcpToolStart(name);
23
26
  try {
24
- const result = await fn();
25
- telemetry.recordMcpToolSuccess(name, Date.now() - start);
27
+ const result = await fn(telemetryExtra);
28
+ telemetry.recordMcpToolSuccess(name, Date.now() - start, telemetryExtra);
26
29
  void telemetry.flush();
27
30
  return result;
28
31
  }
29
32
  catch (error) {
30
- telemetry.recordMcpToolFailure(name, error, Date.now() - start);
33
+ telemetry.recordMcpToolFailure(name, error, Date.now() - start, telemetryExtra);
31
34
  void telemetry.flush();
32
35
  return errorResult(error instanceof Error ? error.message : String(error));
33
36
  }
@@ -11,7 +11,7 @@ import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
11
11
  import { refreshAuth } from '../../utils/auth.js';
12
12
  import { getCliVersion } from '../../utils/cli.js';
13
13
  import { fetchCompatibilityData } from '../../utils/compatibility.js';
14
- import { isEncryptionEnabled } from '../../utils/envelope.js';
14
+ import { checkKekForEncryption, resolveEncryption } from '../../utils/envelope.js';
15
15
  import { getConsoleUrl } from '../../utils/styling.js';
16
16
  import { getContext, logStderr } from '../context.js';
17
17
  import { jsonResult, runTool } from '../helpers.js';
@@ -69,13 +69,17 @@ export function registerRunCloudTest(server) {
69
69
  runnerType: z
70
70
  .enum(['default', 'm4', 'm1', 'gpu1', 'cpu1'])
71
71
  .optional()
72
- .describe('Runner type (default "default")'),
72
+ .describe('Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.'),
73
73
  configFile: z.string().optional().describe('Path to a workspace config.yaml'),
74
74
  ignoreShaCheck: z
75
75
  .boolean()
76
76
  .optional()
77
77
  .describe('Force re-upload of the binary even if an identical one exists'),
78
78
  dryRun: z.boolean().optional().describe('Preview flows that would run without submitting'),
79
+ encrypt: z
80
+ .boolean()
81
+ .optional()
82
+ .describe('true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.'),
79
83
  wait: z
80
84
  .boolean()
81
85
  .optional()
@@ -94,7 +98,7 @@ export function registerRunCloudTest(server) {
94
98
  destructiveHint: true,
95
99
  openWorldHint: true,
96
100
  },
97
- }, async (args) => runTool('dcd_run_cloud_test', async () => {
101
+ }, async (args) => runTool('dcd_run_cloud_test', async (telemetryExtra) => {
98
102
  if (args.appFile && args.appBinaryId) {
99
103
  throw new Error('Provide only one of appFile or appBinaryId, not both.');
100
104
  }
@@ -142,9 +146,26 @@ export function registerRunCloudTest(server) {
142
146
  sequentialFlowCount: executionPlan.sequence?.flows.length ?? 0,
143
147
  });
144
148
  }
145
- // Client-side envelope encryption (binary/flow/env). No MCP flag, so
146
- // it's env-driven: DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
147
- const encrypt = isEncryptionEnabled();
149
+ // Client-side envelope encryption (binary/flow/env), in the same order
150
+ // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default.
151
+ const encryption = checkKekForEncryption(resolveEncryption({
152
+ flag: args.encrypt,
153
+ serverDefault: compatibilityData.encryption?.defaultOn,
154
+ }), apiUrl);
155
+ const encrypt = encryption.enabled;
156
+ if (encryption.notice)
157
+ logStderr(encryption.notice);
158
+ // Filled in as each part is encrypted; sent on this call's telemetry.
159
+ const encryptTelemetry = {
160
+ binary: false,
161
+ env: false,
162
+ flow: false,
163
+ source: encryption.source,
164
+ };
165
+ telemetryExtra.encrypt = encryptTelemetry;
166
+ if (encrypt && encryption.source === 'server') {
167
+ logStderr('Encrypting uploads (beta); opt out with encrypt: false');
168
+ }
148
169
  // Resolve the binary: existing id, or upload the local file.
149
170
  let appBinaryId = args.appBinaryId;
150
171
  if (!appBinaryId) {
@@ -165,6 +186,7 @@ export function registerRunCloudTest(server) {
165
186
  ignoreShaCheck: Boolean(args.ignoreShaCheck),
166
187
  log: false,
167
188
  });
189
+ encryptTelemetry.binary = encrypt;
168
190
  }
169
191
  const { continueOnFailure = true } = executionPlan.sequence ?? {};
170
192
  const testSubmissionService = new TestSubmissionService();
@@ -191,6 +213,8 @@ export function registerRunCloudTest(server) {
191
213
  retry: args.retry,
192
214
  logger: logStderr,
193
215
  });
216
+ encryptTelemetry.flow = encrypt;
217
+ encryptTelemetry.env = encrypt && (args.env?.length ?? 0) > 0;
194
218
  // New path: upload the zip to storage, then submit a JSON test
195
219
  // referencing it. Older API deployments lack these endpoints (404/405);
196
220
  // fall back to the legacy multipart POST /uploads/flow. Mirrors
package/dist/methods.d.ts CHANGED
@@ -7,10 +7,10 @@ interface UploadBinaryConfig {
7
7
  debug?: boolean;
8
8
  /**
9
9
  * Encrypt the binary before upload (client-side envelope encryption, #1138).
10
- * Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see
11
- * {@link isEncryptionEnabled}).
10
+ * Required: callers pass the decision from `resolveEncryption`, so nothing
11
+ * here falls back to a default of its own.
12
12
  */
13
- encrypt?: boolean;
13
+ encrypt: boolean;
14
14
  filePath: string;
15
15
  ignoreShaCheck?: boolean;
16
16
  log?: boolean;
package/dist/methods.js CHANGED
@@ -10,8 +10,8 @@ import * as yazl from 'yazl';
10
10
  import { inferEnvFromApiUrl } from './config/environments.js';
11
11
  import { ApiError, ApiGateway } from './gateways/api-gateway.js';
12
12
  import { SupabaseGateway } from './gateways/supabase-gateway.js';
13
- import { MetadataExtractorService } from './services/metadata-extractor.service.js';
14
- import { encryptFileToPath, generateDek, isEncryptionEnabled, resolveKekPublicKey, wrapDek, } from './utils/envelope.js';
13
+ import { detectFlutterEngine, MetadataExtractorService, } from './services/metadata-extractor.service.js';
14
+ import { encryptFileToPath, generateDek, resolveKekPublicKey, wrapDek, } from './utils/envelope.js';
15
15
  import { colors, formatId } from './utils/styling.js';
16
16
  const mimeTypeLookupByExtension = {
17
17
  apk: 'application/vnd.android.package-archive',
@@ -94,8 +94,7 @@ export const verifyAppZip = async (zipPath) => {
94
94
  }
95
95
  };
96
96
  export const uploadBinary = async (config) => {
97
- const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
98
- const encrypt = isEncryptionEnabled(config.encrypt);
97
+ const { filePath, apiUrl, auth, encrypt, ignoreShaCheck = false, log = true, debug = false } = config;
99
98
  if (log) {
100
99
  ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
101
100
  stdout: true,
@@ -199,8 +198,8 @@ export const uploadBinary = async (config) => {
199
198
  async function encryptUploadSource(source, apiUrl, debug) {
200
199
  const kek = resolveKekPublicKey(apiUrl);
201
200
  if (!kek) {
202
- throw new Error('Binary encryption was requested but no KEK public key is configured for this environment. ' +
203
- 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
201
+ throw new Error('Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' +
202
+ 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64>, or pass --no-encrypt to upload without encryption.');
204
203
  }
205
204
  const dek = generateDek();
206
205
  const enc = wrapDek(dek, kek);
@@ -359,7 +358,7 @@ async function checkExistingUpload(apiUrl, auth, lookup, debug) {
359
358
  }
360
359
  if (exists && lookup.encrypted && encrypted !== true) {
361
360
  if (debug) {
362
- console.log('[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted');
361
+ console.log('[DEBUG] Ignoring dedup hit: encryption is on but the matched binary is not encrypted');
363
362
  }
364
363
  return { exists: false };
365
364
  }
@@ -597,6 +596,17 @@ async function performUpload(config) {
597
596
  const metadata = await extractBinaryMetadata(filePath, debug);
598
597
  if (enc) {
599
598
  metadata.enc = enc;
599
+ // The API can only look for the Flutter engine in a plaintext APK, so an
600
+ // encrypted one carries the verdict the API would have reached itself.
601
+ if (metadata.platform === 'android') {
602
+ const usesFlutterEngine = await detectFlutterEngine(filePath);
603
+ if (usesFlutterEngine !== undefined) {
604
+ metadata.usesFlutterEngine = usesFlutterEngine;
605
+ }
606
+ if (debug) {
607
+ console.log(`[DEBUG] Flutter engine: ${usesFlutterEngine ?? 'unknown'}`);
608
+ }
609
+ }
600
610
  }
601
611
  const env = inferEnvFromApiUrl(apiUrl);
602
612
  // Upload to Backblaze first (primary)
@@ -16,6 +16,25 @@ export declare class AndroidMetadataExtractor implements IMetadataExtractor {
16
16
  canHandle(filePath: string): boolean;
17
17
  extract(filePath: string): Promise<TAppMetadata>;
18
18
  }
19
+ /**
20
+ * The entry that marks an APK as shipping the Flutter engine: the x86_64
21
+ * build, the one an emulator loads. Must match FLUTTER_ENGINE_ENTRY in the
22
+ * dcd API (api/src/apps/uploads/flutter-detection.ts).
23
+ */
24
+ export declare const FLUTTER_ENGINE_ENTRY = "lib/x86_64/libflutter.so";
25
+ /**
26
+ * Whether the APK at `apkPath` ships the Flutter engine, read from the zip's
27
+ * central directory. The API works this out itself for a plaintext binary
28
+ * but can't see inside an encrypted one, so an encrypted upload sends the
29
+ * verdict in its metadata (dcd#1138). Without it a Flutter app runs on the
30
+ * lavapipe renderer, which crashes it.
31
+ *
32
+ * Same rule as the API, which byte-searches the central directory for the
33
+ * entry name, so a name that contains it counts too: an APK then gets the
34
+ * same renderer whether or not it was encrypted. Undefined when the APK
35
+ * can't be read, which the API also treats as unknown.
36
+ */
37
+ export declare function detectFlutterEngine(apkPath: string): Promise<boolean | undefined>;
19
38
  /**
20
39
  * Extracts metadata from iOS .app directories
21
40
  */
@@ -46,6 +46,37 @@ export class AndroidMetadataExtractor {
46
46
  }
47
47
  }
48
48
  }
49
+ /**
50
+ * The entry that marks an APK as shipping the Flutter engine: the x86_64
51
+ * build, the one an emulator loads. Must match FLUTTER_ENGINE_ENTRY in the
52
+ * dcd API (api/src/apps/uploads/flutter-detection.ts).
53
+ */
54
+ export const FLUTTER_ENGINE_ENTRY = 'lib/x86_64/libflutter.so';
55
+ /**
56
+ * Whether the APK at `apkPath` ships the Flutter engine, read from the zip's
57
+ * central directory. The API works this out itself for a plaintext binary
58
+ * but can't see inside an encrypted one, so an encrypted upload sends the
59
+ * verdict in its metadata (dcd#1138). Without it a Flutter app runs on the
60
+ * lavapipe renderer, which crashes it.
61
+ *
62
+ * Same rule as the API, which byte-searches the central directory for the
63
+ * entry name, so a name that contains it counts too: an APK then gets the
64
+ * same renderer whether or not it was encrypted. Undefined when the APK
65
+ * can't be read, which the API also treats as unknown.
66
+ */
67
+ export async function detectFlutterEngine(apkPath) {
68
+ const zip = new StreamZip.async({ file: apkPath });
69
+ try {
70
+ const names = Object.keys(await zip.entries());
71
+ return names.some((name) => name.includes(FLUTTER_ENGINE_ENTRY));
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ finally {
77
+ await zip.close().catch(() => { });
78
+ }
79
+ }
49
80
  /**
50
81
  * Extracts metadata from iOS .app directories
51
82
  */