@devicecloud.dev/dcd 4.4.8 → 5.0.0-beta.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.
Files changed (97) hide show
  1. package/README.md +40 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +68 -60
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +389 -282
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +122 -127
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +513 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +250 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +32 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +162 -173
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +78 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +122 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +62 -67
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +34 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +58 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +12 -10
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +13 -14
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +41 -33
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +23 -25
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +30 -37
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +18 -11
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +47 -43
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +2 -2
  42. package/dist/gateways/api-gateway.d.ts +43 -12
  43. package/dist/gateways/api-gateway.js +240 -100
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +57 -0
  46. package/dist/gateways/supabase-gateway.d.ts +11 -11
  47. package/dist/gateways/supabase-gateway.js +15 -39
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +93 -2
  50. package/dist/methods.d.ts +3 -5
  51. package/dist/methods.js +170 -178
  52. package/dist/services/device-validation.service.d.ts +8 -0
  53. package/dist/services/device-validation.service.js +55 -35
  54. package/dist/services/execution-plan.service.js +27 -15
  55. package/dist/services/execution-plan.utils.d.ts +3 -0
  56. package/dist/services/execution-plan.utils.js +10 -32
  57. package/dist/services/metadata-extractor.service.d.ts +0 -2
  58. package/dist/services/metadata-extractor.service.js +57 -57
  59. package/dist/services/moropo.service.js +25 -24
  60. package/dist/services/report-download.service.d.ts +12 -1
  61. package/dist/services/report-download.service.js +31 -20
  62. package/dist/services/results-polling.service.d.ts +6 -7
  63. package/dist/services/results-polling.service.js +80 -33
  64. package/dist/services/telemetry.service.d.ts +40 -0
  65. package/dist/services/telemetry.service.js +230 -0
  66. package/dist/services/test-submission.service.js +2 -1
  67. package/dist/services/version.service.d.ts +3 -2
  68. package/dist/services/version.service.js +27 -11
  69. package/dist/types/domain/auth.types.d.ts +12 -0
  70. package/dist/types/{schema.types.js → domain/auth.types.js} +0 -1
  71. package/dist/types/domain/live.types.d.ts +76 -0
  72. package/dist/types/domain/live.types.js +4 -0
  73. package/dist/utils/auth.d.ts +13 -0
  74. package/dist/utils/auth.js +142 -0
  75. package/dist/utils/cli.d.ts +35 -0
  76. package/dist/utils/cli.js +127 -0
  77. package/dist/utils/compatibility.d.ts +2 -1
  78. package/dist/utils/compatibility.js +2 -2
  79. package/dist/utils/config-store.d.ts +35 -0
  80. package/dist/utils/config-store.js +125 -0
  81. package/dist/utils/connectivity.js +7 -3
  82. package/dist/utils/expo.js +14 -3
  83. package/dist/utils/orgs.d.ts +11 -0
  84. package/dist/utils/orgs.js +40 -0
  85. package/dist/utils/paths.d.ts +11 -0
  86. package/dist/utils/paths.js +24 -0
  87. package/dist/utils/progress.d.ts +13 -0
  88. package/dist/utils/progress.js +50 -0
  89. package/dist/utils/styling.d.ts +13 -5
  90. package/dist/utils/styling.js +37 -7
  91. package/package.json +26 -38
  92. package/bin/dev.cmd +0 -3
  93. package/bin/dev.js +0 -6
  94. package/bin/run.cmd +0 -3
  95. package/bin/run.js +0 -7
  96. package/dist/types/schema.types.d.ts +0 -2702
  97. package/oclif.manifest.json +0 -884
@@ -28,4 +28,12 @@ export declare class DeviceValidationService {
28
28
  * @throws Error if device/version combination is not supported
29
29
  */
30
30
  validateiOSDevice(iOSVersion: string | undefined, iOSDevice: string | undefined, compatibilityData: CompatibilityData, options?: DeviceValidationOptions): void;
31
+ /**
32
+ * Shared validation flow for both platforms: apply the default device,
33
+ * look up its supported versions, then check the requested version
34
+ * @param config Platform-specific lookup table, defaults, and messages
35
+ * @returns void
36
+ * @throws Error if device/version combination is not supported
37
+ */
38
+ private validateDevice;
31
39
  }
@@ -16,29 +16,24 @@ class DeviceValidationService {
16
16
  * @throws Error if device/API level combination is not supported
17
17
  */
18
18
  validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, options = {}) {
19
- const { debug = false, logger } = options;
20
- if (!androidApiLevel && !androidDevice) {
21
- return;
22
- }
23
- const androidDeviceID = androidDevice || 'pixel-7';
24
- const lookup = googlePlay
25
- ? compatibilityData.androidPlay
26
- : compatibilityData.android;
27
- const supportedAndroidVersions = lookup?.[androidDeviceID] || [];
28
- const version = androidApiLevel || '34';
29
- if (supportedAndroidVersions.length === 0) {
30
- throw new Error(`We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`);
31
- }
32
- if (Array.isArray(supportedAndroidVersions) &&
33
- !supportedAndroidVersions.includes(version)) {
34
- throw new Error(`${androidDeviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedAndroidVersions.join(', ')}`);
35
- }
36
- if (debug && logger) {
37
- logger(`[DEBUG] Android device: ${androidDeviceID}`);
38
- logger(`[DEBUG] Android API level: ${version}`);
39
- logger(`[DEBUG] Google Play enabled: ${googlePlay}`);
40
- logger(`[DEBUG] Supported Android versions: ${supportedAndroidVersions.join(', ')}`);
41
- }
19
+ this.validateDevice({
20
+ debugLines: (deviceID, version, supportedVersions) => [
21
+ `[DEBUG] Android device: ${deviceID}`,
22
+ `[DEBUG] Android API level: ${version}`,
23
+ `[DEBUG] Google Play enabled: ${googlePlay}`,
24
+ `[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`,
25
+ ],
26
+ defaultDevice: 'pixel-7',
27
+ defaultVersion: '34',
28
+ device: androidDevice,
29
+ lookup: googlePlay
30
+ ? compatibilityData.androidPlay
31
+ : compatibilityData.android,
32
+ noSupportMessage: () => `We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`,
33
+ options,
34
+ unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedVersions.join(', ')}`,
35
+ version: androidApiLevel,
36
+ });
42
37
  }
43
38
  /**
44
39
  * Validate iOS device configuration
@@ -50,24 +45,49 @@ class DeviceValidationService {
50
45
  * @throws Error if device/version combination is not supported
51
46
  */
52
47
  validateiOSDevice(iOSVersion, iOSDevice, compatibilityData, options = {}) {
48
+ this.validateDevice({
49
+ debugLines: (deviceID, version, supportedVersions) => [
50
+ `[DEBUG] iOS device: ${deviceID}`,
51
+ `[DEBUG] iOS version: ${version}`,
52
+ `[DEBUG] Supported iOS versions: ${supportedVersions.join(', ')}`,
53
+ ],
54
+ defaultDevice: 'iphone-14',
55
+ defaultVersion: '17',
56
+ device: iOSDevice,
57
+ lookup: compatibilityData?.ios,
58
+ noSupportMessage: (deviceID) => `Device ${deviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`,
59
+ options,
60
+ unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} only supports these iOS versions: ${supportedVersions.join(', ')}`,
61
+ version: iOSVersion,
62
+ });
63
+ }
64
+ /**
65
+ * Shared validation flow for both platforms: apply the default device,
66
+ * look up its supported versions, then check the requested version
67
+ * @param config Platform-specific lookup table, defaults, and messages
68
+ * @returns void
69
+ * @throws Error if device/version combination is not supported
70
+ */
71
+ validateDevice(config) {
72
+ const { debugLines, defaultDevice, defaultVersion, device, lookup, noSupportMessage, options, unsupportedVersionMessage, version, } = config;
53
73
  const { debug = false, logger } = options;
54
- if (!iOSVersion && !iOSDevice) {
74
+ if (!version && !device) {
55
75
  return;
56
76
  }
57
- const iOSDeviceID = iOSDevice || 'iphone-14';
58
- const supportediOSVersions = compatibilityData?.ios?.[iOSDeviceID] || [];
59
- const version = iOSVersion || '17';
60
- if (supportediOSVersions.length === 0) {
61
- throw new Error(`Device ${iOSDeviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`);
77
+ const deviceID = device || defaultDevice;
78
+ const supportedVersions = lookup?.[deviceID] || [];
79
+ const requestedVersion = version || defaultVersion;
80
+ if (supportedVersions.length === 0) {
81
+ throw new Error(noSupportMessage(deviceID));
62
82
  }
63
- if (Array.isArray(supportediOSVersions) &&
64
- !supportediOSVersions.includes(version)) {
65
- throw new Error(`${iOSDeviceID} only supports these iOS versions: ${supportediOSVersions.join(', ')}`);
83
+ if (Array.isArray(supportedVersions) &&
84
+ !supportedVersions.includes(requestedVersion)) {
85
+ throw new Error(unsupportedVersionMessage(deviceID, supportedVersions));
66
86
  }
67
87
  if (debug && logger) {
68
- logger(`[DEBUG] iOS device: ${iOSDeviceID}`);
69
- logger(`[DEBUG] iOS version: ${version}`);
70
- logger(`[DEBUG] Supported iOS versions: ${supportediOSVersions.join(', ')}`);
88
+ for (const line of debugLines(deviceID, requestedVersion, supportedVersions)) {
89
+ logger(line);
90
+ }
71
91
  }
72
92
  }
73
93
  }
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.plan = plan;
4
- const glob_1 = require("glob");
5
4
  const fs = require("node:fs");
6
5
  const path = require("node:path");
7
6
  const execution_plan_utils_1 = require("./execution-plan.utils");
@@ -131,16 +130,24 @@ async function planSingleFile(normalizedInput, configFile) {
131
130
  * @param normalizedInput - Normalized path to the workspace directory
132
131
  * @param unfilteredFlowFiles - List of all discovered flow files
133
132
  * @param configFile - Optional custom config file path
133
+ * @param excludeFlows - --exclude-flows patterns to re-apply to glob matches
134
134
  * @returns Filtered list of flow file paths matching the globs
135
135
  */
136
- async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile) {
136
+ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows) {
137
137
  if (workspaceConfig.flows) {
138
138
  const globs = workspaceConfig.flows.map((g) => g);
139
- const matchedFiles = await (0, glob_1.glob)(globs, {
140
- cwd: normalizedInput,
141
- nodir: true,
139
+ // fs.globSync lands in Node 22; the CLI's `engines.node` already requires it.
140
+ // No `nodir` option — we strip directories with a stat check below.
141
+ const allMatches = fs.globSync(globs, { cwd: normalizedInput });
142
+ const matchedFiles = allMatches.filter((file) => {
143
+ try {
144
+ return fs.statSync(path.resolve(normalizedInput, file)).isFile();
145
+ }
146
+ catch {
147
+ return false;
148
+ }
142
149
  });
143
- return matchedFiles
150
+ const globbedFlowFiles = matchedFiles
144
151
  .filter((file) => {
145
152
  if (file === 'config.yaml' || file === 'config.yml')
146
153
  return false;
@@ -156,6 +163,9 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
156
163
  return true;
157
164
  })
158
165
  .map((file) => path.resolve(normalizedInput, file));
166
+ // Re-globbing from disk bypasses the earlier --exclude-flows filter, so
167
+ // re-apply it here or excluded flows sneak back in via `flows:` globs.
168
+ return filterFlowFiles(globbedFlowFiles, excludeFlows);
159
169
  }
160
170
  return unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') &&
161
171
  !file.endsWith('config.yml') &&
@@ -176,14 +186,16 @@ function resolveSequentialFlows(workspaceConfig, pathsByName, debug) {
176
186
  console.log('[DEBUG] executionOrder.flowsOrder:', workspaceConfig.executionOrder.flowsOrder);
177
187
  console.log('[DEBUG] Available flow names:', Object.keys(pathsByName));
178
188
  }
179
- const flowsToRunInSequence = workspaceConfig.executionOrder.flowsOrder
180
- .flatMap((flowOrder) => {
181
- const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, '');
182
- if (debug && flowOrder !== normalizedFlowOrder) {
183
- console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`);
184
- }
185
- return (0, execution_plan_utils_1.getFlowsToRunInSequence)(pathsByName, [normalizedFlowOrder], debug);
186
- });
189
+ // Dedupe so a flow listed twice in flowsOrder isn't run twice.
190
+ const flowsToRunInSequence = [
191
+ ...new Set(workspaceConfig.executionOrder.flowsOrder.flatMap((flowOrder) => {
192
+ const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, '');
193
+ if (debug && flowOrder !== normalizedFlowOrder) {
194
+ console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`);
195
+ }
196
+ return (0, execution_plan_utils_1.getFlowsToRunInSequence)(pathsByName, [normalizedFlowOrder], debug);
197
+ })),
198
+ ];
187
199
  if (debug) {
188
200
  console.log(`[DEBUG] Sequential flows resolved: ${flowsToRunInSequence.length} flow(s)`);
189
201
  }
@@ -239,7 +251,7 @@ async function plan(options) {
239
251
  else {
240
252
  workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
241
253
  }
242
- unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile);
254
+ unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows);
243
255
  if (unfilteredFlowFiles.length === 0) {
244
256
  const error = workspaceConfig.flows
245
257
  ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
@@ -6,6 +6,9 @@ export declare const readYamlFileAsJson: (filePath: string) => unknown;
6
6
  export declare const readTestYamlFileAsJson: (filePath: string) => {
7
7
  config: Record<string, unknown>;
8
8
  testSteps: Record<string, unknown>[];
9
+ } | {
10
+ config: null;
11
+ testSteps: Record<string, unknown>[];
9
12
  };
10
13
  export declare function readDirectory(dir: string, filterFunction?: (filePath: string) => boolean): Promise<string[]>;
11
14
  export declare const checkIfFilesExistInWorkspace: (commandName: string, command: Record<string, string> | string | string[], absoluteFilePath: string) => {
@@ -15,44 +15,23 @@ function getFlowsToRunInSequence(paths, flowOrder, debug = false) {
15
15
  }
16
16
  return [];
17
17
  }
18
- const orderSet = new Set(flowOrder);
19
18
  const availableNames = Object.keys(paths);
20
19
  if (debug) {
21
- console.log(`[DEBUG] getFlowsToRunInSequence: Looking for flows in order: [${[...orderSet].join(', ')}]`);
20
+ console.log(`[DEBUG] getFlowsToRunInSequence: Looking for flows in order: [${flowOrder.join(', ')}]`);
22
21
  console.log(`[DEBUG] getFlowsToRunInSequence: Available flow names: [${availableNames.join(', ')}]`);
23
22
  }
24
- const namesInOrder = availableNames.filter((key) => orderSet.has(key));
23
+ const namesInOrder = flowOrder.filter((name) => Object.hasOwn(paths, name));
25
24
  if (debug) {
26
25
  console.log(`[DEBUG] getFlowsToRunInSequence: Matched ${namesInOrder.length} flow(s): [${namesInOrder.join(', ')}]`);
27
26
  }
28
27
  if (namesInOrder.length === 0) {
29
- const notFound = [...orderSet].filter((item) => !availableNames.includes(item));
28
+ const notFound = flowOrder.filter((name) => !availableNames.includes(name));
30
29
  console.warn(`Warning: Could not find flows specified in executionOrder.flowsOrder: ${notFound.join(', ')}\n` +
31
30
  `This may be intentional if flows were excluded by tags.\n` +
32
31
  `Available flow names:\n${availableNames.join('\n')}`);
33
32
  return [];
34
33
  }
35
- const result = [...orderSet].filter((item) => namesInOrder.includes(item));
36
- if (result.length === 0) {
37
- const notFound = [...orderSet].filter((item) => !namesInOrder.includes(item));
38
- console.warn(`Warning: Could not find flows needed for execution in order: ${notFound.join(', ')}\n` +
39
- `This may be intentional if flows were excluded by tags.\n` +
40
- `Available flow names:\n${availableNames.join('\n')}`);
41
- return [];
42
- }
43
- if (flowOrder
44
- .slice(0, result.length)
45
- .every((value, index) => value === result[index])) {
46
- const resolvedPaths = result.map((item) => paths[item]);
47
- if (debug) {
48
- console.log(`[DEBUG] getFlowsToRunInSequence: Order matches, returning ${resolvedPaths.length} path(s)`);
49
- }
50
- return resolvedPaths;
51
- }
52
- throw new Error(`Flow order mismatch in executionOrder.flowsOrder.\n\n` +
53
- `Expected order: [${flowOrder.slice(0, result.length).join(', ')}]\n` +
54
- `Actual order: [${result.join(', ')}]\n\n` +
55
- `Please ensure flows are specified in the correct order.`);
34
+ return namesInOrder.map((name) => paths[name]);
56
35
  }
57
36
  function isFlowFile(filePath) {
58
37
  // Exclude files inside .app bundles
@@ -96,15 +75,14 @@ const readTestYamlFileAsJson = (filePath) => {
96
75
  if (normalizedText.includes('\n---\n')) {
97
76
  const yamlTexts = normalizedText.split('\n---\n');
98
77
  const config = yaml.load(yamlTexts[0]);
99
- const testSteps = yaml.load(yamlTexts[1]);
100
- if (Object.keys(config ?? {}).length > 0) {
78
+ // Rejoin everything after the first separator so step documents beyond
79
+ // a second `---` aren't silently dropped.
80
+ const testSteps = yaml.load(yamlTexts.slice(1).join('\n'));
81
+ if (config && Object.keys(config).length > 0) {
101
82
  return { config, testSteps };
102
83
  }
103
84
  }
104
85
  const testSteps = yaml.load(yamlText);
105
- if (Object.keys(testSteps).length > 0) {
106
- return { config: null, testSteps };
107
- }
108
86
  return { config: null, testSteps };
109
87
  }
110
88
  catch (error) {
@@ -142,9 +120,9 @@ const checkIfFilesExistInWorkspace = (commandName, command, absoluteFilePath) =>
142
120
  errors.push(buildError(error));
143
121
  files.push(absoluteFilePath);
144
122
  };
145
- // simple command
123
+ // simple command — processFilePath already resolves against `directory`
146
124
  if (typeof command === 'string') {
147
- processFilePath(path.normalize(path.join(directory, command)));
125
+ processFilePath(command);
148
126
  }
149
127
  // array command
150
128
  if (Array.isArray(command)) {
@@ -22,7 +22,6 @@ export declare class AndroidMetadataExtractor implements IMetadataExtractor {
22
22
  export declare class IosAppMetadataExtractor implements IMetadataExtractor {
23
23
  canHandle(filePath: string): boolean;
24
24
  extract(filePath: string): Promise<TAppMetadata>;
25
- private parseInfoPlist;
26
25
  }
27
26
  /**
28
27
  * Extracts metadata from iOS .zip files containing .app bundles
@@ -30,7 +29,6 @@ export declare class IosAppMetadataExtractor implements IMetadataExtractor {
30
29
  export declare class IosZipMetadataExtractor implements IMetadataExtractor {
31
30
  canHandle(filePath: string): boolean;
32
31
  extract(filePath: string): Promise<TAppMetadata>;
33
- private parseInfoPlist;
34
32
  }
35
33
  /**
36
34
  * Extracts metadata from Expo iOS .tar.gz archives by extracting the
@@ -1,12 +1,31 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MetadataExtractorService = exports.ExpoTarGzMetadataExtractor = exports.IosZipMetadataExtractor = exports.IosAppMetadataExtractor = exports.AndroidMetadataExtractor = void 0;
4
- const AppInfoParser = require("app-info-parser");
5
4
  const bplist_parser_1 = require("bplist-parser");
5
+ const node_apk_1 = require("node-apk");
6
6
  const promises_1 = require("node:fs/promises");
7
7
  const path = require("node:path");
8
8
  const StreamZip = require("node-stream-zip");
9
9
  const plist_1 = require("plist");
10
+ /**
11
+ * Parses an Info.plist buffer (XML, UTF-8 BOM'd XML, or binary bplist).
12
+ * Shared by the .app and .zip extractors.
13
+ */
14
+ function parseInfoPlist(buffer) {
15
+ let data;
16
+ const bufferType = buffer[0];
17
+ // 60 = '<' (XML plist), 239 = UTF-8 BOM, 98 = 'b' (binary "bplist")
18
+ if (bufferType === 60 || bufferType === 239) {
19
+ data = (0, plist_1.parse)(buffer.toString());
20
+ }
21
+ else if (bufferType === 98) {
22
+ data = (0, bplist_parser_1.parseBuffer)(buffer)[0];
23
+ }
24
+ else {
25
+ throw new Error('Unknown plist buffer type.');
26
+ }
27
+ return data;
28
+ }
10
29
  /**
11
30
  * Extracts metadata from Android APK files
12
31
  */
@@ -15,9 +34,14 @@ class AndroidMetadataExtractor {
15
34
  return filePath.endsWith('.apk');
16
35
  }
17
36
  async extract(filePath) {
18
- const parser = new AppInfoParser(filePath);
19
- const result = await parser.parse();
20
- return { appId: result.package, platform: 'android' };
37
+ const apk = new node_apk_1.Apk(filePath);
38
+ try {
39
+ const manifest = await apk.getManifestInfo();
40
+ return { appId: manifest.package, platform: 'android' };
41
+ }
42
+ finally {
43
+ apk.close();
44
+ }
21
45
  }
22
46
  }
23
47
  exports.AndroidMetadataExtractor = AndroidMetadataExtractor;
@@ -31,26 +55,10 @@ class IosAppMetadataExtractor {
31
55
  async extract(filePath) {
32
56
  const infoPlistPath = path.normalize(path.join(filePath, 'Info.plist'));
33
57
  const buffer = await (0, promises_1.readFile)(infoPlistPath);
34
- const data = await this.parseInfoPlist(buffer);
58
+ const data = parseInfoPlist(buffer);
35
59
  const appId = data.CFBundleIdentifier;
36
60
  return { appId, platform: 'ios' };
37
61
  }
38
- async parseInfoPlist(buffer) {
39
- let data;
40
- const bufferType = buffer[0];
41
- if (bufferType === 60 ||
42
- bufferType === '<' ||
43
- bufferType === 239) {
44
- data = (0, plist_1.parse)(buffer.toString());
45
- }
46
- else if (bufferType === 98) {
47
- data = (0, bplist_parser_1.parseBuffer)(buffer)[0];
48
- }
49
- else {
50
- throw new Error('Unknown plist buffer type.');
51
- }
52
- return data;
53
- }
54
62
  }
55
63
  exports.IosAppMetadataExtractor = IosAppMetadataExtractor;
56
64
  /**
@@ -63,48 +71,40 @@ class IosZipMetadataExtractor {
63
71
  async extract(filePath) {
64
72
  return new Promise((resolve, reject) => {
65
73
  const zip = new StreamZip({ file: filePath });
74
+ // A throw inside an emitter callback escapes the caller's try/catch and
75
+ // crashes the process, so route all failures through reject explicitly.
66
76
  zip.on('ready', () => {
67
- // Get all entries and sort them by path depth
68
- const entries = Object.values(zip.entries());
69
- const sortedEntries = entries.sort((a, b) => {
70
- const aDepth = a.name.split('/').length;
71
- const bDepth = b.name.split('/').length;
72
- return aDepth - bDepth;
73
- });
74
- // Find the first Info.plist in the shallowest directory
75
- const infoPlist = sortedEntries.find((e) => e.name.endsWith('.app/Info.plist'));
76
- if (!infoPlist) {
77
- reject(new Error('Failed to find info plist'));
78
- return;
77
+ try {
78
+ // Get all entries and sort them by path depth
79
+ const entries = Object.values(zip.entries());
80
+ const sortedEntries = entries.sort((a, b) => {
81
+ const aDepth = a.name.split('/').length;
82
+ const bDepth = b.name.split('/').length;
83
+ return aDepth - bDepth;
84
+ });
85
+ // Find the first Info.plist in the shallowest directory
86
+ const infoPlist = sortedEntries.find((e) => e.name.endsWith('.app/Info.plist'));
87
+ if (!infoPlist) {
88
+ reject(new Error('Failed to find info plist'));
89
+ return;
90
+ }
91
+ const buffer = zip.entryDataSync(infoPlist.name);
92
+ const data = parseInfoPlist(buffer);
93
+ resolve({ appId: data.CFBundleIdentifier, platform: 'ios' });
79
94
  }
80
- const buffer = zip.entryDataSync(infoPlist.name);
81
- this.parseInfoPlist(buffer)
82
- .then((data) => {
83
- const appId = data.CFBundleIdentifier;
95
+ catch (error) {
96
+ reject(error);
97
+ }
98
+ finally {
84
99
  zip.close();
85
- resolve({ appId, platform: 'ios' });
86
- })
87
- .catch(reject);
100
+ }
101
+ });
102
+ zip.on('error', (error) => {
103
+ zip.close();
104
+ reject(error);
88
105
  });
89
- zip.on('error', reject);
90
106
  });
91
107
  }
92
- async parseInfoPlist(buffer) {
93
- let data;
94
- const bufferType = buffer[0];
95
- if (bufferType === 60 ||
96
- bufferType === '<' ||
97
- bufferType === 239) {
98
- data = (0, plist_1.parse)(buffer.toString());
99
- }
100
- else if (bufferType === 98) {
101
- data = (0, bplist_parser_1.parseBuffer)(buffer)[0];
102
- }
103
- else {
104
- throw new Error('Unknown plist buffer type.');
105
- }
106
- return data;
107
- }
108
108
  }
109
109
  exports.IosZipMetadataExtractor = IosZipMetadataExtractor;
110
110
  /**
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MoropoService = void 0;
4
- const core_1 = require("@oclif/core");
4
+ const progress_1 = require("../utils/progress");
5
5
  const fs = require("node:fs");
6
6
  const os = require("node:os");
7
7
  const path = require("node:path");
8
+ const node_stream_1 = require("node:stream");
9
+ const promises_1 = require("node:stream/promises");
8
10
  const StreamZip = require("node-stream-zip");
9
11
  /**
10
12
  * Service for downloading and extracting Moropo tests from the Moropo API
@@ -20,9 +22,10 @@ class MoropoService {
20
22
  const { apiKey, branchName = 'main', debug = false, quiet = false, json = false, logger } = options;
21
23
  this.logDebug(debug, logger, '[DEBUG] Moropo v1 API key detected, downloading tests from Moropo API');
22
24
  this.logDebug(debug, logger, `[DEBUG] Using branch name: ${branchName}`);
25
+ let moropoDir;
23
26
  try {
24
27
  if (!quiet && !json) {
25
- core_1.ux.action.start('Downloading Moropo tests', 'Initializing', {
28
+ progress_1.ux.action.start('Downloading Moropo tests', 'Initializing', {
26
29
  stdout: true,
27
30
  });
28
31
  }
@@ -36,7 +39,7 @@ class MoropoService {
36
39
  if (!response.ok) {
37
40
  throw new Error(`Failed to download Moropo tests: ${response.statusText}`);
38
41
  }
39
- const moropoDir = path.join(os.tmpdir(), `moropo-tests-${Date.now()}`);
42
+ moropoDir = path.join(os.tmpdir(), `moropo-tests-${Date.now()}`);
40
43
  this.logDebug(debug, logger, `[DEBUG] Extracting Moropo tests to: ${moropoDir}`);
41
44
  // Create moropo directory if it doesn't exist
42
45
  if (!fs.existsSync(moropoDir)) {
@@ -49,7 +52,7 @@ class MoropoService {
49
52
  // Extract zip file
50
53
  await this.extractZipFile(zipPath, moropoDir);
51
54
  if (!quiet && !json) {
52
- core_1.ux.action.stop('completed');
55
+ progress_1.ux.action.stop('completed');
53
56
  }
54
57
  this.logDebug(debug, logger, '[DEBUG] Successfully extracted Moropo tests');
55
58
  // Create config.yaml file
@@ -59,7 +62,11 @@ class MoropoService {
59
62
  }
60
63
  catch (error) {
61
64
  if (!quiet && !json) {
62
- core_1.ux.action.stop('failed');
65
+ progress_1.ux.action.stop('failed');
66
+ }
67
+ // Remove the temp directory (and any partially-written zip inside it)
68
+ if (moropoDir) {
69
+ fs.rmSync(moropoDir, { recursive: true, force: true });
63
70
  }
64
71
  this.logDebug(debug, logger, `[DEBUG] Error downloading/extracting Moropo tests: ${error}`);
65
72
  throw new Error(`Failed to download/extract Moropo tests: ${error}`);
@@ -74,28 +81,22 @@ class MoropoService {
74
81
  const contentLength = response.headers.get('content-length');
75
82
  const totalSize = contentLength ? Number.parseInt(contentLength, 10) : 0;
76
83
  let downloadedSize = 0;
77
- const fileStream = fs.createWriteStream(zipPath);
78
- const reader = response.body?.getReader();
79
- if (!reader) {
84
+ if (!response.body) {
80
85
  throw new Error('Failed to get response reader');
81
86
  }
82
- let readerResult = await reader.read();
83
- while (!readerResult.done) {
84
- const { value } = readerResult;
85
- downloadedSize += value.length;
86
- if (!quiet && !json && totalSize) {
87
+ const source = node_stream_1.Readable.fromWeb(response.body);
88
+ if (!quiet && !json && totalSize) {
89
+ // Progress tap pipeline below still owns the flow/backpressure
90
+ source.on('data', (chunk) => {
91
+ downloadedSize += chunk.length;
87
92
  const progress = Math.round((downloadedSize / totalSize) * 100);
88
- core_1.ux.action.status = `Downloading: ${progress}%`;
89
- }
90
- fileStream.write(value);
91
- readerResult = await reader.read();
92
- }
93
- fileStream.end();
94
- await new Promise((resolve) => {
95
- fileStream.on('finish', () => {
96
- resolve();
93
+ progress_1.ux.action.status = `Downloading: ${progress}%`;
97
94
  });
98
- });
95
+ }
96
+ // pipeline (unlike a bare 'finish' wait) propagates errors from both
97
+ // streams, so disk-full or a stalled download rejects instead of
98
+ // crashing or hanging.
99
+ await (0, promises_1.pipeline)(source, fs.createWriteStream(zipPath));
99
100
  }
100
101
  async extractZipFile(zipPath, extractPath) {
101
102
  // eslint-disable-next-line new-cap
@@ -111,7 +112,7 @@ class MoropoService {
111
112
  }
112
113
  showProgress(quiet, json, message) {
113
114
  if (!quiet && !json) {
114
- core_1.ux.action.status = message;
115
+ progress_1.ux.action.status = message;
115
116
  }
116
117
  }
117
118
  }
@@ -1,5 +1,6 @@
1
+ import type { AuthContext } from '../types/domain/auth.types';
1
2
  export interface DownloadOptions {
2
- apiKey: string;
3
+ auth: AuthContext;
3
4
  apiUrl: string;
4
5
  debug?: boolean;
5
6
  logger?: (message: string) => void;
@@ -40,4 +41,14 @@ export declare class ReportDownloadService {
40
41
  * @returns Promise that resolves when download is complete
41
42
  */
42
43
  private downloadReport;
44
+ /**
45
+ * Warn about a failed download with the underlying cause plus hints for
46
+ * common error classes (missing results, permissions, bad paths)
47
+ * @param warnLogger Warning logger, if configured
48
+ * @param subject What was being downloaded, e.g. 'artifacts' or 'JUNIT report'
49
+ * @param notFoundHint Message to show when the error looks like a 404
50
+ * @param error The error that occurred
51
+ * @returns void
52
+ */
53
+ private warnDownloadFailure;
43
54
  }