eoas 3.1.2 → 3.2.0-beta1
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/doctor.js +3 -12
- package/dist/commands/init.js +2 -2
- package/dist/commands/publish.js +53 -22
- package/dist/commands/republish.js +1 -1
- package/dist/commands/rollback.js +1 -1
- package/dist/commands/server/init.js +56 -9
- package/dist/lib/assets.d.ts +21 -2
- package/dist/lib/assets.js +99 -22
- package/dist/lib/crypto.d.ts +8 -0
- package/dist/lib/crypto.js +27 -0
- package/dist/lib/expoConfig.d.ts +1 -0
- package/dist/lib/expoConfig.js +13 -2
- package/dist/lib/serverConfig/choices.d.ts +2 -0
- package/dist/lib/serverConfig/envCatalog.js +16 -1
- package/dist/lib/utils.js +17 -1
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -60,19 +60,10 @@ class Doctor extends core_1.Command {
|
|
|
60
60
|
const privateConfig = await (0, expoConfig_1.getPrivateExpoConfigAsync)(projectDir, {
|
|
61
61
|
env: process.env,
|
|
62
62
|
});
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
log_1.default.error("Update url is not setup in your config. Please run 'eoas init' to setup the update url, or pass --url");
|
|
63
|
+
const baseUrl = await (0, expoConfig_1.resolveServerUrl)(privateConfig, flags.url).catch(e => {
|
|
64
|
+
log_1.default.error(e.message);
|
|
66
65
|
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
let baseUrl;
|
|
69
|
-
try {
|
|
70
|
-
baseUrl = new URL(updateUrl).origin;
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
log_1.default.error('Invalid URL', e);
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
66
|
+
});
|
|
76
67
|
const appId = flags.appId ?? (0, expoConfig_1.getExpoAppId)(privateConfig);
|
|
77
68
|
log_1.default.log(`🩺 Probing ${baseUrl} on channel '${flags.channel}'`);
|
|
78
69
|
log_1.default.newLine();
|
package/dist/commands/init.js
CHANGED
|
@@ -38,7 +38,7 @@ class Init extends core_1.Command {
|
|
|
38
38
|
validate: v => !!v,
|
|
39
39
|
});
|
|
40
40
|
const { updateUrl: promptedUrl } = await (0, prompts_1.promptAsync)({
|
|
41
|
-
message: 'Enter the URL of your update server (ex: https://customota.com)',
|
|
41
|
+
message: 'Enter the URL of your update server (ex: https://customota.com or https://api.example.com/ota)',
|
|
42
42
|
name: 'updateUrl',
|
|
43
43
|
type: 'text',
|
|
44
44
|
initial: ((0, expoConfig_1.getExpoConfigUpdateUrl)(config) || '').replace(/\/manifest$/, ''),
|
|
@@ -46,7 +46,7 @@ class Init extends core_1.Command {
|
|
|
46
46
|
return !!v && (0, utils_1.isValidUpdateUrl)(v);
|
|
47
47
|
},
|
|
48
48
|
});
|
|
49
|
-
let manifestEndpoint = `${promptedUrl}/manifest`;
|
|
49
|
+
let manifestEndpoint = `${promptedUrl.replace(/\/+$/, '')}/manifest`;
|
|
50
50
|
const updateUrl = (0, expoConfig_1.getExpoConfigUpdateUrl)(config);
|
|
51
51
|
if (updateUrl && !updateUrl.includes('expo.dev')) {
|
|
52
52
|
const confirmed = await (0, prompts_1.confirmAsync)({
|
package/dist/commands/publish.js
CHANGED
|
@@ -51,7 +51,7 @@ class Publish extends core_1.Command {
|
|
|
51
51
|
required: true,
|
|
52
52
|
}),
|
|
53
53
|
serverUrl: core_1.Flags.string({
|
|
54
|
-
description: 'URL of the self-hosted update server to publish to. Defaults to
|
|
54
|
+
description: 'URL of the self-hosted update server to publish to. Defaults to updates.url from your Expo config, minus a trailing /manifest',
|
|
55
55
|
required: false,
|
|
56
56
|
}),
|
|
57
57
|
nonInteractive: core_1.Flags.boolean({
|
|
@@ -72,8 +72,9 @@ class Publish extends core_1.Command {
|
|
|
72
72
|
required: false,
|
|
73
73
|
}),
|
|
74
74
|
dumpSourcemap: core_1.Flags.boolean({
|
|
75
|
-
description: 'Emit Hermes source maps alongside the bundle so the
|
|
76
|
-
default:
|
|
75
|
+
description: 'Emit Hermes source maps alongside the bundle (default: true). Without a source map Hermes bakes a random temp path into the bytecode, so two exports of identical code never hash the same and server-side change detection cannot work. The maps also let tools like Sentry or PostHog symbolicate the published artifact; they stay in the output directory and are never uploaded. Disable with --no-dumpSourcemap.',
|
|
76
|
+
default: true,
|
|
77
|
+
allowNo: true,
|
|
77
78
|
}),
|
|
78
79
|
'rollout-percentage': core_1.Flags.integer({
|
|
79
80
|
min: 1,
|
|
@@ -259,7 +260,7 @@ class Publish extends core_1.Command {
|
|
|
259
260
|
});
|
|
260
261
|
log_1.default.withInfo(`expoConfig.json file created in ${outputDir} directory`);
|
|
261
262
|
const uploadFilesSpinner = (0, ora_1.ora)('📤 Uploading files...').start();
|
|
262
|
-
const files = (0, assets_1.computeFilesRequests)(projectDir, outputDir, platform || expoConfig_1.RequestedPlatform.All);
|
|
263
|
+
const files = await (0, assets_1.computeFilesRequests)(projectDir, outputDir, platform || expoConfig_1.RequestedPlatform.All);
|
|
263
264
|
if (!files.length) {
|
|
264
265
|
uploadFilesSpinner.fail('No files to upload');
|
|
265
266
|
process.exit(1);
|
|
@@ -268,30 +269,47 @@ class Publish extends core_1.Command {
|
|
|
268
269
|
// One group id for the whole run: every platform update of this publish
|
|
269
270
|
// shares it, so control plane servers list them as a single publish.
|
|
270
271
|
const publishGroupId = (0, crypto_1.randomUUID)();
|
|
272
|
+
// Collected rather than logged inline: writing to the terminal under a
|
|
273
|
+
// running spinner interleaves with its frames.
|
|
274
|
+
const unchangedPlatforms = [];
|
|
271
275
|
try {
|
|
272
|
-
|
|
276
|
+
const outcomes = await Promise.all(runtimeVersions.map(async ({ runtimeVersion, platform }) => {
|
|
273
277
|
if (!runtimeVersion) {
|
|
274
278
|
throw new Error('Runtime version is not resolved');
|
|
275
279
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
280
|
+
try {
|
|
281
|
+
return {
|
|
282
|
+
...(await (0, assets_1.requestUploadUrls)({
|
|
283
|
+
body: { files: (0, assets_1.buildUploadFiles)(files, platform) },
|
|
284
|
+
requestUploadUrl: `${serverUrl}/${appId}/requestUploadUrl/${branch}`,
|
|
285
|
+
auth: credentials,
|
|
286
|
+
runtimeVersion,
|
|
287
|
+
platform,
|
|
288
|
+
commitHash,
|
|
289
|
+
message: resolvedMessage,
|
|
290
|
+
rolloutPercentage,
|
|
291
|
+
publishGroup: publishGroupId,
|
|
292
|
+
branch,
|
|
293
|
+
})),
|
|
283
294
|
runtimeVersion,
|
|
284
295
|
platform,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (error instanceof assets_1.NoChangesDetectedError) {
|
|
300
|
+
unchangedPlatforms.push(platform);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
294
305
|
}));
|
|
306
|
+
uploadUrls = outcomes.filter((entry) => {
|
|
307
|
+
return entry !== null;
|
|
308
|
+
});
|
|
309
|
+
if (!uploadUrls.length) {
|
|
310
|
+
uploadFilesSpinner.warn('⚠️ No changes found in the update, nothing to deploy');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
295
313
|
// Every path and URL the server handed back is checked here, before a
|
|
296
314
|
// single file is opened. A server that forges a filePath would otherwise
|
|
297
315
|
// make the CLI read arbitrary files off this machine and PUT them wherever
|
|
@@ -358,6 +376,14 @@ class Publish extends core_1.Command {
|
|
|
358
376
|
}
|
|
359
377
|
}));
|
|
360
378
|
uploadFilesSpinner.succeed('✅ Files uploaded successfully');
|
|
379
|
+
for (const { platform: uploadedPlatform, uploadRequests } of uploadUrls) {
|
|
380
|
+
const totalFiles = (0, assets_1.buildUploadFiles)(files, uploadedPlatform).length;
|
|
381
|
+
const deduplicated = totalFiles - uploadRequests.length;
|
|
382
|
+
log_1.default.withInfo(`📊 ${uploadedPlatform}: ${uploadRequests.length}/${totalFiles} files uploaded, ${deduplicated} deduplicated (already on the server)`);
|
|
383
|
+
}
|
|
384
|
+
for (const skipped of unchangedPlatforms) {
|
|
385
|
+
log_1.default.withInfo(`⚠️ There is no change in the update for ${skipped}, ignored...`);
|
|
386
|
+
}
|
|
361
387
|
}
|
|
362
388
|
catch (e) {
|
|
363
389
|
uploadFilesSpinner.fail('❌ Failed to upload static files');
|
|
@@ -423,9 +449,14 @@ class Publish extends core_1.Command {
|
|
|
423
449
|
if (hasSuccess) {
|
|
424
450
|
log_1.default.withInfo(`🌿 Branch: \`${branch}\``);
|
|
425
451
|
log_1.default.withInfo(`⏳ Deployed at: \`${new Date().toUTCString()}\`\n`);
|
|
452
|
+
for (const { platform: publishedPlatform, updateId } of uploadUrls) {
|
|
453
|
+
log_1.default.withInfo(`🆔 ${publishedPlatform} update id: \`${updateId}\``);
|
|
454
|
+
}
|
|
426
455
|
const groupAcknowledged = uploadUrls.every(u => u.publishGroup === publishGroupId);
|
|
427
456
|
if (groupAcknowledged) {
|
|
428
|
-
log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}
|
|
457
|
+
log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}\` (groups the ${uploadUrls
|
|
458
|
+
.map(u => u.platform)
|
|
459
|
+
.join(' + ')} updates of this run)`);
|
|
429
460
|
}
|
|
430
461
|
else if (uploadUrls.length > 1) {
|
|
431
462
|
// Only worth a note when several platforms were published: a single
|
|
@@ -28,7 +28,7 @@ class Publish extends core_1.Command {
|
|
|
28
28
|
required: true,
|
|
29
29
|
}),
|
|
30
30
|
serverUrl: core_1.Flags.string({
|
|
31
|
-
description: 'URL of the self-hosted update server to republish on. Defaults to
|
|
31
|
+
description: 'URL of the self-hosted update server to republish on. Defaults to updates.url from your Expo config, minus a trailing /manifest',
|
|
32
32
|
required: false,
|
|
33
33
|
}),
|
|
34
34
|
};
|
|
@@ -29,7 +29,7 @@ class Publish extends core_1.Command {
|
|
|
29
29
|
required: true,
|
|
30
30
|
}),
|
|
31
31
|
serverUrl: core_1.Flags.string({
|
|
32
|
-
description: 'URL of the self-hosted update server to roll back on. Defaults to
|
|
32
|
+
description: 'URL of the self-hosted update server to roll back on. Defaults to updates.url from your Expo config, minus a trailing /manifest',
|
|
33
33
|
required: false,
|
|
34
34
|
}),
|
|
35
35
|
nonInteractive: core_1.Flags.boolean({
|
|
@@ -64,6 +64,18 @@ function isHttpUrl(value) {
|
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
/** The path of the URL without trailing slash; '' when at the root or invalid. */
|
|
68
|
+
function pathPrefix(baseUrl) {
|
|
69
|
+
if (!baseUrl) {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return new URL(baseUrl).pathname.replace(/\/+$/, '');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
67
79
|
function shortPasswordHint(missing) {
|
|
68
80
|
const short = missing.map(rule => rule
|
|
69
81
|
.replace('at least 8 characters', '8+ chars')
|
|
@@ -117,8 +129,19 @@ function summaryLines(state) {
|
|
|
117
129
|
const storageChoice = STORAGE_CHOICES.find(choice => choice.value === state.storagePick);
|
|
118
130
|
const set = ACCENT;
|
|
119
131
|
const later = chalk_1.default.dim('to fill later');
|
|
132
|
+
const prefix = pathPrefix(state.baseUrl);
|
|
120
133
|
const rows = [
|
|
121
134
|
['Base URL', state.baseUrl ? set(state.baseUrl) : later],
|
|
135
|
+
...(prefix
|
|
136
|
+
? [
|
|
137
|
+
[
|
|
138
|
+
'Path prefix',
|
|
139
|
+
set(state.serveFromSubPath
|
|
140
|
+
? `${prefix} · routed by the server`
|
|
141
|
+
: `${prefix} · stripped by the proxy`),
|
|
142
|
+
],
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
122
145
|
['JWT secret', state.jwtSecret ? set('generated') : later],
|
|
123
146
|
['PostgreSQL', state.dbUrl ? set(state.dbUrl) : later],
|
|
124
147
|
['Master key', set('generated, keep a backup')],
|
|
@@ -165,16 +188,39 @@ class ServerInit extends core_1.Command {
|
|
|
165
188
|
{
|
|
166
189
|
id: 'base-url',
|
|
167
190
|
run: async () => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
191
|
+
while (true) {
|
|
192
|
+
const value = await (0, prompts_1.textStep)('Public HTTPS URL of the server', {
|
|
193
|
+
optional: true,
|
|
194
|
+
initial: state.baseUrl,
|
|
195
|
+
validate: v => isHttpUrl(v) || 'Must be a valid http(s) URL',
|
|
196
|
+
});
|
|
197
|
+
if (value === prompts_1.BACK) {
|
|
198
|
+
return 'back';
|
|
199
|
+
}
|
|
200
|
+
state.baseUrl = value;
|
|
201
|
+
const prefix = pathPrefix(value);
|
|
202
|
+
if (!prefix) {
|
|
203
|
+
state.serveFromSubPath = undefined;
|
|
204
|
+
return 'next';
|
|
205
|
+
}
|
|
206
|
+
const passthrough = await (0, prompts_1.selectStep)(`How does your proxy forward the ${prefix} prefix?`, [
|
|
207
|
+
{
|
|
208
|
+
title: 'It strips it',
|
|
209
|
+
value: false,
|
|
210
|
+
description: `${prefix}/manifest reaches the server as /manifest`,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
title: 'It passes it through',
|
|
214
|
+
value: true,
|
|
215
|
+
description: `the server routes under ${prefix} itself`,
|
|
216
|
+
},
|
|
217
|
+
], { allowBack: true, initial: state.serveFromSubPath ?? false });
|
|
218
|
+
if (passthrough === prompts_1.BACK) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
state.serveFromSubPath = passthrough;
|
|
222
|
+
return 'next';
|
|
175
223
|
}
|
|
176
|
-
state.baseUrl = value;
|
|
177
|
-
return 'next';
|
|
178
224
|
},
|
|
179
225
|
},
|
|
180
226
|
{
|
|
@@ -552,6 +598,7 @@ class ServerInit extends core_1.Command {
|
|
|
552
598
|
}
|
|
553
599
|
const choices = {
|
|
554
600
|
baseUrl: state.baseUrl,
|
|
601
|
+
serveFromSubPath: state.serveFromSubPath,
|
|
555
602
|
jwtSecret: state.jwtSecret,
|
|
556
603
|
dbUrl: state.dbUrl,
|
|
557
604
|
// The wizard always seals the master key in the env; storing it in AWS
|
package/dist/lib/assets.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Platform } from '@expo/config';
|
|
1
2
|
import Joi from 'joi';
|
|
2
3
|
import { Credentials } from './auth';
|
|
3
4
|
import { RequestedPlatform } from './expoConfig';
|
|
@@ -6,12 +7,26 @@ export interface AssetToUpload {
|
|
|
6
7
|
path: string;
|
|
7
8
|
name: string;
|
|
8
9
|
ext: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
key: string;
|
|
12
|
+
platform: Platform | null;
|
|
13
|
+
isLaunchAsset: boolean;
|
|
9
14
|
}
|
|
10
|
-
export
|
|
15
|
+
export type FileRole = 'launch' | 'asset' | 'config';
|
|
16
|
+
export interface FileUploadItem {
|
|
17
|
+
path: string;
|
|
18
|
+
hash: string;
|
|
19
|
+
key?: string;
|
|
20
|
+
ext?: string;
|
|
21
|
+
role: FileRole;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildUploadFiles(files: AssetToUpload[], platform: string): FileUploadItem[];
|
|
24
|
+
export declare function computeFilesRequests(projectDir: string, outputDir: string, requestedPlatform: RequestedPlatform): Promise<AssetToUpload[]>;
|
|
11
25
|
export interface RequestUploadUrlItem {
|
|
12
26
|
requestUploadUrl: string;
|
|
13
27
|
fileName: string;
|
|
14
28
|
filePath: string;
|
|
29
|
+
originalFileName: string;
|
|
15
30
|
headers?: Record<string, string>;
|
|
16
31
|
}
|
|
17
32
|
export interface RequestUploadUrlsResponse {
|
|
@@ -41,9 +56,13 @@ export declare function resolveUploadRequests({ uploadRequests, exportDir, manif
|
|
|
41
56
|
manifest: AssetToUpload[];
|
|
42
57
|
}): Promise<ResolvedUploadRequest[]>;
|
|
43
58
|
export declare function activeRolloutConflictMessage(branch: string): string;
|
|
59
|
+
export declare class NoChangesDetectedError extends Error {
|
|
60
|
+
readonly platform: string;
|
|
61
|
+
constructor(platform: string);
|
|
62
|
+
}
|
|
44
63
|
export declare function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion, platform, commitHash, message, rolloutPercentage, publishGroup, branch, }: {
|
|
45
64
|
body: {
|
|
46
|
-
|
|
65
|
+
files: FileUploadItem[];
|
|
47
66
|
};
|
|
48
67
|
requestUploadUrl: string;
|
|
49
68
|
auth: Credentials;
|
package/dist/lib/assets.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.requestUploadUrls = exports.activeRolloutConflictMessage = exports.resolveUploadRequests = exports.RequestUploadUrlsResponseJoi = exports.computeFilesRequests = exports.MetadataJoi = void 0;
|
|
3
|
+
exports.requestUploadUrls = exports.NoChangesDetectedError = exports.activeRolloutConflictMessage = exports.resolveUploadRequests = exports.RequestUploadUrlsResponseJoi = exports.computeFilesRequests = exports.buildUploadFiles = exports.MetadataJoi = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
|
|
6
6
|
const joi_1 = tslib_1.__importDefault(require("joi"));
|
|
7
7
|
const path_1 = tslib_1.__importDefault(require("path"));
|
|
8
8
|
const auth_1 = require("./auth");
|
|
9
|
+
const crypto_1 = require("./crypto");
|
|
9
10
|
const expoConfig_1 = require("./expoConfig");
|
|
10
11
|
const fetch_1 = require("./fetch");
|
|
11
12
|
const log_1 = tslib_1.__importDefault(require("./log"));
|
|
@@ -24,6 +25,26 @@ exports.MetadataJoi = joi_1.default.object({
|
|
|
24
25
|
web: fileMetadataJoi,
|
|
25
26
|
}).required(),
|
|
26
27
|
}).required();
|
|
28
|
+
// buildUploadFiles is one platform's publish: its launch asset, its assets and
|
|
29
|
+
// the config files, each stamped with its role. This is what tells the server
|
|
30
|
+
// which bundle is which — only the CLI reads metadata.json.
|
|
31
|
+
function buildUploadFiles(files, platform) {
|
|
32
|
+
return files
|
|
33
|
+
.filter(file => file.platform === null || file.platform === platform)
|
|
34
|
+
.map(file => {
|
|
35
|
+
if (file.platform === null) {
|
|
36
|
+
return { path: file.path, hash: file.hash, role: 'config' };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
path: file.path,
|
|
40
|
+
hash: file.hash,
|
|
41
|
+
key: file.key,
|
|
42
|
+
ext: file.ext,
|
|
43
|
+
role: file.isLaunchAsset ? 'launch' : 'asset',
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
exports.buildUploadFiles = buildUploadFiles;
|
|
27
48
|
function loadMetadata(distRoot) {
|
|
28
49
|
// eslint-disable-next-line
|
|
29
50
|
const fileContent = fs_extra_1.default.readFileSync(path_1.default.join(distRoot, 'metadata.json'), 'utf8');
|
|
@@ -54,23 +75,65 @@ function loadMetadata(distRoot) {
|
|
|
54
75
|
log_1.default.debug(`Loaded ${platforms.length} platform(s): ${platforms.join(', ')}`);
|
|
55
76
|
return metadata;
|
|
56
77
|
}
|
|
57
|
-
function
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
async function digestExportFile(exportRoot, relativePath) {
|
|
79
|
+
const absolutePath = path_1.default.resolve(exportRoot, relativePath);
|
|
80
|
+
if (absolutePath !== exportRoot && !absolutePath.startsWith(exportRoot + path_1.default.sep)) {
|
|
81
|
+
throw new Error(`Refusing to hash "${relativePath}": it resolves outside the export directory.`);
|
|
82
|
+
}
|
|
83
|
+
return await (0, crypto_1.digestFile)(absolutePath);
|
|
84
|
+
}
|
|
85
|
+
async function computeFilesRequests(projectDir, outputDir, requestedPlatform) {
|
|
86
|
+
const exportDir = path_1.default.join(projectDir, outputDir);
|
|
87
|
+
let exportRoot;
|
|
88
|
+
try {
|
|
89
|
+
exportRoot = await fs_extra_1.default.realpath(exportDir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Export directory ${exportDir} could not be resolved.`);
|
|
93
|
+
}
|
|
94
|
+
const metadata = loadMetadata(exportRoot);
|
|
95
|
+
const pending = [
|
|
96
|
+
{
|
|
97
|
+
path: 'metadata.json',
|
|
98
|
+
name: 'metadata.json',
|
|
99
|
+
ext: 'json',
|
|
100
|
+
platform: null,
|
|
101
|
+
isLaunchAsset: false,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: 'expoConfig.json',
|
|
105
|
+
name: 'expoConfig.json',
|
|
106
|
+
ext: 'json',
|
|
107
|
+
platform: null,
|
|
108
|
+
isLaunchAsset: false,
|
|
109
|
+
},
|
|
62
110
|
];
|
|
63
111
|
for (const platform of Object.keys(metadata.fileMetadata)) {
|
|
64
112
|
if (requestedPlatform !== expoConfig_1.RequestedPlatform.All && requestedPlatform !== platform) {
|
|
65
113
|
continue;
|
|
66
114
|
}
|
|
67
115
|
const bundle = metadata.fileMetadata[platform].bundle;
|
|
68
|
-
|
|
116
|
+
pending.push({
|
|
117
|
+
path: bundle,
|
|
118
|
+
name: path_1.default.basename(bundle),
|
|
119
|
+
ext: 'hbc',
|
|
120
|
+
platform,
|
|
121
|
+
isLaunchAsset: true,
|
|
122
|
+
});
|
|
69
123
|
for (const asset of metadata.fileMetadata[platform].assets) {
|
|
70
|
-
|
|
124
|
+
pending.push({
|
|
125
|
+
path: asset.path,
|
|
126
|
+
name: path_1.default.basename(asset.path),
|
|
127
|
+
ext: asset.ext,
|
|
128
|
+
platform,
|
|
129
|
+
isLaunchAsset: false,
|
|
130
|
+
});
|
|
71
131
|
}
|
|
72
132
|
}
|
|
73
|
-
return
|
|
133
|
+
return await Promise.all(pending.map(async (entry) => ({
|
|
134
|
+
...entry,
|
|
135
|
+
...(await digestExportFile(exportRoot, entry.path)),
|
|
136
|
+
})));
|
|
74
137
|
}
|
|
75
138
|
exports.computeFilesRequests = computeFilesRequests;
|
|
76
139
|
// The server dictates which local files the CLI opens and where their bytes are
|
|
@@ -91,6 +154,7 @@ const uploadRequestJoi = joi_1.default.object({
|
|
|
91
154
|
.required(),
|
|
92
155
|
fileName: joi_1.default.string().required(),
|
|
93
156
|
filePath: joi_1.default.string().required(),
|
|
157
|
+
originalFileName: joi_1.default.string().required(),
|
|
94
158
|
headers: uploadRequestHeadersJoi,
|
|
95
159
|
// Unknown keys are tolerated so a newer server can add fields without
|
|
96
160
|
// breaking older CLIs; nothing reads them.
|
|
@@ -177,40 +241,41 @@ async function resolveUploadRequests({ uploadRequests, exportDir, manifest, }) {
|
|
|
177
241
|
const resolved = [];
|
|
178
242
|
for (const item of uploadRequests) {
|
|
179
243
|
assertSafeUploadUrl(item.requestUploadUrl);
|
|
180
|
-
|
|
181
|
-
|
|
244
|
+
const exportPath = item.originalFileName;
|
|
245
|
+
assertRelativePathShape(exportPath);
|
|
246
|
+
const manifestEntry = manifestByPath.get(exportPath);
|
|
182
247
|
if (!manifestEntry) {
|
|
183
|
-
throw new Error(`Refusing to upload "${
|
|
248
|
+
throw new Error(`Refusing to upload "${exportPath}": the server asked for a file that is not part of this export.`);
|
|
184
249
|
}
|
|
185
|
-
if (item.fileName !== path_1.default.basename(
|
|
186
|
-
throw new Error(`Refusing to upload "${
|
|
250
|
+
if (item.fileName !== path_1.default.basename(exportPath)) {
|
|
251
|
+
throw new Error(`Refusing to upload "${exportPath}": the server returned the mismatched name "${item.fileName}".`);
|
|
187
252
|
}
|
|
188
|
-
if (seen.has(
|
|
189
|
-
throw new Error(`The server requested "${
|
|
253
|
+
if (seen.has(exportPath)) {
|
|
254
|
+
throw new Error(`The server requested "${exportPath}" more than once.`);
|
|
190
255
|
}
|
|
191
|
-
seen.add(
|
|
192
|
-
const absolutePath = path_1.default.resolve(exportRoot,
|
|
256
|
+
seen.add(exportPath);
|
|
257
|
+
const absolutePath = path_1.default.resolve(exportRoot, exportPath);
|
|
193
258
|
// Unreachable on POSIX: a path with no '..' segment and no leading separator
|
|
194
259
|
// cannot resolve out of the root. Kept for the Windows drive-relative case
|
|
195
260
|
// ("C:file" when the export root sits on another drive) and as a backstop if
|
|
196
261
|
// the checks above are ever relaxed.
|
|
197
262
|
if (absolutePath !== exportRoot && !absolutePath.startsWith(exportRoot + path_1.default.sep)) {
|
|
198
|
-
throw new Error(`Refusing to upload "${
|
|
263
|
+
throw new Error(`Refusing to upload "${exportPath}": it resolves outside the export directory.`);
|
|
199
264
|
}
|
|
200
265
|
let realPath;
|
|
201
266
|
try {
|
|
202
267
|
realPath = await fs_extra_1.default.realpath(absolutePath);
|
|
203
268
|
}
|
|
204
269
|
catch {
|
|
205
|
-
throw new Error(`File ${
|
|
270
|
+
throw new Error(`File ${exportPath} not found in the export directory.`);
|
|
206
271
|
}
|
|
207
272
|
// The root is already canonical, so any difference here means a symlink was
|
|
208
273
|
// traversed, either as the file itself or as one of its parent directories.
|
|
209
274
|
if (realPath !== absolutePath) {
|
|
210
|
-
throw new Error(`Refusing to upload "${
|
|
275
|
+
throw new Error(`Refusing to upload "${exportPath}": it is or goes through a symlink.`);
|
|
211
276
|
}
|
|
212
277
|
if (!(await fs_extra_1.default.lstat(absolutePath)).isFile()) {
|
|
213
|
-
throw new Error(`Refusing to upload "${
|
|
278
|
+
throw new Error(`Refusing to upload "${exportPath}": it is not a regular file.`);
|
|
214
279
|
}
|
|
215
280
|
resolved.push({ item, absolutePath, manifestEntry });
|
|
216
281
|
}
|
|
@@ -221,6 +286,15 @@ function activeRolloutConflictMessage(branch) {
|
|
|
221
286
|
return `A progressive rollout is already active for branch "${branch}" on this runtime version. End or revert it from the dashboard before publishing a new update.`;
|
|
222
287
|
}
|
|
223
288
|
exports.activeRolloutConflictMessage = activeRolloutConflictMessage;
|
|
289
|
+
class NoChangesDetectedError extends Error {
|
|
290
|
+
platform;
|
|
291
|
+
constructor(platform) {
|
|
292
|
+
super(`There is no change in the update for ${platform}`);
|
|
293
|
+
this.platform = platform;
|
|
294
|
+
this.name = 'NoChangesDetectedError';
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
exports.NoChangesDetectedError = NoChangesDetectedError;
|
|
224
298
|
async function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion, platform, commitHash, message, rolloutPercentage, publishGroup, branch, }) {
|
|
225
299
|
const uploadUrl = new URL(requestUploadUrl);
|
|
226
300
|
uploadUrl.searchParams.set('runtimeVersion', runtimeVersion);
|
|
@@ -247,6 +321,9 @@ async function requestUploadUrls({ body, requestUploadUrl, auth, runtimeVersion,
|
|
|
247
321
|
if (response.status === 409) {
|
|
248
322
|
throw new Error(activeRolloutConflictMessage(branch));
|
|
249
323
|
}
|
|
324
|
+
if (response.status === 406) {
|
|
325
|
+
throw new NoChangesDetectedError(platform);
|
|
326
|
+
}
|
|
250
327
|
if (!response.ok) {
|
|
251
328
|
const text = await response.text();
|
|
252
329
|
throw new Error(`Failed to request upload URL: ${text}${(0, auth_1.missingEooTokenHint)(response.status)}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
export declare function toBase64Url(buffer: Buffer): string;
|
|
4
|
+
export interface FileDigest {
|
|
5
|
+
hash: string;
|
|
6
|
+
key: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function digestFile(filePath: string): Promise<FileDigest>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.digestFile = exports.toBase64Url = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
7
|
+
// Same encoding as internal/crypto.GetBase64URLEncoding: the string
|
|
8
|
+
// shapeManifestAsset puts on ManifestAsset.hash.
|
|
9
|
+
function toBase64Url(buffer) {
|
|
10
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
11
|
+
}
|
|
12
|
+
exports.toBase64Url = toBase64Url;
|
|
13
|
+
async function digestFile(filePath) {
|
|
14
|
+
const sha256 = (0, crypto_1.createHash)('sha256');
|
|
15
|
+
const md5 = (0, crypto_1.createHash)('md5');
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
fs_1.default.createReadStream(filePath)
|
|
18
|
+
.on('error', reject)
|
|
19
|
+
.on('data', chunk => {
|
|
20
|
+
sha256.update(chunk);
|
|
21
|
+
md5.update(chunk);
|
|
22
|
+
})
|
|
23
|
+
.on('end', resolve);
|
|
24
|
+
});
|
|
25
|
+
return { hash: toBase64Url(sha256.digest()), key: md5.digest('hex') };
|
|
26
|
+
}
|
|
27
|
+
exports.digestFile = digestFile;
|
package/dist/lib/expoConfig.d.ts
CHANGED
|
@@ -30,4 +30,5 @@ export type CommentedValue = {
|
|
|
30
30
|
};
|
|
31
31
|
/** Strips the comment wrappers, for the JSON path and for reading values back. */
|
|
32
32
|
export declare function unwrapValue(value: any): any;
|
|
33
|
+
export declare function publicServerUrl(raw: string): string;
|
|
33
34
|
export declare function resolveServerUrl(config: ExpoConfig, customServerUrl?: string): Promise<string>;
|
package/dist/lib/expoConfig.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resolveServerUrl = exports.unwrapValue = exports.createOrModifyExpoConfigAsync = exports.requireExpoAppId = exports.getExpoAppId = exports.getExpoConfigUpdateUrl = exports.getPublicExpoConfigAsync = exports.isUsingStaticExpoConfig = exports.ensureExpoConfigExists = exports.getPrivateExpoConfigAsync = exports.RequestedPlatform = void 0;
|
|
3
|
+
exports.resolveServerUrl = exports.publicServerUrl = exports.unwrapValue = exports.createOrModifyExpoConfigAsync = exports.requireExpoAppId = exports.getExpoAppId = exports.getExpoConfigUpdateUrl = exports.getPublicExpoConfigAsync = exports.isUsingStaticExpoConfig = exports.ensureExpoConfigExists = exports.getPrivateExpoConfigAsync = exports.RequestedPlatform = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
|
|
6
6
|
const config_1 = require("@expo/config");
|
|
@@ -314,6 +314,17 @@ function stringifyWithEnv(obj) {
|
|
|
314
314
|
: value, 2);
|
|
315
315
|
return json.replace(/"__RAW_EXPR_(\d+)__"/g, (_match, index) => rawExpressions[Number(index)]);
|
|
316
316
|
}
|
|
317
|
+
// Strips a trailing /manifest from an Expo updates.url (or --serverUrl) and
|
|
318
|
+
// keeps any path prefix the server is mounted under.
|
|
319
|
+
function publicServerUrl(raw) {
|
|
320
|
+
const parsed = new URL(raw);
|
|
321
|
+
let path = parsed.pathname.replace(/\/+$/, '');
|
|
322
|
+
if (path.endsWith('/manifest')) {
|
|
323
|
+
path = path.slice(0, -'/manifest'.length);
|
|
324
|
+
}
|
|
325
|
+
return `${parsed.origin}${path}`;
|
|
326
|
+
}
|
|
327
|
+
exports.publicServerUrl = publicServerUrl;
|
|
317
328
|
// customServerUrl wins over the Expo config, so a project serving updates and
|
|
318
329
|
// receiving publishes on two different origins can point the CLI at the second
|
|
319
330
|
// one without touching its config.
|
|
@@ -323,7 +334,7 @@ async function resolveServerUrl(config, customServerUrl) {
|
|
|
323
334
|
throw new Error("Update url is not setup in your config. Please run 'eoas init' to setup the update url, or pass --serverUrl");
|
|
324
335
|
}
|
|
325
336
|
try {
|
|
326
|
-
return
|
|
337
|
+
return publicServerUrl(updateUrl);
|
|
327
338
|
}
|
|
328
339
|
catch {
|
|
329
340
|
throw new Error(customServerUrl ? 'Invalid --serverUrl value.' : 'Invalid update URL.');
|
|
@@ -9,6 +9,8 @@ export type Deployment = 'docker' | 'binary' | 'helm';
|
|
|
9
9
|
export type GeoipStrategy = 'proxy-headers' | 'maxmind';
|
|
10
10
|
export type ServerChoices = {
|
|
11
11
|
baseUrl?: string;
|
|
12
|
+
/** The server routes under BASE_URL's path itself (SERVE_FROM_SUB_PATH). */
|
|
13
|
+
serveFromSubPath?: boolean;
|
|
12
14
|
jwtSecret?: string;
|
|
13
15
|
dbUrl?: string;
|
|
14
16
|
masterKeySource: MasterKeySource;
|
|
@@ -19,7 +19,14 @@ exports.ENV_SECTIONS = [
|
|
|
19
19
|
applies: () => true,
|
|
20
20
|
required: true,
|
|
21
21
|
value: c => or(c.baseUrl, '<https://your-ota-domain>'),
|
|
22
|
-
comment: 'Public HTTPS URL of the server
|
|
22
|
+
comment: 'Public HTTPS URL of the server, including a path prefix if the gateway mounts it under one (e.g. https://api.example.com/ota).',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'SERVE_FROM_SUB_PATH',
|
|
26
|
+
applies: c => c.serveFromSubPath === true,
|
|
27
|
+
required: true,
|
|
28
|
+
value: () => 'true',
|
|
29
|
+
comment: "Serve every route under the BASE_URL path prefix. Remove when the gateway strips the prefix before forwarding (it only affects routing; links always use BASE_URL's path).",
|
|
23
30
|
},
|
|
24
31
|
{
|
|
25
32
|
name: 'JWT_SECRET',
|
|
@@ -282,6 +289,13 @@ exports.ENV_SECTIONS = [
|
|
|
282
289
|
value: c => or(c.adminPassword, '<strong-password>'),
|
|
283
290
|
comment: 'Policy: 8+ characters, an uppercase, a lowercase, a digit and a special character.',
|
|
284
291
|
},
|
|
292
|
+
{
|
|
293
|
+
name: 'DASHBOARD_ROOT_REDIRECT',
|
|
294
|
+
applies: c => c.dashboard !== false,
|
|
295
|
+
required: false,
|
|
296
|
+
value: () => 'true',
|
|
297
|
+
comment: 'Sends / to the dashboard login. Off by default: the root stays a 404 rather than pointing every visitor at the admin UI.',
|
|
298
|
+
},
|
|
285
299
|
],
|
|
286
300
|
},
|
|
287
301
|
{
|
|
@@ -474,6 +488,7 @@ function inferChoicesFromEnv(env) {
|
|
|
474
488
|
const cacheMode = (env.CACHE_MODE ?? 'local');
|
|
475
489
|
return {
|
|
476
490
|
baseUrl: env.BASE_URL,
|
|
491
|
+
serveFromSubPath: env.SERVE_FROM_SUB_PATH === 'true',
|
|
477
492
|
jwtSecret: env.JWT_SECRET,
|
|
478
493
|
dbUrl: env.DB_URL,
|
|
479
494
|
masterKeySource,
|
package/dist/lib/utils.js
CHANGED
|
@@ -6,7 +6,23 @@ const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
|
|
|
6
6
|
const path_1 = tslib_1.__importDefault(require("path"));
|
|
7
7
|
const log_1 = tslib_1.__importDefault(require("./log"));
|
|
8
8
|
function isValidUpdateUrl(updateUrl) {
|
|
9
|
-
|
|
9
|
+
try {
|
|
10
|
+
const parsed = new URL(updateUrl);
|
|
11
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (!parsed.host) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
// A query or fragment would corrupt the `${url}/manifest` concatenation.
|
|
18
|
+
if (parsed.search || parsed.hash) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return !parsed.pathname.replace(/\/+$/, '').endsWith('/manifest');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
10
26
|
}
|
|
11
27
|
exports.isValidUpdateUrl = isValidUpdateUrl;
|
|
12
28
|
// Appends pattern to the project .gitignore unless an identical rule is already
|