@devicecloud.dev/dcd 4.3.3 → 4.4.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.
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Artifacts extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
+ apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
8
+ debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
+ 'upload-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
10
+ 'download-artifacts': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
11
+ 'artifacts-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ report: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
13
+ 'allure-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
14
+ 'html-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
+ 'junit-path': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
16
+ };
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const constants_1 = require("../constants");
5
+ const report_download_service_1 = require("../services/report-download.service");
6
+ class Artifacts extends core_1.Command {
7
+ static description = 'Download artifacts or reports for a completed test run';
8
+ static examples = [
9
+ '<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --download-artifacts FAILED',
10
+ '<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --download-artifacts ALL --artifacts-path ./my-artifacts.zip',
11
+ '<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --report junit',
12
+ '<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --report allure --allure-path ./report.html',
13
+ ];
14
+ static flags = {
15
+ apiKey: constants_1.flags.apiKey,
16
+ apiUrl: constants_1.flags.apiUrl,
17
+ debug: core_1.Flags.boolean({
18
+ default: false,
19
+ description: 'Enable detailed debug logging for troubleshooting issues',
20
+ }),
21
+ 'upload-id': core_1.Flags.string({
22
+ description: 'UUID of the completed upload to download artifacts for',
23
+ required: true,
24
+ }),
25
+ 'download-artifacts': core_1.Flags.string({
26
+ description: 'Download a zip containing the logs, screenshots and videos for this run. Options: ALL (everything), FAILED (failures only).',
27
+ exclusive: ['report'],
28
+ options: ['ALL', 'FAILED'],
29
+ }),
30
+ 'artifacts-path': core_1.Flags.string({
31
+ dependsOn: ['download-artifacts'],
32
+ description: 'Custom file path for downloaded artifacts (default: ./artifacts.zip)',
33
+ }),
34
+ report: core_1.Flags.string({
35
+ description: 'Download a test report in the specified format.',
36
+ exclusive: ['download-artifacts'],
37
+ options: ['allure', 'html', 'html-detailed', 'junit'],
38
+ }),
39
+ 'allure-path': core_1.Flags.string({
40
+ dependsOn: ['report'],
41
+ description: 'Custom file path for downloaded Allure report (default: ./report.html)',
42
+ }),
43
+ 'html-path': core_1.Flags.string({
44
+ dependsOn: ['report'],
45
+ description: 'Custom file path for downloaded HTML report (default: ./report.html)',
46
+ }),
47
+ 'junit-path': core_1.Flags.string({
48
+ dependsOn: ['report'],
49
+ description: 'Custom file path for downloaded JUnit report (default: ./report.xml)',
50
+ }),
51
+ };
52
+ async run() {
53
+ const { flags } = await this.parse(Artifacts);
54
+ const { apiKey: apiKeyFlag, apiUrl, debug, 'upload-id': uploadId, 'download-artifacts': downloadArtifacts, 'artifacts-path': artifactsPath, report, 'allure-path': allurePath, 'html-path': htmlPath, 'junit-path': junitPath, } = flags;
55
+ const apiKey = apiKeyFlag || process.env.DEVICE_CLOUD_API_KEY;
56
+ if (!apiKey) {
57
+ this.error('API key is required. Please provide it via --api-key flag or DEVICE_CLOUD_API_KEY environment variable.');
58
+ return;
59
+ }
60
+ if (!downloadArtifacts && !report) {
61
+ this.error('Either --download-artifacts or --report must be specified.');
62
+ return;
63
+ }
64
+ const service = new report_download_service_1.ReportDownloadService();
65
+ if (downloadArtifacts) {
66
+ await service.downloadArtifacts({
67
+ apiKey,
68
+ apiUrl,
69
+ artifactsPath: artifactsPath ?? './artifacts.zip',
70
+ debug,
71
+ downloadType: downloadArtifacts,
72
+ logger: this.log.bind(this),
73
+ uploadId,
74
+ warnLogger: this.warn.bind(this),
75
+ });
76
+ }
77
+ if (report) {
78
+ await service.downloadReports({
79
+ allurePath,
80
+ apiKey,
81
+ apiUrl,
82
+ debug,
83
+ htmlPath,
84
+ junitPath,
85
+ logger: this.log.bind(this),
86
+ reportType: report,
87
+ uploadId,
88
+ warnLogger: this.warn.bind(this),
89
+ });
90
+ }
91
+ }
92
+ }
93
+ exports.default = Artifacts;
@@ -33,6 +33,11 @@ export default class Cloud extends Command {
33
33
  'json-file-name': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
34
34
  quiet: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
35
35
  report: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
36
+ branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
37
+ 'commit-sha': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
38
+ 'repo-name': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
39
+ 'pr-number': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
40
+ 'pr-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
36
41
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
37
42
  'exclude-flows': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
38
43
  'exclude-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
@@ -60,6 +65,7 @@ export default class Cloud extends Command {
60
65
  'disable-animations': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
61
66
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
62
67
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
68
+ 'app-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
63
69
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
64
70
  apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
65
71
  apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
@@ -15,6 +15,7 @@ const results_polling_service_1 = require("../services/results-polling.service")
15
15
  const test_submission_service_1 = require("../services/test-submission.service");
16
16
  const version_service_1 = require("../services/version.service");
17
17
  const compatibility_1 = require("../utils/compatibility");
18
+ const expo_1 = require("../utils/expo");
18
19
  const styling_1 = require("../utils/styling");
19
20
  // Suppress punycode deprecation warning (caused by whatwg, supabase dependancy)
20
21
  process.removeAllListeners('warning');
@@ -93,9 +94,11 @@ class Cloud extends core_1.Command {
93
94
  // Store debug flag outside try/catch to access it in catch block
94
95
  let debugFlag = false;
95
96
  let jsonFile = false;
97
+ // Temp files created during Expo URL download / .tar.gz extraction — cleaned up in finally
98
+ const tempFiles = [];
96
99
  try {
97
100
  const { args, flags, raw } = await this.parse(Cloud);
98
- let { 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'artifacts-path': artifactsPath, 'junit-path': junitPath, 'allure-path': allurePath, 'html-path': htmlPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'android-no-snapshot': androidNoSnapshot, } = flags;
101
+ let { 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey: apiKeyFlag, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, 'app-url': appUrl, 'artifacts-path': artifactsPath, 'junit-path': junitPath, 'allure-path': allurePath, 'html-path': htmlPath, async, config: configFile, debug, 'device-locale': deviceLocale, 'download-artifacts': downloadArtifacts, 'dry-run': dryRun, env, 'exclude-flows': excludeFlows, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'ignore-sha-check': ignoreShaCheck, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, json, 'json-file-name': jsonFileName, 'maestro-version': maestroVersion, metadata, mitmHost, mitmPath, 'moropo-v1-api-key': moropoApiKey, name, orientation, quiet, report, retry, 'runner-type': runnerType, 'android-no-snapshot': androidNoSnapshot, branch: ghBranch, 'commit-sha': ghCommitSha, 'repo-name': ghRepoName, 'pr-number': ghPrNumber, 'pr-url': ghPrUrl, } = flags;
99
102
  // Store debug flag for use in catch block
100
103
  debugFlag = debug === true;
101
104
  jsonFile = flags['json-file'] === true;
@@ -164,11 +167,11 @@ class Cloud extends core_1.Command {
164
167
  debug,
165
168
  logger: this.log.bind(this),
166
169
  });
167
- const DEPRECATED_MAESTRO_VERSIONS = ['1.39.2', '1.39.7'];
170
+ const DEPRECATED_MAESTRO_VERSIONS = ['1.39.2', '1.39.7', '2.0.3'];
168
171
  if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
169
172
  this.log(`\n${styling_1.dividers.light}\n` +
170
173
  `${styling_1.colors.warning('⚠')} ${styling_1.colors.bold('Deprecation Warning')}\n` +
171
- styling_1.colors.dim(`Maestro version ${resolvedMaestroVersion} is deprecated and will be removed soon. `) +
174
+ styling_1.colors.dim(`Maestro version ${resolvedMaestroVersion} is deprecated and will be removed on 27/03/2026. `) +
172
175
  styling_1.colors.dim(`Please upgrade to a newer version. See: `) +
173
176
  styling_1.colors.info('https://docs.devicecloud.dev/configuration/maestro-versions') + `\n` +
174
177
  `${styling_1.dividers.light}\n`);
@@ -189,8 +192,26 @@ class Cloud extends core_1.Command {
189
192
  }
190
193
  const { firstFile, secondFile } = args;
191
194
  let finalBinaryId = appBinaryId;
192
- const finalAppFile = appFile ?? firstFile;
195
+ let finalAppFile = appFile ?? (appUrl ?? firstFile);
193
196
  let flowFile = flows ?? secondFile;
197
+ // Resolve --app-url or a local .tar.gz to a .app path before validation
198
+ if (finalAppFile && !appBinaryId) {
199
+ if ((0, expo_1.isUrl)(finalAppFile)) {
200
+ this.log(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
201
+ const tarPath = await (0, expo_1.downloadExpoUrl)(finalAppFile, debug ?? false);
202
+ tempFiles.push(tarPath);
203
+ finalAppFile = tarPath;
204
+ }
205
+ if (finalAppFile.endsWith('.tar.gz')) {
206
+ this.log(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
207
+ const extractDir = await (0, expo_1.extractTarGz)(finalAppFile, debug ?? false);
208
+ tempFiles.push(extractDir);
209
+ finalAppFile = await (0, expo_1.findAppBundle)(extractDir);
210
+ if (debug) {
211
+ this.log(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
212
+ }
213
+ }
214
+ }
194
215
  if (debug) {
195
216
  this.log(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
196
217
  this.log(`[DEBUG] Second file argument: ${secondFile || 'not provided'}`);
@@ -296,8 +317,8 @@ class Cloud extends core_1.Command {
296
317
  if (!(flowFile && finalAppFile)) {
297
318
  throw new Error('You must provide a flow file and an app binary id');
298
319
  }
299
- if (!['apk', '.app', '.zip'].some((ext) => finalAppFile.endsWith(ext))) {
300
- throw new Error('App file must be a .apk for android or .app/.zip file for iOS');
320
+ if (!['apk', '.app', '.zip', '.tar.gz'].some((ext) => finalAppFile.endsWith(ext))) {
321
+ throw new Error('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
301
322
  }
302
323
  if (finalAppFile.endsWith('.zip')) {
303
324
  if (debug) {
@@ -384,6 +405,19 @@ class Cloud extends core_1.Command {
384
405
  if (!finalBinaryId) {
385
406
  throw new Error('Internal error: finalBinaryId should be defined after validation');
386
407
  }
408
+ const ghMetadataOverrides = [];
409
+ if (ghBranch)
410
+ ghMetadataOverrides.push(`gh_branch=${ghBranch}`);
411
+ if (ghCommitSha)
412
+ ghMetadataOverrides.push(`gh_sha=${ghCommitSha}`);
413
+ if (ghRepoName)
414
+ ghMetadataOverrides.push(`gh_repo=${ghRepoName}`);
415
+ if (ghPrNumber)
416
+ ghMetadataOverrides.push(`gh_pr_number=${ghPrNumber}`);
417
+ if (ghPrUrl)
418
+ ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`);
419
+ // Explicit --metadata values take precedence (last-write-wins in parseKeyValuePairs)
420
+ const mergedMetadata = [...ghMetadataOverrides, ...(metadata ?? [])];
387
421
  const testFormData = await this.testSubmissionService.buildTestFormData({
388
422
  androidApiLevel,
389
423
  androidDevice,
@@ -402,7 +436,7 @@ class Cloud extends core_1.Command {
402
436
  iOSVersion,
403
437
  logger: this.log.bind(this),
404
438
  maestroVersion: resolvedMaestroVersion,
405
- metadata,
439
+ metadata: mergedMetadata,
406
440
  mitmHost,
407
441
  mitmPath,
408
442
  name,
@@ -572,6 +606,10 @@ class Cloud extends core_1.Command {
572
606
  }
573
607
  }
574
608
  finally {
609
+ // Clean up any temp files created during Expo URL download / .tar.gz extraction
610
+ for (const p of tempFiles) {
611
+ await Promise.resolve().then(() => require('node:fs/promises')).then((fsp) => fsp.rm(p, { recursive: true, force: true }).catch(() => { }));
612
+ }
575
613
  if (output) {
576
614
  // eslint-disable-next-line no-unsafe-finally
577
615
  return output;
@@ -9,6 +9,7 @@ export default class Upload extends Command {
9
9
  static flags: {
10
10
  apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
11
11
  apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ 'app-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
12
13
  debug: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
13
14
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
14
15
  };
@@ -1,15 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
+ const promises_1 = require("node:fs/promises");
4
5
  const constants_1 = require("../constants");
5
6
  const methods_1 = require("../methods");
7
+ const expo_1 = require("../utils/expo");
6
8
  const styling_1 = require("../utils/styling");
7
9
  class Upload extends core_1.Command {
8
10
  static args = {
9
11
  appFile: core_1.Args.string({
10
- description: 'The binary file to upload (e.g. test.apk for android or test.app/.zip for ios)',
12
+ description: 'The binary file or Expo signed URL to upload (e.g. test.apk, test.app, test.zip, build.tar.gz, or https://expo.dev/...)',
11
13
  name: 'App file',
12
- required: true,
14
+ required: false,
13
15
  }),
14
16
  };
15
17
  static description = 'Upload an app binary to devicecloud.dev';
@@ -17,36 +19,61 @@ class Upload extends core_1.Command {
17
19
  static examples = [
18
20
  '<%= config.bin %> <%= command.id %> path/to/app.apk',
19
21
  '<%= config.bin %> <%= command.id %> path/to/app.zip --api-key YOUR_API_KEY',
22
+ '<%= config.bin %> <%= command.id %> path/to/build.tar.gz',
23
+ '<%= config.bin %> <%= command.id %> --app-url https://expo.dev/artifacts/...',
20
24
  ];
21
25
  static flags = {
22
26
  apiKey: constants_1.flags.apiKey,
23
27
  apiUrl: constants_1.flags.apiUrl,
28
+ 'app-url': constants_1.flags['app-url'],
24
29
  debug: constants_1.flags.debug,
25
30
  'ignore-sha-check': constants_1.flags['ignore-sha-check'],
26
31
  };
27
32
  async run() {
33
+ const tempFiles = [];
28
34
  try {
29
35
  const { args, flags } = await this.parse(Upload);
30
- const { appFile } = args;
31
- const { apiKey: apiKeyFlag, apiUrl, debug, 'ignore-sha-check': ignoreShaCheck, json, } = flags;
36
+ const { apiKey: apiKeyFlag, apiUrl, 'app-url': appUrl, debug, 'ignore-sha-check': ignoreShaCheck, json, } = flags;
32
37
  const apiKey = apiKeyFlag || process.env.DEVICE_CLOUD_API_KEY;
33
38
  if (!apiKey) {
34
39
  throw new Error('You must provide an API key via --api-key flag or DEVICE_CLOUD_API_KEY environment variable');
35
40
  }
36
- if (!['apk', '.app', '.zip'].some((ext) => appFile.endsWith(ext))) {
37
- throw new Error('App file must be a .apk for android or .app/.zip file for iOS');
41
+ // Resolve source: --app-url flag takes precedence, then positional arg
42
+ let resolvedFile = appUrl ?? args.appFile;
43
+ if (!resolvedFile) {
44
+ throw new Error('You must provide an app file path or a signed Expo URL via --app-url');
38
45
  }
39
- if (appFile.endsWith('.zip')) {
40
- await (0, methods_1.verifyAppZip)(appFile);
46
+ // Download from URL if needed
47
+ if ((0, expo_1.isUrl)(resolvedFile)) {
48
+ this.log(` ${styling_1.colors.dim('→ Downloading Expo build from URL...')}`);
49
+ const tarPath = await (0, expo_1.downloadExpoUrl)(resolvedFile, debug ?? false);
50
+ tempFiles.push(tarPath);
51
+ resolvedFile = tarPath;
52
+ }
53
+ // Extract .tar.gz if needed
54
+ if (resolvedFile.endsWith('.tar.gz')) {
55
+ this.log(` ${styling_1.colors.dim('→ Extracting Expo archive...')}`);
56
+ const extractDir = await (0, expo_1.extractTarGz)(resolvedFile, debug ?? false);
57
+ tempFiles.push(extractDir);
58
+ resolvedFile = await (0, expo_1.findAppBundle)(extractDir);
59
+ if (debug) {
60
+ this.log(`[DEBUG] Found .app bundle at: ${resolvedFile}`);
61
+ }
62
+ }
63
+ if (!['apk', '.app', '.zip', '.tar.gz'].some((ext) => resolvedFile.endsWith(ext))) {
64
+ throw new Error('App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)');
65
+ }
66
+ if (resolvedFile.endsWith('.zip')) {
67
+ await (0, methods_1.verifyAppZip)(resolvedFile);
41
68
  }
42
69
  this.log((0, styling_1.sectionHeader)('Uploading app binary'));
43
- this.log(` ${styling_1.colors.dim('→ File:')} ${styling_1.colors.highlight(appFile)}`);
70
+ this.log(` ${styling_1.colors.dim('→ File:')} ${styling_1.colors.highlight(resolvedFile)}`);
44
71
  this.log('');
45
72
  const appBinaryId = await (0, methods_1.uploadBinary)({
46
73
  apiKey,
47
74
  apiUrl,
48
75
  debug,
49
- filePath: appFile,
76
+ filePath: resolvedFile,
50
77
  ignoreShaCheck,
51
78
  log: !json,
52
79
  });
@@ -61,6 +88,11 @@ class Upload extends core_1.Command {
61
88
  catch (error) {
62
89
  this.error(error, { exit: 1 });
63
90
  }
91
+ finally {
92
+ for (const p of tempFiles) {
93
+ await (0, promises_1.rm)(p, { recursive: true, force: true }).catch(() => { });
94
+ }
95
+ }
64
96
  }
65
97
  }
66
98
  exports.default = Upload;
@@ -4,5 +4,6 @@
4
4
  export declare const binaryFlags: {
5
5
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
6
6
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
+ 'app-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
8
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
9
  };
@@ -13,6 +13,12 @@ exports.binaryFlags = {
13
13
  'app-file': core_1.Flags.file({
14
14
  aliases: ['app-file'],
15
15
  description: 'App binary to run your flows against',
16
+ exclusive: ['app-url'],
17
+ }),
18
+ 'app-url': core_1.Flags.string({
19
+ aliases: ['app-url'],
20
+ description: 'Signed URL to an Expo iOS build (.tar.gz). The archive is downloaded and extracted automatically. Expo signed URLs expire after ~1 hour.',
21
+ exclusive: ['app-file'],
16
22
  }),
17
23
  'ignore-sha-check': core_1.Flags.boolean({
18
24
  description: 'Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)',
@@ -0,0 +1,7 @@
1
+ export declare const githubFlags: {
2
+ branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
3
+ 'commit-sha': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
4
+ 'repo-name': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
5
+ 'pr-number': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
6
+ 'pr-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
+ };
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.githubFlags = void 0;
4
+ const core_1 = require("@oclif/core");
5
+ exports.githubFlags = {
6
+ branch: core_1.Flags.string({
7
+ description: 'Git branch name for this run (stored as gh_branch metadata)',
8
+ }),
9
+ 'commit-sha': core_1.Flags.string({
10
+ description: 'Git commit SHA for this run (stored as gh_sha metadata)',
11
+ }),
12
+ 'repo-name': core_1.Flags.string({
13
+ description: 'Repository in owner/repo format (stored as gh_repo metadata, e.g. "acme/my-app")',
14
+ }),
15
+ 'pr-number': core_1.Flags.string({
16
+ description: 'Pull request number for this run (stored as gh_pr_number metadata)',
17
+ }),
18
+ 'pr-url': core_1.Flags.string({
19
+ description: 'Pull request URL for this run (stored as gh_pr_url metadata)',
20
+ }),
21
+ };
@@ -20,6 +20,11 @@ export declare const flags: {
20
20
  'json-file-name': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
21
21
  quiet: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
22
22
  report: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
23
+ branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
24
+ 'commit-sha': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
25
+ 'repo-name': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
26
+ 'pr-number': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
27
+ 'pr-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
23
28
  config: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
24
29
  'exclude-flows': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
25
30
  'exclude-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
@@ -47,6 +52,7 @@ export declare const flags: {
47
52
  'disable-animations': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
48
53
  'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
49
54
  'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
55
+ 'app-url': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
50
56
  'ignore-sha-check': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
51
57
  apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
52
58
  apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
package/dist/constants.js CHANGED
@@ -10,6 +10,7 @@ const binary_flags_1 = require("./config/flags/binary.flags");
10
10
  const device_flags_1 = require("./config/flags/device.flags");
11
11
  const environment_flags_1 = require("./config/flags/environment.flags");
12
12
  const execution_flags_1 = require("./config/flags/execution.flags");
13
+ const github_flags_1 = require("./config/flags/github.flags");
13
14
  const output_flags_1 = require("./config/flags/output.flags");
14
15
  /**
15
16
  * All flag definitions consolidated from domain-specific flag modules.
@@ -21,5 +22,6 @@ exports.flags = {
21
22
  ...device_flags_1.deviceFlags,
22
23
  ...environment_flags_1.environmentFlags,
23
24
  ...execution_flags_1.executionFlags,
25
+ ...github_flags_1.githubFlags,
24
26
  ...output_flags_1.outputFlags,
25
27
  };
@@ -32,6 +32,15 @@ export declare class IosZipMetadataExtractor implements IMetadataExtractor {
32
32
  extract(filePath: string): Promise<TAppMetadata>;
33
33
  private parseInfoPlist;
34
34
  }
35
+ /**
36
+ * Extracts metadata from Expo iOS .tar.gz archives by extracting the
37
+ * archive to a temp directory, finding the .app bundle inside, then
38
+ * delegating to IosAppMetadataExtractor.
39
+ */
40
+ export declare class ExpoTarGzMetadataExtractor implements IMetadataExtractor {
41
+ canHandle(filePath: string): boolean;
42
+ extract(filePath: string): Promise<TAppMetadata>;
43
+ }
35
44
  /**
36
45
  * Service for extracting app metadata from various file formats
37
46
  */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MetadataExtractorService = exports.IosZipMetadataExtractor = exports.IosAppMetadataExtractor = exports.AndroidMetadataExtractor = void 0;
3
+ exports.MetadataExtractorService = exports.ExpoTarGzMetadataExtractor = exports.IosZipMetadataExtractor = exports.IosAppMetadataExtractor = exports.AndroidMetadataExtractor = void 0;
4
4
  const AppInfoParser = require("app-info-parser");
5
5
  const bplist_parser_1 = require("bplist-parser");
6
6
  const promises_1 = require("node:fs/promises");
@@ -107,12 +107,36 @@ class IosZipMetadataExtractor {
107
107
  }
108
108
  }
109
109
  exports.IosZipMetadataExtractor = IosZipMetadataExtractor;
110
+ /**
111
+ * Extracts metadata from Expo iOS .tar.gz archives by extracting the
112
+ * archive to a temp directory, finding the .app bundle inside, then
113
+ * delegating to IosAppMetadataExtractor.
114
+ */
115
+ class ExpoTarGzMetadataExtractor {
116
+ canHandle(filePath) {
117
+ return filePath.endsWith('.tar.gz');
118
+ }
119
+ async extract(filePath) {
120
+ const { extractTarGz, findAppBundle } = await Promise.resolve().then(() => require('../utils/expo'));
121
+ const extractDir = await extractTarGz(filePath, false);
122
+ try {
123
+ const appPath = await findAppBundle(extractDir);
124
+ const iosExtractor = new IosAppMetadataExtractor();
125
+ return await iosExtractor.extract(appPath);
126
+ }
127
+ finally {
128
+ await (0, promises_1.rm)(extractDir, { recursive: true, force: true }).catch(() => { });
129
+ }
130
+ }
131
+ }
132
+ exports.ExpoTarGzMetadataExtractor = ExpoTarGzMetadataExtractor;
110
133
  /**
111
134
  * Service for extracting app metadata from various file formats
112
135
  */
113
136
  class MetadataExtractorService {
114
137
  extractors = [
115
138
  new AndroidMetadataExtractor(),
139
+ new ExpoTarGzMetadataExtractor(),
116
140
  new IosZipMetadataExtractor(),
117
141
  new IosAppMetadataExtractor(),
118
142
  ];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Returns true if the input looks like an HTTP/HTTPS URL.
3
+ * @param input - String to test
4
+ * @returns True if the string begins with http:// or https://
5
+ */
6
+ export declare function isUrl(input: string): boolean;
7
+ /**
8
+ * Downloads a file from a URL to a temp file and returns the temp path.
9
+ * Retries up to 3 times on network error. Expo signed URLs expire after ~1 hour,
10
+ * so a clear error is shown on 401/403 responses.
11
+ * Caller is responsible for deleting the returned file.
12
+ * @param url - HTTPS URL to download from
13
+ * @param debug - Whether to emit debug log lines
14
+ * @returns Absolute path to the downloaded temp file
15
+ */
16
+ export declare function downloadExpoUrl(url: string, debug: boolean): Promise<string>;
17
+ /**
18
+ * Extracts a .tar.gz archive to a new temp directory and returns the directory path.
19
+ * Caller is responsible for deleting the returned directory.
20
+ * @param tarPath - Absolute path to the .tar.gz file
21
+ * @param debug - Whether to emit debug log lines
22
+ * @returns Absolute path to the newly created extract directory
23
+ */
24
+ export declare function extractTarGz(tarPath: string, debug: boolean): Promise<string>;
25
+ /**
26
+ * Recursively searches a directory for the shallowest .app bundle and returns its absolute path.
27
+ * Handles both root-level bundles (MyApp.app/) and nested layouts (Payload/MyApp.app/).
28
+ * @param dir - Directory to search within
29
+ * @returns Absolute path to the .app directory
30
+ */
31
+ export declare function findAppBundle(dir: string): Promise<string>;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUrl = isUrl;
4
+ exports.downloadExpoUrl = downloadExpoUrl;
5
+ exports.extractTarGz = extractTarGz;
6
+ exports.findAppBundle = findAppBundle;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const fs = require("node:fs");
9
+ const fsp = require("node:fs/promises");
10
+ const os = require("node:os");
11
+ const path = require("node:path");
12
+ const node_stream_1 = require("node:stream");
13
+ const promises_1 = require("node:stream/promises");
14
+ const tar = require("tar");
15
+ const DOWNLOAD_RETRY_ATTEMPTS = 3;
16
+ const DOWNLOAD_RETRY_DELAY_MS = 2000;
17
+ /**
18
+ * Returns true if the input looks like an HTTP/HTTPS URL.
19
+ * @param input - String to test
20
+ * @returns True if the string begins with http:// or https://
21
+ */
22
+ function isUrl(input) {
23
+ return input.startsWith('http://') || input.startsWith('https://');
24
+ }
25
+ /**
26
+ * Downloads a file from a URL to a temp file and returns the temp path.
27
+ * Retries up to 3 times on network error. Expo signed URLs expire after ~1 hour,
28
+ * so a clear error is shown on 401/403 responses.
29
+ * Caller is responsible for deleting the returned file.
30
+ * @param url - HTTPS URL to download from
31
+ * @param debug - Whether to emit debug log lines
32
+ * @returns Absolute path to the downloaded temp file
33
+ */
34
+ async function downloadExpoUrl(url, debug) {
35
+ const destPath = path.join(os.tmpdir(), `dcd-expo-${(0, node_crypto_1.randomUUID)()}.tar.gz`);
36
+ for (let attempt = 1; attempt <= DOWNLOAD_RETRY_ATTEMPTS; attempt++) {
37
+ if (debug) {
38
+ console.log(`[DEBUG] Downloading Expo URL (attempt ${attempt}/${DOWNLOAD_RETRY_ATTEMPTS}): ${url}`);
39
+ }
40
+ try {
41
+ const response = await fetch(url);
42
+ if (!response.ok) {
43
+ if (response.status === 403 || response.status === 401) {
44
+ throw new Error(`Failed to download Expo build from URL (HTTP ${response.status}). Expo signed URLs expire after ~1 hour — please generate a fresh URL with 'eas build' and try again.`);
45
+ }
46
+ throw new Error(`Failed to download Expo build from URL (HTTP ${response.status}).`);
47
+ }
48
+ if (!response.body) {
49
+ throw new Error('No response body received from Expo URL.');
50
+ }
51
+ // Stream to disk using pipeline to handle backpressure and avoid loading
52
+ // the entire archive in memory
53
+ await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(response.body), fs.createWriteStream(destPath));
54
+ if (debug) {
55
+ const stat = await fsp.stat(destPath);
56
+ console.log(`[DEBUG] Downloaded ${(stat.size / 1024 / 1024).toFixed(2)} MB to ${destPath}`);
57
+ }
58
+ return destPath;
59
+ }
60
+ catch (error) {
61
+ // Clean up any partial file before retrying
62
+ await fsp.rm(destPath, { force: true }).catch(() => { });
63
+ const isLastAttempt = attempt === DOWNLOAD_RETRY_ATTEMPTS;
64
+ if (isLastAttempt) {
65
+ throw error;
66
+ }
67
+ if (debug) {
68
+ console.log(`[DEBUG] Download failed (attempt ${attempt}), retrying in ${DOWNLOAD_RETRY_DELAY_MS}ms...`);
69
+ }
70
+ await new Promise((resolve) => { setTimeout(resolve, DOWNLOAD_RETRY_DELAY_MS); });
71
+ }
72
+ }
73
+ // Unreachable — loop always throws or returns before exhausting attempts
74
+ throw new Error('Download failed after all retry attempts.');
75
+ }
76
+ /**
77
+ * Extracts a .tar.gz archive to a new temp directory and returns the directory path.
78
+ * Caller is responsible for deleting the returned directory.
79
+ * @param tarPath - Absolute path to the .tar.gz file
80
+ * @param debug - Whether to emit debug log lines
81
+ * @returns Absolute path to the newly created extract directory
82
+ */
83
+ async function extractTarGz(tarPath, debug) {
84
+ const extractDir = path.join(os.tmpdir(), `dcd-expo-${(0, node_crypto_1.randomUUID)()}`);
85
+ await fsp.mkdir(extractDir, { recursive: true });
86
+ if (debug) {
87
+ console.log(`[DEBUG] Extracting ${tarPath} to ${extractDir}`);
88
+ }
89
+ await tar.extract({ cwd: extractDir, file: tarPath });
90
+ if (debug) {
91
+ console.log(`[DEBUG] Extraction complete: ${extractDir}`);
92
+ }
93
+ return extractDir;
94
+ }
95
+ /**
96
+ * Recursively searches a directory for the shallowest .app bundle and returns its absolute path.
97
+ * Handles both root-level bundles (MyApp.app/) and nested layouts (Payload/MyApp.app/).
98
+ * @param dir - Directory to search within
99
+ * @returns Absolute path to the .app directory
100
+ */
101
+ async function findAppBundle(dir) {
102
+ const candidates = [];
103
+ async function walk(current, depth) {
104
+ const entries = await fsp.readdir(current, { withFileTypes: true });
105
+ for (const entry of entries) {
106
+ const fullPath = path.join(current, entry.name);
107
+ if (entry.isDirectory()) {
108
+ if (entry.name.endsWith('.app')) {
109
+ candidates.push({ depth, fullPath });
110
+ // Do not recurse into the .app bundle itself
111
+ }
112
+ else if (entry.name !== '__MACOSX') {
113
+ // Skip __MACOSX metadata directories created by macOS zip utilities
114
+ await walk(fullPath, depth + 1);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ await walk(dir, 0);
120
+ if (candidates.length === 0) {
121
+ throw new Error('No .app bundle found inside the archive. Ensure you are using an iOS Expo build (eas build --platform ios).');
122
+ }
123
+ // Return the shallowest (lowest depth) candidate
124
+ candidates.sort((a, b) => a.depth - b.depth);
125
+ return candidates[0].fullPath;
126
+ }
@@ -1,5 +1,139 @@
1
1
  {
2
2
  "commands": {
3
+ "artifacts": {
4
+ "aliases": [],
5
+ "args": {},
6
+ "description": "Download artifacts or reports for a completed test run",
7
+ "examples": [
8
+ "<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --download-artifacts FAILED",
9
+ "<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --download-artifacts ALL --artifacts-path ./my-artifacts.zip",
10
+ "<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --report junit",
11
+ "<%= config.bin %> <%= command.id %> --upload-id 123e4567-e89b-12d3-a456-426614174000 --report allure --allure-path ./report.html"
12
+ ],
13
+ "flags": {
14
+ "apiKey": {
15
+ "aliases": [
16
+ "api-key"
17
+ ],
18
+ "description": "API key for devicecloud.dev (find this in the console UI). You can also set the DEVICE_CLOUD_API_KEY environment variable.",
19
+ "name": "apiKey",
20
+ "hasDynamicHelp": false,
21
+ "multiple": false,
22
+ "type": "option"
23
+ },
24
+ "apiUrl": {
25
+ "aliases": [
26
+ "api-url",
27
+ "apiURL"
28
+ ],
29
+ "description": "API base URL",
30
+ "hidden": true,
31
+ "name": "apiUrl",
32
+ "default": "https://api.devicecloud.dev",
33
+ "hasDynamicHelp": false,
34
+ "multiple": false,
35
+ "type": "option"
36
+ },
37
+ "debug": {
38
+ "description": "Enable detailed debug logging for troubleshooting issues",
39
+ "name": "debug",
40
+ "allowNo": false,
41
+ "type": "boolean"
42
+ },
43
+ "upload-id": {
44
+ "description": "UUID of the completed upload to download artifacts for",
45
+ "name": "upload-id",
46
+ "required": true,
47
+ "hasDynamicHelp": false,
48
+ "multiple": false,
49
+ "type": "option"
50
+ },
51
+ "download-artifacts": {
52
+ "description": "Download a zip containing the logs, screenshots and videos for this run. Options: ALL (everything), FAILED (failures only).",
53
+ "exclusive": [
54
+ "report"
55
+ ],
56
+ "name": "download-artifacts",
57
+ "hasDynamicHelp": false,
58
+ "multiple": false,
59
+ "options": [
60
+ "ALL",
61
+ "FAILED"
62
+ ],
63
+ "type": "option"
64
+ },
65
+ "artifacts-path": {
66
+ "dependsOn": [
67
+ "download-artifacts"
68
+ ],
69
+ "description": "Custom file path for downloaded artifacts (default: ./artifacts.zip)",
70
+ "name": "artifacts-path",
71
+ "hasDynamicHelp": false,
72
+ "multiple": false,
73
+ "type": "option"
74
+ },
75
+ "report": {
76
+ "description": "Download a test report in the specified format.",
77
+ "exclusive": [
78
+ "download-artifacts"
79
+ ],
80
+ "name": "report",
81
+ "hasDynamicHelp": false,
82
+ "multiple": false,
83
+ "options": [
84
+ "allure",
85
+ "html",
86
+ "html-detailed",
87
+ "junit"
88
+ ],
89
+ "type": "option"
90
+ },
91
+ "allure-path": {
92
+ "dependsOn": [
93
+ "report"
94
+ ],
95
+ "description": "Custom file path for downloaded Allure report (default: ./report.html)",
96
+ "name": "allure-path",
97
+ "hasDynamicHelp": false,
98
+ "multiple": false,
99
+ "type": "option"
100
+ },
101
+ "html-path": {
102
+ "dependsOn": [
103
+ "report"
104
+ ],
105
+ "description": "Custom file path for downloaded HTML report (default: ./report.html)",
106
+ "name": "html-path",
107
+ "hasDynamicHelp": false,
108
+ "multiple": false,
109
+ "type": "option"
110
+ },
111
+ "junit-path": {
112
+ "dependsOn": [
113
+ "report"
114
+ ],
115
+ "description": "Custom file path for downloaded JUnit report (default: ./report.xml)",
116
+ "name": "junit-path",
117
+ "hasDynamicHelp": false,
118
+ "multiple": false,
119
+ "type": "option"
120
+ }
121
+ },
122
+ "hasDynamicHelp": false,
123
+ "hiddenAliases": [],
124
+ "id": "artifacts",
125
+ "pluginAlias": "@devicecloud.dev/dcd",
126
+ "pluginName": "@devicecloud.dev/dcd",
127
+ "pluginType": "core",
128
+ "strict": true,
129
+ "enableJsonFlag": false,
130
+ "isESM": false,
131
+ "relativePath": [
132
+ "dist",
133
+ "commands",
134
+ "artifacts.js"
135
+ ]
136
+ },
3
137
  "cloud": {
4
138
  "aliases": [],
5
139
  "args": {
@@ -63,11 +197,27 @@
63
197
  "app-file"
64
198
  ],
65
199
  "description": "App binary to run your flows against",
200
+ "exclusive": [
201
+ "app-url"
202
+ ],
66
203
  "name": "app-file",
67
204
  "hasDynamicHelp": false,
68
205
  "multiple": false,
69
206
  "type": "option"
70
207
  },
208
+ "app-url": {
209
+ "aliases": [
210
+ "app-url"
211
+ ],
212
+ "description": "Signed URL to an Expo iOS build (.tar.gz). The archive is downloaded and extracted automatically. Expo signed URLs expire after ~1 hour.",
213
+ "exclusive": [
214
+ "app-file"
215
+ ],
216
+ "name": "app-url",
217
+ "hasDynamicHelp": false,
218
+ "multiple": false,
219
+ "type": "option"
220
+ },
71
221
  "ignore-sha-check": {
72
222
  "description": "Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)",
73
223
  "name": "ignore-sha-check",
@@ -313,6 +463,41 @@
313
463
  ],
314
464
  "type": "option"
315
465
  },
466
+ "branch": {
467
+ "description": "Git branch name for this run (stored as gh_branch metadata)",
468
+ "name": "branch",
469
+ "hasDynamicHelp": false,
470
+ "multiple": false,
471
+ "type": "option"
472
+ },
473
+ "commit-sha": {
474
+ "description": "Git commit SHA for this run (stored as gh_sha metadata)",
475
+ "name": "commit-sha",
476
+ "hasDynamicHelp": false,
477
+ "multiple": false,
478
+ "type": "option"
479
+ },
480
+ "repo-name": {
481
+ "description": "Repository in owner/repo format (stored as gh_repo metadata, e.g. \"acme/my-app\")",
482
+ "name": "repo-name",
483
+ "hasDynamicHelp": false,
484
+ "multiple": false,
485
+ "type": "option"
486
+ },
487
+ "pr-number": {
488
+ "description": "Pull request number for this run (stored as gh_pr_number metadata)",
489
+ "name": "pr-number",
490
+ "hasDynamicHelp": false,
491
+ "multiple": false,
492
+ "type": "option"
493
+ },
494
+ "pr-url": {
495
+ "description": "Pull request URL for this run (stored as gh_pr_url metadata)",
496
+ "name": "pr-url",
497
+ "hasDynamicHelp": false,
498
+ "multiple": false,
499
+ "type": "option"
500
+ },
316
501
  "artifacts-path": {
317
502
  "dependsOn": [
318
503
  "download-artifacts"
@@ -610,15 +795,17 @@
610
795
  "aliases": [],
611
796
  "args": {
612
797
  "appFile": {
613
- "description": "The binary file to upload (e.g. test.apk for android or test.app/.zip for ios)",
798
+ "description": "The binary file or Expo signed URL to upload (e.g. test.apk, test.app, test.zip, build.tar.gz, or https://expo.dev/...)",
614
799
  "name": "appFile",
615
- "required": true
800
+ "required": false
616
801
  }
617
802
  },
618
803
  "description": "Upload an app binary to devicecloud.dev",
619
804
  "examples": [
620
805
  "<%= config.bin %> <%= command.id %> path/to/app.apk",
621
- "<%= config.bin %> <%= command.id %> path/to/app.zip --api-key YOUR_API_KEY"
806
+ "<%= config.bin %> <%= command.id %> path/to/app.zip --api-key YOUR_API_KEY",
807
+ "<%= config.bin %> <%= command.id %> path/to/build.tar.gz",
808
+ "<%= config.bin %> <%= command.id %> --app-url https://expo.dev/artifacts/..."
622
809
  ],
623
810
  "flags": {
624
811
  "json": {
@@ -651,6 +838,19 @@
651
838
  "multiple": false,
652
839
  "type": "option"
653
840
  },
841
+ "app-url": {
842
+ "aliases": [
843
+ "app-url"
844
+ ],
845
+ "description": "Signed URL to an Expo iOS build (.tar.gz). The archive is downloaded and extracted automatically. Expo signed URLs expire after ~1 hour.",
846
+ "exclusive": [
847
+ "app-file"
848
+ ],
849
+ "name": "app-url",
850
+ "hasDynamicHelp": false,
851
+ "multiple": false,
852
+ "type": "option"
853
+ },
654
854
  "debug": {
655
855
  "description": "Enable detailed debug logging for troubleshooting issues",
656
856
  "name": "debug",
@@ -680,5 +880,5 @@
680
880
  ]
681
881
  }
682
882
  },
683
- "version": "4.3.3"
883
+ "version": "4.4.0"
684
884
  }
package/package.json CHANGED
@@ -5,36 +5,38 @@
5
5
  },
6
6
  "dependencies": {
7
7
  "@oclif/core": "^3.27.0",
8
- "@oclif/plugin-help": "^6.2.36",
9
- "@supabase/supabase-js": "^2.81.1",
8
+ "@oclif/plugin-help": "^6.2.37",
9
+ "@supabase/supabase-js": "^2.99.1",
10
10
  "app-info-parser": "^1.1.6",
11
11
  "archiver": "^7.0.1",
12
12
  "bplist-parser": "^0.3.2",
13
13
  "chalk": "4.1.2",
14
- "glob": "^11.1.0",
14
+ "glob": "^13.0.6",
15
15
  "js-yaml": "^4.1.1",
16
16
  "node-stream-zip": "^1.15.0",
17
17
  "plist": "^3.1.0",
18
+ "tar": "^7.5.11",
18
19
  "tus-js-client": "^4.3.1"
19
20
  },
20
21
  "description": "Better cloud maestro testing",
21
22
  "devDependencies": {
22
23
  "@oclif/prettier-config": "^0.2.1",
23
- "@oclif/test": "^4.1.15",
24
- "@types/archiver": "^6.0.4",
24
+ "@oclif/test": "^4.1.16",
25
+ "@types/archiver": "^7.0.0",
25
26
  "@types/chai": "^5.2.3",
26
27
  "@types/glob-to-regexp": "^0.4.4",
27
28
  "@types/js-yaml": "^4.0.9",
28
29
  "@types/mocha": "^10.0.10",
29
- "@types/node": "^24.10.1",
30
+ "@types/node": "^25.4.0",
30
31
  "@types/plist": "^3.0.5",
31
- "chai": "^5.3.3",
32
+ "@types/tar": "^7.0.87",
33
+ "chai": "^6.2.2",
32
34
  "eslint": "^8.57.1",
33
35
  "eslint-config-oclif": "^5.2.2",
34
36
  "eslint-config-oclif-typescript": "^3.1.14",
35
37
  "eslint-config-prettier": "^10.1.8",
36
38
  "mocha": "^11.7.5",
37
- "oclif": "^4.22.47",
39
+ "oclif": "^4.22.87",
38
40
  "shx": "^0.4.0",
39
41
  "ts-node": "^10.9.2",
40
42
  "typescript": "^5.9.3"
@@ -67,7 +69,7 @@
67
69
  "type": "git",
68
70
  "url": "https://devicecloud.dev"
69
71
  },
70
- "version": "4.3.3",
72
+ "version": "4.4.0",
71
73
  "bugs": {
72
74
  "url": "https://discord.gg/gm3mJwcNw8"
73
75
  },