eoas 3.0.5 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # EOAS (Expo Open Application Services)
1
+ # EOAS
2
2
 
3
- EOAS ((Expo Open Application Services) is a powerful helper package designed to simplify the setup and update publication process for the [expo-open-ota](https://github.com/mercuretechnologies/expo-open-ota) project.
3
+ EOAS is a powerful helper package designed to simplify the setup and update publication process for the [xprem](https://github.com/mercuretechnologies/xprem) project.
4
4
 
5
5
  ## Quick Start
6
6
 
7
7
  To get started with EOAS, check out the official documentation:
8
- [EOAS Official Documentation](https://mercure-technologies.gitbook.io/expo-open-ota/eoas/overview)
8
+ [EOAS Official Documentation](https://mercure-technologies.gitbook.io/xprem/eoas/overview)
9
9
 
10
10
  ## Learn More
11
- For detailed information and to explore the core functionalities of expo-open-ota, visit the main repository:
12
- [expo-open-ota on GitHub](https://github.com/mercuretechnologies/expo-open-ota)
11
+ For detailed information and to explore the core functionalities of xprem, visit the main repository:
12
+ [xprem on GitHub](https://github.com/mercuretechnologies/xprem)
13
13
 
14
14
  ---
15
15
 
package/bin/dev.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // Silence DEP0040 (punycode) emitted by node-fetch@2 -> whatwg-url@5 on Node 21+.
3
+ process.noDeprecation = true;
4
+
2
5
  // eslint-disable-next-line node/shebang, unicorn/prefer-top-level-await
3
6
  (async () => {
4
7
  const oclif = await import('@oclif/core');
package/bin/run.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Silence DEP0040 (punycode) emitted by node-fetch@2 -> whatwg-url@5 on Node 21+.
4
+ process.noDeprecation = true;
5
+
3
6
  // eslint-disable-next-line unicorn/prefer-top-level-await
4
7
  (async () => {
5
8
  const oclif = await import('@oclif/core')
@@ -8,6 +8,9 @@ const path_1 = tslib_1.__importDefault(require("path"));
8
8
  const log_1 = tslib_1.__importDefault(require("../lib/log"));
9
9
  const prompts_1 = require("../lib/prompts");
10
10
  const utils_1 = require("../lib/utils");
11
+ // Owner read/write only: the private key signs every update served by the OTA
12
+ // server.
13
+ const PRIVATE_KEY_MODE = 0o600;
11
14
  class GenerateCerts extends core_1.Command {
12
15
  static args = {};
13
16
  static description = 'Generate private & public certificates for code signing';
@@ -46,6 +49,20 @@ class GenerateCerts extends core_1.Command {
46
49
  }
47
50
  },
48
51
  });
52
+ const keyOutput = path_1.default.resolve(process.cwd(), keyOutputDir);
53
+ const privateKeyPath = path_1.default.join(keyOutput, 'private-key.pem');
54
+ if ((0, fs_extra_1.existsSync)(privateKeyPath)) {
55
+ const overwrite = await (0, prompts_1.confirmAsync)({
56
+ message: `${privateKeyPath} already exists. Overwrite it? Updates signed with the current key will no longer be accepted by apps embedding the matching certificate.`,
57
+ name: 'overwritePrivateKey',
58
+ type: 'confirm',
59
+ initial: false,
60
+ });
61
+ if (!overwrite) {
62
+ log_1.default.warn('Aborted: no certificate or key was written.');
63
+ return;
64
+ }
65
+ }
49
66
  const { certificateCommonName } = await (0, prompts_1.promptAsync)({
50
67
  message: 'Please enter your Organization name',
51
68
  name: 'certificateCommonName',
@@ -66,7 +83,6 @@ class GenerateCerts extends core_1.Command {
66
83
  });
67
84
  const validityDurationYears = Math.floor(Number(certificateValidityDurationYears));
68
85
  const certificateOutput = path_1.default.resolve(process.cwd(), certificateOutputDir);
69
- const keyOutput = path_1.default.resolve(process.cwd(), keyOutputDir);
70
86
  const validityNotBefore = new Date();
71
87
  const validityNotAfter = new Date();
72
88
  validityNotAfter.setFullYear(validityNotAfter.getFullYear() + validityDurationYears);
@@ -82,9 +98,13 @@ class GenerateCerts extends core_1.Command {
82
98
  // Before the key touches the disk, so there is no window where it exists
83
99
  // uncovered by the ignore rule.
84
100
  (0, utils_1.ensurePrivateKeyIgnored)(process.cwd());
101
+ // Removed first: writeFile only applies the mode when it creates the file,
102
+ // so overwriting an existing key would keep its (possibly world readable)
103
+ // permissions.
104
+ await (0, fs_extra_1.remove)(privateKeyPath);
85
105
  await Promise.all([
86
106
  (0, fs_extra_1.writeFile)(path_1.default.join(keyOutput, 'public-key.pem'), keyPairPEM.publicKeyPEM),
87
- (0, fs_extra_1.writeFile)(path_1.default.join(keyOutput, 'private-key.pem'), keyPairPEM.privateKeyPEM),
107
+ (0, fs_extra_1.writeFile)(privateKeyPath, keyPairPEM.privateKeyPEM, { mode: PRIVATE_KEY_MODE }),
88
108
  (0, fs_extra_1.writeFile)(path_1.default.join(certificateOutput, 'certificate.pem'), certificatePEM),
89
109
  ]);
90
110
  log_1.default.succeed(`Generated public and private keys output in ${keyOutputDir}. Please follow the documentation to securely store them and do not commit them to your repository.`);
@@ -12,7 +12,7 @@ const prompts_1 = require("../lib/prompts");
12
12
  const utils_1 = require("../lib/utils");
13
13
  class Init extends core_1.Command {
14
14
  static args = {};
15
- static description = 'Configure your existing expo project with Expo Open OTA';
15
+ static description = 'Configure your existing expo project with xprem';
16
16
  static examples = ['<%= config.bin %> <%= command.id %>'];
17
17
  static flags = {};
18
18
  async run() {
@@ -31,7 +31,7 @@ class Init extends core_1.Command {
31
31
  ?.projectId;
32
32
  const { appId } = await (0, prompts_1.promptAsync)({
33
33
  message: 'Enter the project id for this project (sent as the expo-app-id header).\n' +
34
- ' See https://mercure-technologies.gitbook.io/expo-open-ota/stateless-mode/getting-started for details.',
34
+ ' See https://mercure-technologies.gitbook.io/xprem/stateless-mode/getting-started for details.',
35
35
  name: 'appId',
36
36
  type: 'text',
37
37
  initial: detectedAppId,
@@ -4,6 +4,7 @@ const tslib_1 = require("tslib");
4
4
  const eas_build_job_1 = require("@expo/eas-build-job");
5
5
  const spawn_async_1 = tslib_1.__importDefault(require("@expo/spawn-async"));
6
6
  const core_1 = require("@oclif/core");
7
+ const crypto_1 = require("crypto");
7
8
  const form_data_1 = tslib_1.__importDefault(require("form-data"));
8
9
  const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
9
10
  const mime_1 = tslib_1.__importDefault(require("mime"));
@@ -243,6 +244,9 @@ class Publish extends core_1.Command {
243
244
  process.exit(1);
244
245
  }
245
246
  let uploadUrls = [];
247
+ // One group id for the whole run: every platform update of this publish
248
+ // shares it, so control plane servers list them as a single publish.
249
+ const publishGroupId = (0, crypto_1.randomUUID)();
246
250
  try {
247
251
  uploadUrls = await Promise.all(runtimeVersions.map(async ({ runtimeVersion, platform }) => {
248
252
  if (!runtimeVersion) {
@@ -260,50 +264,56 @@ class Publish extends core_1.Command {
260
264
  commitHash,
261
265
  message: resolvedMessage,
262
266
  rolloutPercentage,
267
+ publishGroup: publishGroupId,
263
268
  branch,
264
269
  })),
265
270
  runtimeVersion,
266
271
  platform,
267
272
  };
268
273
  }));
269
- const allItems = uploadUrls.flatMap(({ uploadRequests }) => uploadRequests);
270
- await Promise.all(allItems.map(async (itm) => {
274
+ // Every path and URL the server handed back is checked here, before a
275
+ // single file is opened. A server that forges a filePath would otherwise
276
+ // make the CLI read arbitrary files off this machine and PUT them wherever
277
+ // it likes. Validated per response: each platform gets its own upload URLs
278
+ // for the same files.
279
+ const resolvedUploads = (await Promise.all(uploadUrls.map(({ uploadRequests }) => (0, assets_1.resolveUploadRequests)({
280
+ uploadRequests,
281
+ exportDir: path_1.default.join(projectDir, outputDir),
282
+ manifest: files,
283
+ })))).flat();
284
+ await Promise.all(resolvedUploads.map(async ({ item: itm, absolutePath, manifestEntry }) => {
271
285
  const isLocalBucketFileUpload = itm.requestUploadUrl.startsWith(`${serverUrl}/${appId}/uploadLocalFile`);
272
- const formData = new form_data_1.default();
273
- let file;
274
- try {
275
- file = fs_extra_1.default.createReadStream(path_1.default.join(projectDir, outputDir, itm.filePath));
276
- }
277
- catch {
278
- throw new Error(`Failed to read file ${itm.filePath}`);
279
- }
280
- formData.append(itm.fileName, file);
281
286
  if (isLocalBucketFileUpload) {
282
- const response = await (0, fetch_1.fetchWithRetries)(itm.requestUploadUrl, {
283
- method: 'PUT',
284
- headers: {
285
- ...formData.getHeaders(),
286
- ...(0, auth_1.getAuthHeaders)(credentials),
287
- },
288
- body: formData,
289
- });
290
- if (!response.ok) {
291
- log_1.default.error('Failed to upload file', await response.text());
292
- throw new Error('Failed to upload file');
287
+ const formData = new form_data_1.default();
288
+ const file = fs_extra_1.default.createReadStream(absolutePath);
289
+ formData.append(itm.fileName, file);
290
+ try {
291
+ const response = await (0, fetch_1.fetchWithRetries)(itm.requestUploadUrl, {
292
+ method: 'PUT',
293
+ headers: {
294
+ ...formData.getHeaders(),
295
+ ...(0, auth_1.getAuthHeaders)(credentials),
296
+ },
297
+ body: formData,
298
+ // The URL was validated as a string; following a redirect would
299
+ // send these bytes to an origin nothing ever checked.
300
+ redirect: 'error',
301
+ });
302
+ if (!response.ok) {
303
+ log_1.default.error('Failed to upload file', await response.text());
304
+ throw new Error('Failed to upload file');
305
+ }
306
+ }
307
+ finally {
308
+ file.close();
293
309
  }
294
- file.close();
295
310
  return;
296
311
  }
297
- const findFile = files.find(f => f.path === itm.filePath || f.name === itm.fileName);
298
- if (!findFile) {
299
- log_1.default.error(`File ${itm.filePath} not found`);
300
- throw new Error(`File ${itm.filePath} not found`);
301
- }
302
- let contentType = mime_1.default.getType(findFile.ext);
312
+ let contentType = mime_1.default.getType(manifestEntry.ext);
303
313
  if (!contentType) {
304
314
  contentType = 'application/octet-stream';
305
315
  }
306
- const buffer = await fs_extra_1.default.readFile(path_1.default.join(projectDir, outputDir, itm.filePath));
316
+ const buffer = await fs_extra_1.default.readFile(absolutePath);
307
317
  const response = await (0, fetch_1.fetchWithRetries)(itm.requestUploadUrl, {
308
318
  method: 'PUT',
309
319
  headers: {
@@ -312,12 +322,17 @@ class Publish extends core_1.Command {
312
322
  ...(itm.headers ?? {}),
313
323
  },
314
324
  body: buffer,
325
+ // Only the URL string was validated. node-fetch follows up to 20
326
+ // redirects by default with no protocol or host check, so a server
327
+ // handing back a valid https URL that 302s to http://internal-host
328
+ // would exfiltrate the bundle in cleartext to an origin of its
329
+ // choosing. Nothing legitimate redirects an upload PUT.
330
+ redirect: 'error',
315
331
  });
316
332
  if (!response.ok) {
317
333
  log_1.default.error('❌ File upload failed', await response.text());
318
334
  process.exit(1);
319
335
  }
320
- file.close();
321
336
  }));
322
337
  uploadFilesSpinner.succeed('✅ Files uploaded successfully');
323
338
  }
@@ -385,6 +400,15 @@ class Publish extends core_1.Command {
385
400
  if (hasSuccess) {
386
401
  log_1.default.withInfo(`🌿 Branch: \`${branch}\``);
387
402
  log_1.default.withInfo(`⏳ Deployed at: \`${new Date().toUTCString()}\`\n`);
403
+ const groupAcknowledged = uploadUrls.every(u => u.publishGroup === publishGroupId);
404
+ if (groupAcknowledged) {
405
+ log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}\``);
406
+ }
407
+ else if (uploadUrls.length > 1) {
408
+ // Only worth a note when several platforms were published: a single
409
+ // update has nothing to group anyway.
410
+ log_1.default.withInfo('ℹ️ Platform updates were published without grouping (publish groups require a server in control plane mode).');
411
+ }
388
412
  log_1.default.withInfo('🔥 Your users will receive the latest update automatically!');
389
413
  }
390
414
  }
@@ -2,13 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const tslib_1 = require("tslib");
4
4
  const core_1 = require("@oclif/core");
5
- const ora_1 = tslib_1.__importDefault(require("ora"));
6
5
  const auth_1 = require("../lib/auth");
7
6
  const expoConfig_1 = require("../lib/expoConfig");
8
7
  const fetch_1 = require("../lib/fetch");
9
8
  const log_1 = tslib_1.__importDefault(require("../lib/log"));
9
+ const ora_1 = require("../lib/ora");
10
10
  const package_1 = require("../lib/package");
11
11
  const prompts_1 = require("../lib/prompts");
12
+ const serverUpdates_1 = require("../lib/serverUpdates");
12
13
  const vcs_1 = require("../lib/vcs");
13
14
  class Publish extends core_1.Command {
14
15
  static args = {};
@@ -50,7 +51,6 @@ class Publish extends core_1.Command {
50
51
  }
51
52
  const vcsClient = (0, vcs_1.resolveVcsClient)(true);
52
53
  await vcsClient.ensureRepoExistsAsync();
53
- // const commitHash = await vcsClient.getCommitHashAsync();
54
54
  const projectDir = process.cwd();
55
55
  const hasExpo = (0, package_1.isExpoInstalled)(projectDir);
56
56
  if (!hasExpo) {
@@ -75,24 +75,19 @@ class Publish extends core_1.Command {
75
75
  log_1.default.error('Invalid URL', e);
76
76
  process.exit(1);
77
77
  }
78
- const runtimeVersionsEndpoint = `${baseUrl}/api/apps/${appId}/branch/${branch}/runtimeVersions`;
79
- const response = await (0, fetch_1.fetchWithRetries)(runtimeVersionsEndpoint, {
80
- headers: {
81
- ...(0, auth_1.getAuthHeaders)(credentials),
82
- 'use-cli-auth': 'true',
83
- },
84
- });
85
- if (!response.ok) {
86
- log_1.default.error(`Failed to fetch runtime versions: ${await response.text()}`);
78
+ let runtimeVersions;
79
+ try {
80
+ runtimeVersions = await (0, serverUpdates_1.fetchRuntimeVersions)({ baseUrl, appId, branch, credentials });
81
+ }
82
+ catch (e) {
83
+ log_1.default.error(e instanceof Error ? e.message : e);
87
84
  process.exit(1);
88
85
  }
89
- const runtimeVersions = (await response.json());
90
86
  const filteredRuntimeVersions = runtimeVersions.filter(runtimeVersion => runtimeVersion.numberOfUpdates > 1);
91
87
  if (filteredRuntimeVersions.length === 0) {
92
88
  log_1.default.error('No runtime versions found');
93
89
  process.exit(1);
94
90
  }
95
- // Ask the user to select a runtime version
96
91
  const selectedRuntimeVersion = await (0, prompts_1.promptAsync)({
97
92
  type: 'select',
98
93
  name: 'runtimeVersion',
@@ -103,21 +98,86 @@ class Publish extends core_1.Command {
103
98
  })),
104
99
  });
105
100
  log_1.default.log(`Selected runtime version: ${selectedRuntimeVersion.runtimeVersion}`);
106
- const updatesEndpoint = `${baseUrl}/api/apps/${appId}/branch/${branch}/runtimeVersion/${selectedRuntimeVersion.runtimeVersion}/updates`;
107
- const updatesResponse = await (0, fetch_1.fetchWithRetries)(updatesEndpoint, {
108
- headers: {
109
- ...(0, auth_1.getAuthHeaders)(credentials),
110
- 'use-cli-auth': 'true',
111
- },
112
- });
113
- if (!updatesResponse.ok) {
114
- log_1.default.error(`Failed to fetch updates: ${await updatesResponse.text()}`);
101
+ let allUpdates;
102
+ try {
103
+ allUpdates = await (0, serverUpdates_1.fetchUpdates)({
104
+ baseUrl,
105
+ appId,
106
+ branch,
107
+ runtimeVersion: selectedRuntimeVersion.runtimeVersion,
108
+ credentials,
109
+ });
110
+ }
111
+ catch (e) {
112
+ log_1.default.error(e instanceof Error ? e.message : e);
115
113
  process.exit(1);
116
114
  }
117
- const updates = (await updatesResponse.json()).filter(u => {
118
- return (u.updateUUID !== 'Rollback to embedded' && (platform === 'all' || u.platform === platform));
119
- });
115
+ // Rollback markers have no files to republish.
116
+ const updates = allUpdates.filter(u => u.updateUUID !== 'Rollback to embedded');
120
117
  if (updates.length === 0) {
118
+ log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion}.`);
119
+ process.exit(1);
120
+ }
121
+ const { groups } = (0, serverUpdates_1.groupPublishedUpdates)(updates);
122
+ // Offer the group mode only when there is something to group and the user
123
+ // did not already narrow the run to one platform.
124
+ let mode = 'single';
125
+ if (platform === 'all' && groups.length > 0) {
126
+ const selectedMode = await (0, prompts_1.promptAsync)({
127
+ type: 'select',
128
+ name: 'mode',
129
+ message: 'What do you want to republish?',
130
+ choices: [
131
+ {
132
+ title: 'A full publish (all its platforms together)',
133
+ description: 'Only for servers in control plane mode',
134
+ value: 'group',
135
+ },
136
+ {
137
+ title: 'A single platform update',
138
+ description: 'Pick one iOS or Android update',
139
+ value: 'single',
140
+ },
141
+ ],
142
+ });
143
+ mode = selectedMode.mode;
144
+ }
145
+ if (mode === 'group') {
146
+ const selectedGroup = await (0, prompts_1.promptAsync)({
147
+ type: 'select',
148
+ name: 'group',
149
+ message: 'Select a publish to republish',
150
+ choices: groups.map(group => ({
151
+ ...(0, serverUpdates_1.describePublishGroup)(group),
152
+ value: group,
153
+ })),
154
+ });
155
+ const group = selectedGroup.group;
156
+ const republishUrl = new URL(`${baseUrl}/${appId}/republish/${branch}`);
157
+ republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
158
+ republishUrl.searchParams.set('publishGroup', group.publishGroup);
159
+ const republishSpinner = (0, ora_1.ora)(`🔄 Republishing ${group.platforms.join(' + ')} updates...`).start();
160
+ const response = await (0, fetch_1.fetchWithRetries)(republishUrl.toString(), {
161
+ method: 'POST',
162
+ headers: {
163
+ ...(0, auth_1.getAuthHeaders)(credentials),
164
+ 'use-cli-auth': 'true',
165
+ 'Content-Type': 'application/json',
166
+ },
167
+ });
168
+ if (!response.ok) {
169
+ republishSpinner.fail('❌ Republish failed');
170
+ log_1.default.error(`Failed to republish publish group: ${await response.text()}`);
171
+ process.exit(1);
172
+ }
173
+ const result = (await response.json());
174
+ republishSpinner.succeed(result.publishGroup
175
+ ? `✅ Republished ${group.platforms.join(' + ')} as publish group ${result.publishGroup}`
176
+ : `✅ Republished ${group.platforms.join(' + ')}`);
177
+ return;
178
+ }
179
+ const platformUpdates = updates.filter(u => platform === 'all' || u.platform === platform);
180
+ if (platformUpdates.length === 0) {
121
181
  log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion} on platform ${platform}.`);
122
182
  process.exit(1);
123
183
  }
@@ -125,7 +185,7 @@ class Publish extends core_1.Command {
125
185
  type: 'select',
126
186
  name: 'update',
127
187
  message: 'Select an update to republish',
128
- choices: updates.map(update => ({
188
+ choices: platformUpdates.map(update => ({
129
189
  title: update.updateUUID,
130
190
  value: update,
131
191
  description: `Created at: ${update.createdAt}, Platform: ${update.platform}, Commit hash: ${update.commitHash}`,
@@ -137,7 +197,7 @@ class Publish extends core_1.Command {
137
197
  republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
138
198
  republishUrl.searchParams.set('updateId', selectedUpdated.update.updateId);
139
199
  republishUrl.searchParams.set('commitHash', selectedUpdated.update.commitHash);
140
- const republishSpinner = (0, ora_1.default)('🔄 Republishing update...').start();
200
+ const republishSpinner = (0, ora_1.ora)('🔄 Republishing update...').start();
141
201
  const republishResponse = await (0, fetch_1.fetchWithRetries)(republishUrl.toString(), {
142
202
  method: 'POST',
143
203
  headers: {
@@ -0,0 +1,17 @@
1
+ import { Command } from '@oclif/core';
2
+ import { type Deployment } from '../../lib/serverConfig/choices';
3
+ /** A master key found in the file the wizard is about to replace. `unreadable`
4
+ * distinguishes "the file has no key" from "the file could not be parsed". */
5
+ type ExistingMasterKey = {
6
+ key?: string;
7
+ unreadable: boolean;
8
+ };
9
+ export declare function readExistingMasterKey(deployment: Deployment): Promise<ExistingMasterKey>;
10
+ export default class ServerInit extends Command {
11
+ static args: {};
12
+ static description: string;
13
+ static examples: string[];
14
+ static flags: {};
15
+ run(): Promise<void>;
16
+ }
17
+ export {};