@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.
- package/dist/commands/cloud.d.ts +3 -2
- package/dist/commands/cloud.js +25 -8
- package/dist/commands/upload.d.ts +2 -1
- package/dist/commands/upload.js +38 -8
- package/dist/config/environments.d.ts +9 -0
- package/dist/config/environments.js +21 -0
- package/dist/config/flags/binary.flags.d.ts +2 -1
- package/dist/config/flags/binary.flags.js +4 -1
- package/dist/config/flags/execution.flags.d.ts +1 -1
- package/dist/config/flags/execution.flags.js +1 -1
- package/dist/constants.d.ts +3 -2
- package/dist/index.js +8 -2
- package/dist/mcp/helpers.d.ts +4 -2
- package/dist/mcp/helpers.js +7 -4
- package/dist/mcp/tools/run-cloud-test.js +30 -6
- package/dist/methods.d.ts +3 -3
- package/dist/methods.js +17 -7
- package/dist/services/metadata-extractor.service.d.ts +19 -0
- package/dist/services/metadata-extractor.service.js +31 -0
- package/dist/services/telemetry.service.d.ts +20 -2
- package/dist/services/telemetry.service.js +27 -2
- package/dist/services/test-submission.service.d.ts +3 -2
- package/dist/services/test-submission.service.js +3 -3
- package/dist/types/generated/schema.types.d.ts +192 -14
- package/dist/types.d.ts +5 -0
- package/dist/utils/compatibility.d.ts +8 -0
- package/dist/utils/envelope.d.ts +32 -6
- package/dist/utils/envelope.js +59 -11
- package/dist/utils/help.d.ts +12 -0
- package/dist/utils/help.js +8 -0
- package/package.json +4 -4
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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";
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// (
|
|
141
|
-
|
|
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
|
|
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";
|
package/dist/commands/upload.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
//
|
|
46
|
-
// DCD_ENCRYPT
|
|
47
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
};
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/mcp/helpers.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mcp/helpers.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
146
|
-
//
|
|
147
|
-
const
|
|
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
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Required: callers pass the decision from `resolveEncryption`, so nothing
|
|
11
|
+
* here falls back to a default of its own.
|
|
12
12
|
*/
|
|
13
|
-
encrypt
|
|
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,
|
|
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('
|
|
203
|
-
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64
|
|
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
|
|
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
|
*/
|