eoas 3.0.5 → 3.1.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.
Files changed (40) hide show
  1. package/README.md +5 -5
  2. package/bin/dev.js +3 -0
  3. package/bin/run.js +3 -0
  4. package/dist/commands/generate-certs.js +22 -2
  5. package/dist/commands/init.js +2 -2
  6. package/dist/commands/publish.d.ts +1 -0
  7. package/dist/commands/publish.js +65 -34
  8. package/dist/commands/republish.d.ts +1 -0
  9. package/dist/commands/republish.js +93 -38
  10. package/dist/commands/rollback.d.ts +1 -0
  11. package/dist/commands/rollback.js +9 -14
  12. package/dist/commands/server/init.d.ts +17 -0
  13. package/dist/commands/server/init.js +654 -0
  14. package/dist/commands/server/validate.d.ts +10 -0
  15. package/dist/commands/server/validate.js +115 -0
  16. package/dist/lib/assets.d.ts +30 -8
  17. package/dist/lib/assets.js +158 -4
  18. package/dist/lib/auth.js +2 -1
  19. package/dist/lib/expoConfig.d.ts +1 -1
  20. package/dist/lib/expoConfig.js +8 -8
  21. package/dist/lib/log.d.ts +8 -2
  22. package/dist/lib/log.js +47 -31
  23. package/dist/lib/ora.d.ts +10 -9
  24. package/dist/lib/ora.js +49 -88
  25. package/dist/lib/packageRunner.js +2 -1
  26. package/dist/lib/prompts.d.ts +32 -0
  27. package/dist/lib/prompts.js +86 -1
  28. package/dist/lib/serverConfig/choices.d.ts +62 -0
  29. package/dist/lib/serverConfig/choices.js +65 -0
  30. package/dist/lib/serverConfig/envCatalog.d.ts +41 -0
  31. package/dist/lib/serverConfig/envCatalog.js +582 -0
  32. package/dist/lib/serverConfig/helmValues.d.ts +22 -0
  33. package/dist/lib/serverConfig/helmValues.js +281 -0
  34. package/dist/lib/serverConfig/passwordPolicy.d.ts +3 -0
  35. package/dist/lib/serverConfig/passwordPolicy.js +36 -0
  36. package/dist/lib/serverUpdates.d.ts +45 -0
  37. package/dist/lib/serverUpdates.js +108 -0
  38. package/dist/lib/utils.d.ts +1 -0
  39. package/dist/lib/utils.js +18 -14
  40. package/package.json +8 -6
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,
@@ -8,6 +8,7 @@ export default class Publish extends Command {
8
8
  channel: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
9
9
  disableRepositoryCheck: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
10
  branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
11
+ serverUrl: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
11
12
  nonInteractive: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
12
13
  outputDir: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
13
14
  packageRunner: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
@@ -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"));
@@ -48,6 +49,10 @@ class Publish extends core_1.Command {
48
49
  description: 'Name of the branch to point to',
49
50
  required: true,
50
51
  }),
52
+ serverUrl: core_1.Flags.string({
53
+ description: 'URL of the self-hosted update server to publish to. Defaults to the origin of updates.url from your Expo config',
54
+ required: false,
55
+ }),
51
56
  nonInteractive: core_1.Flags.boolean({
52
57
  description: 'Run command in non-interactive mode',
53
58
  default: false,
@@ -80,6 +85,7 @@ class Publish extends core_1.Command {
80
85
  disableRepositoryCheck: flags.disableRepositoryCheck,
81
86
  platform: flags.platform,
82
87
  branch: flags.branch,
88
+ customServerUrl: flags.serverUrl,
83
89
  nonInteractive: flags.nonInteractive,
84
90
  outputDir: flags.outputDir,
85
91
  packageRunner: (0, packageRunner_1.resolvePackageRunner)(flags.packageRunner, process.cwd()),
@@ -96,7 +102,7 @@ class Publish extends core_1.Command {
96
102
  process.exit(1);
97
103
  }
98
104
  const { flags } = await this.parse(Publish);
99
- const { platform, nonInteractive, branch, outputDir, packageRunner, providedDeprecatedChannel, disableRepositoryCheck, message, dumpSourcemap, rolloutPercentage, } = this.sanitizeFlags(flags);
105
+ const { platform, nonInteractive, branch, customServerUrl, outputDir, packageRunner, providedDeprecatedChannel, disableRepositoryCheck, message, dumpSourcemap, rolloutPercentage, } = this.sanitizeFlags(flags);
100
106
  if (!branch) {
101
107
  log_1.default.error('Branch name is required');
102
108
  process.exit(1);
@@ -118,12 +124,14 @@ class Publish extends core_1.Command {
118
124
  },
119
125
  packageRunner,
120
126
  });
121
- const serverUrl = await (0, expoConfig_1.resolveServerUrl)(config).catch(e => {
127
+ const serverUrl = await (0, expoConfig_1.resolveServerUrl)(config, customServerUrl).catch(e => {
122
128
  log_1.default.error(e.message);
123
129
  process.exit(1);
124
130
  });
125
131
  const appId = (0, expoConfig_1.requireExpoAppId)(config);
126
- if (!nonInteractive) {
132
+ // A URL passed on the command line needs no confirmation, and `eoas init`
133
+ // would not fix it anyway.
134
+ if (!nonInteractive && !customServerUrl) {
127
135
  const confirmed = await (0, prompts_1.confirmAsync)({
128
136
  message: `Is this the correct URL of your self-hosted update server? ${serverUrl}`,
129
137
  name: 'export',
@@ -243,6 +251,9 @@ class Publish extends core_1.Command {
243
251
  process.exit(1);
244
252
  }
245
253
  let uploadUrls = [];
254
+ // One group id for the whole run: every platform update of this publish
255
+ // shares it, so control plane servers list them as a single publish.
256
+ const publishGroupId = (0, crypto_1.randomUUID)();
246
257
  try {
247
258
  uploadUrls = await Promise.all(runtimeVersions.map(async ({ runtimeVersion, platform }) => {
248
259
  if (!runtimeVersion) {
@@ -260,50 +271,56 @@ class Publish extends core_1.Command {
260
271
  commitHash,
261
272
  message: resolvedMessage,
262
273
  rolloutPercentage,
274
+ publishGroup: publishGroupId,
263
275
  branch,
264
276
  })),
265
277
  runtimeVersion,
266
278
  platform,
267
279
  };
268
280
  }));
269
- const allItems = uploadUrls.flatMap(({ uploadRequests }) => uploadRequests);
270
- await Promise.all(allItems.map(async (itm) => {
281
+ // Every path and URL the server handed back is checked here, before a
282
+ // single file is opened. A server that forges a filePath would otherwise
283
+ // make the CLI read arbitrary files off this machine and PUT them wherever
284
+ // it likes. Validated per response: each platform gets its own upload URLs
285
+ // for the same files.
286
+ const resolvedUploads = (await Promise.all(uploadUrls.map(({ uploadRequests }) => (0, assets_1.resolveUploadRequests)({
287
+ uploadRequests,
288
+ exportDir: path_1.default.join(projectDir, outputDir),
289
+ manifest: files,
290
+ })))).flat();
291
+ await Promise.all(resolvedUploads.map(async ({ item: itm, absolutePath, manifestEntry }) => {
271
292
  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
293
  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');
294
+ const formData = new form_data_1.default();
295
+ const file = fs_extra_1.default.createReadStream(absolutePath);
296
+ formData.append(itm.fileName, file);
297
+ try {
298
+ const response = await (0, fetch_1.fetchWithRetries)(itm.requestUploadUrl, {
299
+ method: 'PUT',
300
+ headers: {
301
+ ...formData.getHeaders(),
302
+ ...(0, auth_1.getAuthHeaders)(credentials),
303
+ },
304
+ body: formData,
305
+ // The URL was validated as a string; following a redirect would
306
+ // send these bytes to an origin nothing ever checked.
307
+ redirect: 'error',
308
+ });
309
+ if (!response.ok) {
310
+ log_1.default.error('Failed to upload file', await response.text());
311
+ throw new Error('Failed to upload file');
312
+ }
313
+ }
314
+ finally {
315
+ file.close();
293
316
  }
294
- file.close();
295
317
  return;
296
318
  }
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);
319
+ let contentType = mime_1.default.getType(manifestEntry.ext);
303
320
  if (!contentType) {
304
321
  contentType = 'application/octet-stream';
305
322
  }
306
- const buffer = await fs_extra_1.default.readFile(path_1.default.join(projectDir, outputDir, itm.filePath));
323
+ const buffer = await fs_extra_1.default.readFile(absolutePath);
307
324
  const response = await (0, fetch_1.fetchWithRetries)(itm.requestUploadUrl, {
308
325
  method: 'PUT',
309
326
  headers: {
@@ -312,12 +329,17 @@ class Publish extends core_1.Command {
312
329
  ...(itm.headers ?? {}),
313
330
  },
314
331
  body: buffer,
332
+ // Only the URL string was validated. node-fetch follows up to 20
333
+ // redirects by default with no protocol or host check, so a server
334
+ // handing back a valid https URL that 302s to http://internal-host
335
+ // would exfiltrate the bundle in cleartext to an origin of its
336
+ // choosing. Nothing legitimate redirects an upload PUT.
337
+ redirect: 'error',
315
338
  });
316
339
  if (!response.ok) {
317
340
  log_1.default.error('❌ File upload failed', await response.text());
318
341
  process.exit(1);
319
342
  }
320
- file.close();
321
343
  }));
322
344
  uploadFilesSpinner.succeed('✅ Files uploaded successfully');
323
345
  }
@@ -385,6 +407,15 @@ class Publish extends core_1.Command {
385
407
  if (hasSuccess) {
386
408
  log_1.default.withInfo(`🌿 Branch: \`${branch}\``);
387
409
  log_1.default.withInfo(`⏳ Deployed at: \`${new Date().toUTCString()}\`\n`);
410
+ const groupAcknowledged = uploadUrls.every(u => u.publishGroup === publishGroupId);
411
+ if (groupAcknowledged) {
412
+ log_1.default.withInfo(`📦 Publish group: \`${publishGroupId}\``);
413
+ }
414
+ else if (uploadUrls.length > 1) {
415
+ // Only worth a note when several platforms were published: a single
416
+ // update has nothing to group anyway.
417
+ log_1.default.withInfo('ℹ️ Platform updates were published without grouping (publish groups require a server in control plane mode).');
418
+ }
388
419
  log_1.default.withInfo('🔥 Your users will receive the latest update automatically!');
389
420
  }
390
421
  }
@@ -6,6 +6,7 @@ export default class Publish extends Command {
6
6
  static flags: {
7
7
  branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
8
8
  platform: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
9
+ serverUrl: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
9
10
  };
10
11
  private sanitizeFlags;
11
12
  run(): Promise<void>;
@@ -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 = {};
@@ -25,11 +26,16 @@ class Publish extends core_1.Command {
25
26
  default: 'all',
26
27
  required: true,
27
28
  }),
29
+ serverUrl: core_1.Flags.string({
30
+ description: 'URL of the self-hosted update server to republish on. Defaults to the origin of updates.url from your Expo config',
31
+ required: false,
32
+ }),
28
33
  };
29
34
  sanitizeFlags(flags) {
30
35
  return {
31
36
  branch: flags.branch,
32
37
  platform: flags.platform,
38
+ customServerUrl: flags.serverUrl,
33
39
  };
34
40
  }
35
41
  async run() {
@@ -39,7 +45,7 @@ class Publish extends core_1.Command {
39
45
  process.exit(1);
40
46
  }
41
47
  const { flags } = await this.parse(Publish);
42
- const { branch, platform } = this.sanitizeFlags(flags);
48
+ const { branch, platform, customServerUrl } = this.sanitizeFlags(flags);
43
49
  if (!branch) {
44
50
  log_1.default.error('Branch name is required');
45
51
  process.exit(1);
@@ -50,7 +56,6 @@ class Publish extends core_1.Command {
50
56
  }
51
57
  const vcsClient = (0, vcs_1.resolveVcsClient)(true);
52
58
  await vcsClient.ensureRepoExistsAsync();
53
- // const commitHash = await vcsClient.getCommitHashAsync();
54
59
  const projectDir = process.cwd();
55
60
  const hasExpo = (0, package_1.isExpoInstalled)(projectDir);
56
61
  if (!hasExpo) {
@@ -60,39 +65,24 @@ class Publish extends core_1.Command {
60
65
  const privateConfig = await (0, expoConfig_1.getPrivateExpoConfigAsync)(projectDir, {
61
66
  env: process.env,
62
67
  });
63
- const updateUrl = (0, expoConfig_1.getExpoConfigUpdateUrl)(privateConfig);
64
- if (!updateUrl) {
65
- log_1.default.error("Update url is not setup in your config. Please run 'eoas init' to setup the update url");
68
+ const baseUrl = await (0, expoConfig_1.resolveServerUrl)(privateConfig, customServerUrl).catch(e => {
69
+ log_1.default.error(e.message);
66
70
  process.exit(1);
67
- }
71
+ });
68
72
  const appId = (0, expoConfig_1.requireExpoAppId)(privateConfig);
69
- let baseUrl;
73
+ let runtimeVersions;
70
74
  try {
71
- const parsedUrl = new URL(updateUrl);
72
- baseUrl = parsedUrl.origin;
75
+ runtimeVersions = await (0, serverUpdates_1.fetchRuntimeVersions)({ baseUrl, appId, branch, credentials });
73
76
  }
74
77
  catch (e) {
75
- log_1.default.error('Invalid URL', e);
76
- process.exit(1);
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
+ log_1.default.error(e instanceof Error ? e.message : e);
87
79
  process.exit(1);
88
80
  }
89
- const runtimeVersions = (await response.json());
90
81
  const filteredRuntimeVersions = runtimeVersions.filter(runtimeVersion => runtimeVersion.numberOfUpdates > 1);
91
82
  if (filteredRuntimeVersions.length === 0) {
92
83
  log_1.default.error('No runtime versions found');
93
84
  process.exit(1);
94
85
  }
95
- // Ask the user to select a runtime version
96
86
  const selectedRuntimeVersion = await (0, prompts_1.promptAsync)({
97
87
  type: 'select',
98
88
  name: 'runtimeVersion',
@@ -103,21 +93,86 @@ class Publish extends core_1.Command {
103
93
  })),
104
94
  });
105
95
  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()}`);
96
+ let allUpdates;
97
+ try {
98
+ allUpdates = await (0, serverUpdates_1.fetchUpdates)({
99
+ baseUrl,
100
+ appId,
101
+ branch,
102
+ runtimeVersion: selectedRuntimeVersion.runtimeVersion,
103
+ credentials,
104
+ });
105
+ }
106
+ catch (e) {
107
+ log_1.default.error(e instanceof Error ? e.message : e);
115
108
  process.exit(1);
116
109
  }
117
- const updates = (await updatesResponse.json()).filter(u => {
118
- return (u.updateUUID !== 'Rollback to embedded' && (platform === 'all' || u.platform === platform));
119
- });
110
+ // Rollback markers have no files to republish.
111
+ const updates = allUpdates.filter(u => u.updateUUID !== 'Rollback to embedded');
120
112
  if (updates.length === 0) {
113
+ log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion}.`);
114
+ process.exit(1);
115
+ }
116
+ const { groups } = (0, serverUpdates_1.groupPublishedUpdates)(updates);
117
+ // Offer the group mode only when there is something to group and the user
118
+ // did not already narrow the run to one platform.
119
+ let mode = 'single';
120
+ if (platform === 'all' && groups.length > 0) {
121
+ const selectedMode = await (0, prompts_1.promptAsync)({
122
+ type: 'select',
123
+ name: 'mode',
124
+ message: 'What do you want to republish?',
125
+ choices: [
126
+ {
127
+ title: 'A full publish (all its platforms together)',
128
+ description: 'Only for servers in control plane mode',
129
+ value: 'group',
130
+ },
131
+ {
132
+ title: 'A single platform update',
133
+ description: 'Pick one iOS or Android update',
134
+ value: 'single',
135
+ },
136
+ ],
137
+ });
138
+ mode = selectedMode.mode;
139
+ }
140
+ if (mode === 'group') {
141
+ const selectedGroup = await (0, prompts_1.promptAsync)({
142
+ type: 'select',
143
+ name: 'group',
144
+ message: 'Select a publish to republish',
145
+ choices: groups.map(group => ({
146
+ ...(0, serverUpdates_1.describePublishGroup)(group),
147
+ value: group,
148
+ })),
149
+ });
150
+ const group = selectedGroup.group;
151
+ const republishUrl = new URL(`${baseUrl}/${appId}/republish/${branch}`);
152
+ republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
153
+ republishUrl.searchParams.set('publishGroup', group.publishGroup);
154
+ const republishSpinner = (0, ora_1.ora)(`🔄 Republishing ${group.platforms.join(' + ')} updates...`).start();
155
+ const response = await (0, fetch_1.fetchWithRetries)(republishUrl.toString(), {
156
+ method: 'POST',
157
+ headers: {
158
+ ...(0, auth_1.getAuthHeaders)(credentials),
159
+ 'use-cli-auth': 'true',
160
+ 'Content-Type': 'application/json',
161
+ },
162
+ });
163
+ if (!response.ok) {
164
+ republishSpinner.fail('❌ Republish failed');
165
+ log_1.default.error(`Failed to republish publish group: ${await response.text()}`);
166
+ process.exit(1);
167
+ }
168
+ const result = (await response.json());
169
+ republishSpinner.succeed(result.publishGroup
170
+ ? `✅ Republished ${group.platforms.join(' + ')} as publish group ${result.publishGroup}`
171
+ : `✅ Republished ${group.platforms.join(' + ')}`);
172
+ return;
173
+ }
174
+ const platformUpdates = updates.filter(u => platform === 'all' || u.platform === platform);
175
+ if (platformUpdates.length === 0) {
121
176
  log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion} on platform ${platform}.`);
122
177
  process.exit(1);
123
178
  }
@@ -125,7 +180,7 @@ class Publish extends core_1.Command {
125
180
  type: 'select',
126
181
  name: 'update',
127
182
  message: 'Select an update to republish',
128
- choices: updates.map(update => ({
183
+ choices: platformUpdates.map(update => ({
129
184
  title: update.updateUUID,
130
185
  value: update,
131
186
  description: `Created at: ${update.createdAt}, Platform: ${update.platform}, Commit hash: ${update.commitHash}`,
@@ -137,7 +192,7 @@ class Publish extends core_1.Command {
137
192
  republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
138
193
  republishUrl.searchParams.set('updateId', selectedUpdated.update.updateId);
139
194
  republishUrl.searchParams.set('commitHash', selectedUpdated.update.commitHash);
140
- const republishSpinner = (0, ora_1.default)('🔄 Republishing update...').start();
195
+ const republishSpinner = (0, ora_1.ora)('🔄 Republishing update...').start();
141
196
  const republishResponse = await (0, fetch_1.fetchWithRetries)(republishUrl.toString(), {
142
197
  method: 'POST',
143
198
  headers: {
@@ -6,6 +6,7 @@ export default class Publish extends Command {
6
6
  static flags: {
7
7
  platform: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
8
8
  branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
9
+ serverUrl: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
9
10
  nonInteractive: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  private sanitizeFlags;
@@ -28,6 +28,10 @@ class Publish extends core_1.Command {
28
28
  description: 'Name of the branch to point to',
29
29
  required: true,
30
30
  }),
31
+ serverUrl: core_1.Flags.string({
32
+ description: 'URL of the self-hosted update server to roll back on. Defaults to the origin of updates.url from your Expo config',
33
+ required: false,
34
+ }),
31
35
  nonInteractive: core_1.Flags.boolean({
32
36
  description: 'Run command in non-interactive mode',
33
37
  default: false,
@@ -37,6 +41,7 @@ class Publish extends core_1.Command {
37
41
  return {
38
42
  platform: flags.platform,
39
43
  branch: flags.branch,
44
+ customServerUrl: flags.serverUrl,
40
45
  nonInteractive: flags.nonInteractive,
41
46
  };
42
47
  }
@@ -47,7 +52,7 @@ class Publish extends core_1.Command {
47
52
  process.exit(1);
48
53
  }
49
54
  const { flags } = await this.parse(Publish);
50
- const { platform, branch, nonInteractive } = this.sanitizeFlags(flags);
55
+ const { platform, branch, customServerUrl, nonInteractive } = this.sanitizeFlags(flags);
51
56
  if (!branch) {
52
57
  log_1.default.error('Branch name is required');
53
58
  process.exit(1);
@@ -79,21 +84,11 @@ class Publish extends core_1.Command {
79
84
  log_1.default.error('When using disableAntiBrickingMeasures, expo-updates is ignoring the embeded update of the app, please use republish command instead');
80
85
  process.exit(1);
81
86
  }
82
- const updateUrl = (0, expoConfig_1.getExpoConfigUpdateUrl)(privateConfig);
83
- if (!updateUrl) {
84
- log_1.default.error("Update url is not setup in your config. Please run 'eoas init' to setup the update url");
87
+ const baseUrl = await (0, expoConfig_1.resolveServerUrl)(privateConfig, customServerUrl).catch(e => {
88
+ log_1.default.error(e.message);
85
89
  process.exit(1);
86
- }
90
+ });
87
91
  const appId = (0, expoConfig_1.requireExpoAppId)(privateConfig);
88
- let baseUrl;
89
- try {
90
- const parsedUrl = new URL(updateUrl);
91
- baseUrl = parsedUrl.origin;
92
- }
93
- catch (e) {
94
- log_1.default.error('Invalid URL', e);
95
- process.exit(1);
96
- }
97
92
  const runtimeSpinner = (0, ora_1.ora)('🔄 Resolving runtime version...').start();
98
93
  const runtimeVersions = [
99
94
  ...(!platform || platform === expoConfig_1.RequestedPlatform.All || platform === expoConfig_1.RequestedPlatform.Ios
@@ -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 {};