@devicecloud.dev/dcd 4.3.3 → 4.4.1
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/artifacts.d.ts +18 -0
- package/dist/commands/artifacts.js +93 -0
- package/dist/commands/cloud.d.ts +6 -0
- package/dist/commands/cloud.js +45 -7
- package/dist/commands/upload.d.ts +1 -0
- package/dist/commands/upload.js +42 -10
- package/dist/config/flags/binary.flags.d.ts +1 -0
- package/dist/config/flags/binary.flags.js +6 -0
- package/dist/config/flags/github.flags.d.ts +7 -0
- package/dist/config/flags/github.flags.js +21 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +2 -0
- package/dist/methods.js +23 -16
- package/dist/services/metadata-extractor.service.d.ts +9 -0
- package/dist/services/metadata-extractor.service.js +25 -1
- package/dist/utils/expo.d.ts +31 -0
- package/dist/utils/expo.js +126 -0
- package/oclif.manifest.json +204 -4
- package/package.json +11 -9
|
@@ -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;
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/dist/commands/upload.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
40
|
-
|
|
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(
|
|
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:
|
|
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
|
+
};
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/methods.js
CHANGED
|
@@ -326,15 +326,15 @@ async function uploadToSupabase(env, tempPath, file, debug) {
|
|
|
326
326
|
* @returns Upload result with success status and any error
|
|
327
327
|
*/
|
|
328
328
|
async function handleBackblazeUpload(config) {
|
|
329
|
-
const { b2, apiUrl, apiKey, finalPath, file, filePath, debug
|
|
329
|
+
const { b2, apiUrl, apiKey, finalPath, file, filePath, debug } = config;
|
|
330
330
|
if (!b2) {
|
|
331
|
-
if (debug
|
|
332
|
-
console.log('[DEBUG] Backblaze not configured,
|
|
331
|
+
if (debug) {
|
|
332
|
+
console.log('[DEBUG] Backblaze not configured, will fall back to Supabase');
|
|
333
333
|
}
|
|
334
334
|
return { error: null, success: false };
|
|
335
335
|
}
|
|
336
336
|
if (debug) {
|
|
337
|
-
console.log(
|
|
337
|
+
console.log('[DEBUG] Starting Backblaze upload (primary)...');
|
|
338
338
|
}
|
|
339
339
|
try {
|
|
340
340
|
const b2UploadStartTime = Date.now();
|
|
@@ -484,10 +484,7 @@ async function performUpload(config) {
|
|
|
484
484
|
// Extract app metadata
|
|
485
485
|
const metadata = await extractBinaryMetadata(filePath, debug);
|
|
486
486
|
const env = apiUrl === 'https://api.devicecloud.dev' ? 'prod' : 'dev';
|
|
487
|
-
// Upload to
|
|
488
|
-
const supabaseResult = await uploadToSupabase(env, tempPath, file, debug);
|
|
489
|
-
let lastError = supabaseResult.error;
|
|
490
|
-
// Upload to Backblaze
|
|
487
|
+
// Upload to Backblaze first (primary)
|
|
491
488
|
const backblazeResult = await handleBackblazeUpload({
|
|
492
489
|
apiKey,
|
|
493
490
|
apiUrl,
|
|
@@ -496,26 +493,36 @@ async function performUpload(config) {
|
|
|
496
493
|
file,
|
|
497
494
|
filePath,
|
|
498
495
|
finalPath,
|
|
499
|
-
supabaseSuccess: supabaseResult.success,
|
|
500
496
|
});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
497
|
+
let lastError = backblazeResult.error;
|
|
498
|
+
// Only upload to Supabase if Backblaze failed
|
|
499
|
+
let supabaseResult = { error: null, success: false };
|
|
500
|
+
if (!backblazeResult.success) {
|
|
501
|
+
if (debug) {
|
|
502
|
+
console.log('[DEBUG] Backblaze upload failed, falling back to Supabase...');
|
|
503
|
+
}
|
|
504
|
+
supabaseResult = await uploadToSupabase(env, tempPath, file, debug);
|
|
505
|
+
if (!supabaseResult.success && supabaseResult.error) {
|
|
506
|
+
lastError = supabaseResult.error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else if (debug) {
|
|
510
|
+
console.log('[DEBUG] Backblaze upload succeeded, skipping Supabase upload');
|
|
504
511
|
}
|
|
505
512
|
// Validate results
|
|
506
513
|
validateUploadResults(supabaseResult.success, backblazeResult.success, lastError, b2, debug);
|
|
507
514
|
// Log upload summary
|
|
508
515
|
if (debug) {
|
|
509
|
-
const hasWarning =
|
|
510
|
-
console.log(`[DEBUG] Upload summary -
|
|
516
|
+
const hasWarning = supabaseResult.success && !backblazeResult.success;
|
|
517
|
+
console.log(`[DEBUG] Upload summary - Backblaze: ${backblazeResult.success ? '✓' : '✗'}, Supabase: ${supabaseResult.success ? '✓' : '✗ (fallback ' + (backblazeResult.success ? 'not needed' : 'attempted') + ')'}`);
|
|
511
518
|
console.log('[DEBUG] Finalizing upload...');
|
|
512
519
|
console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/finaliseUpload`);
|
|
513
520
|
console.log(`[DEBUG] Uploaded to staging path: ${tempPath}`);
|
|
514
521
|
console.log(`[DEBUG] API will move to final path: ${finalPath}`);
|
|
515
|
-
console.log(`[DEBUG] Supabase upload status: ${supabaseResult.success ? 'SUCCESS' : 'FAILED'}`);
|
|
516
522
|
console.log(`[DEBUG] Backblaze upload status: ${backblazeResult.success ? 'SUCCESS' : 'FAILED'}`);
|
|
523
|
+
console.log(`[DEBUG] Supabase upload status: ${supabaseResult.success ? 'SUCCESS (fallback)' : backblazeResult.success ? 'SKIPPED' : 'FAILED'}`);
|
|
517
524
|
if (hasWarning)
|
|
518
|
-
console.log('[DEBUG] ⚠ Warning: File only exists in
|
|
525
|
+
console.log('[DEBUG] ⚠ Warning: File only exists in Supabase (Backblaze failed)');
|
|
519
526
|
}
|
|
520
527
|
// Finalize upload
|
|
521
528
|
const finalizeStartTime = Date.now();
|
|
@@ -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
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -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
|
|
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":
|
|
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.
|
|
883
|
+
"version": "4.4.1"
|
|
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.
|
|
9
|
-
"@supabase/supabase-js": "^2.
|
|
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": "^
|
|
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.
|
|
24
|
-
"@types/archiver": "^
|
|
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": "^
|
|
30
|
+
"@types/node": "^25.4.0",
|
|
30
31
|
"@types/plist": "^3.0.5",
|
|
31
|
-
"
|
|
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.
|
|
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.
|
|
72
|
+
"version": "4.4.1",
|
|
71
73
|
"bugs": {
|
|
72
74
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
73
75
|
},
|