eoas 3.1.2-beta → 3.1.2-beta3

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.
@@ -15,6 +15,7 @@ export default class Publish extends Command {
15
15
  message: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
16
  dumpSourcemap: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
17
17
  'rollout-percentage': import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
18
+ 'upload-rate': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
18
19
  };
19
20
  private sanitizeFlags;
20
21
  run(): Promise<void>;
@@ -18,6 +18,7 @@ const ora_1 = require("../lib/ora");
18
18
  const package_1 = require("../lib/package");
19
19
  const packageRunner_1 = require("../lib/packageRunner");
20
20
  const prompts_1 = require("../lib/prompts");
21
+ const rateLimiter_1 = require("../lib/rateLimiter");
21
22
  const repo_1 = require("../lib/repo");
22
23
  const runtimeVersion_1 = require("../lib/runtimeVersion");
23
24
  const vcs_1 = require("../lib/vcs");
@@ -79,8 +80,17 @@ class Publish extends core_1.Command {
79
80
  max: 99,
80
81
  description: 'Publish this update as a progressive rollout served to N% of devices (1-99). The remaining devices keep receiving the previous update of each branch/runtime version. With --platform all, the rollout applies independently to each platform. Progression (increase, end, revert) is managed from the dashboard.',
81
82
  }),
83
+ 'upload-rate': core_1.Flags.string({
84
+ description: 'Maximum number of asset uploads started per second. Accepts decimals (e.g. 1.5). Lower this if your storage provider rate-limits uploads.',
85
+ default: '10',
86
+ }),
82
87
  };
83
88
  sanitizeFlags(flags) {
89
+ const uploadRate = Number(flags['upload-rate']);
90
+ if (!Number.isFinite(uploadRate) || uploadRate <= 0) {
91
+ log_1.default.error('--upload-rate must be a positive number');
92
+ process.exit(1);
93
+ }
84
94
  return {
85
95
  disableRepositoryCheck: flags.disableRepositoryCheck,
86
96
  platform: flags.platform,
@@ -93,6 +103,7 @@ class Publish extends core_1.Command {
93
103
  message: flags.message,
94
104
  dumpSourcemap: flags.dumpSourcemap,
95
105
  rolloutPercentage: flags['rollout-percentage'],
106
+ uploadRate,
96
107
  };
97
108
  }
98
109
  async run() {
@@ -102,11 +113,14 @@ class Publish extends core_1.Command {
102
113
  process.exit(1);
103
114
  }
104
115
  const { flags } = await this.parse(Publish);
105
- const { platform, nonInteractive, branch, customServerUrl, outputDir, packageRunner, providedDeprecatedChannel, disableRepositoryCheck, message, dumpSourcemap, rolloutPercentage, } = this.sanitizeFlags(flags);
116
+ const { platform, nonInteractive, branch, customServerUrl, outputDir, packageRunner, providedDeprecatedChannel, disableRepositoryCheck, message, dumpSourcemap, rolloutPercentage, uploadRate, } = this.sanitizeFlags(flags);
106
117
  if (!branch) {
107
118
  log_1.default.error('Branch name is required');
108
119
  process.exit(1);
109
120
  }
121
+ // Bucket capacity is ~1s of budget so a low --upload-rate also caps the
122
+ // initial burst, not just the steady rate.
123
+ const publishAssetsRateLimiter = new rateLimiter_1.RateLimiter('publish-eoas-assets', Math.max(1, Math.ceil(uploadRate)), uploadRate);
110
124
  const projectDir = process.cwd();
111
125
  const hasExpo = (0, package_1.isExpoInstalled)(projectDir);
112
126
  if (!hasExpo) {
@@ -289,13 +303,18 @@ class Publish extends core_1.Command {
289
303
  manifest: files,
290
304
  })))).flat();
291
305
  await Promise.all(resolvedUploads.map(async ({ item: itm, absolutePath, manifestEntry }) => {
306
+ await publishAssetsRateLimiter.take();
292
307
  const isLocalBucketFileUpload = itm.requestUploadUrl.startsWith(`${serverUrl}/${appId}/uploadLocalFile`);
293
308
  if (isLocalBucketFileUpload) {
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, {
309
+ // A stream-backed body is consumed by the first attempt, so each
310
+ // retry rebuilds the multipart body from a buffer.
311
+ const fileBuffer = await fs_extra_1.default.readFile(absolutePath);
312
+ const response = await (0, fetch_1.fetchWithRetriesRebuildingBody)(itm.requestUploadUrl, () => {
313
+ const formData = new form_data_1.default();
314
+ formData.append(itm.fileName, fileBuffer, {
315
+ filename: path_1.default.basename(absolutePath),
316
+ });
317
+ return {
299
318
  method: 'PUT',
300
319
  headers: {
301
320
  ...formData.getHeaders(),
@@ -305,14 +324,11 @@ class Publish extends core_1.Command {
305
324
  // The URL was validated as a string; following a redirect would
306
325
  // send these bytes to an origin nothing ever checked.
307
326
  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();
327
+ };
328
+ });
329
+ if (!response.ok) {
330
+ log_1.default.error('Failed to upload file', await response.text());
331
+ throw new Error('Failed to upload file');
316
332
  }
317
333
  return;
318
334
  }
@@ -11,6 +11,7 @@ const package_1 = require("../lib/package");
11
11
  const prompts_1 = require("../lib/prompts");
12
12
  const serverUpdates_1 = require("../lib/serverUpdates");
13
13
  const vcs_1 = require("../lib/vcs");
14
+ const REPUBLISH_PAGE_SIZE = 20;
14
15
  class Publish extends core_1.Command {
15
16
  static args = {};
16
17
  static description = 'Republish a previous update to a branch';
@@ -93,31 +94,27 @@ class Publish extends core_1.Command {
93
94
  })),
94
95
  });
95
96
  log_1.default.log(`Selected runtime version: ${selectedRuntimeVersion.runtimeVersion}`);
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);
108
- process.exit(1);
109
- }
110
- // Rollback markers have no files to republish.
111
- const updates = allUpdates.filter(u => u.updateUUID !== 'Rollback to embedded');
112
- if (updates.length === 0) {
113
- log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion}.`);
114
- process.exit(1);
97
+ let firstGroupsPage = null;
98
+ if (platform === 'all') {
99
+ try {
100
+ firstGroupsPage = await (0, serverUpdates_1.fetchPublishGroups)({
101
+ baseUrl,
102
+ appId,
103
+ branch,
104
+ runtimeVersion: selectedRuntimeVersion.runtimeVersion,
105
+ credentials,
106
+ limit: REPUBLISH_PAGE_SIZE,
107
+ });
108
+ }
109
+ catch (e) {
110
+ log_1.default.error(e instanceof Error ? e.message : e);
111
+ process.exit(1);
112
+ }
115
113
  }
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.
114
+ // Publish groups are a control-plane read model. Servers without that
115
+ // capability return no group page and keep the single-update flow.
119
116
  let mode = 'single';
120
- if (platform === 'all' && groups.length > 0) {
117
+ if (firstGroupsPage && firstGroupsPage.items.length > 0) {
121
118
  const selectedMode = await (0, prompts_1.promptAsync)({
122
119
  type: 'select',
123
120
  name: 'mode',
@@ -138,16 +135,58 @@ class Publish extends core_1.Command {
138
135
  mode = selectedMode.mode;
139
136
  }
140
137
  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;
138
+ if (!firstGroupsPage) {
139
+ log_1.default.error('Publish group listing is not available');
140
+ process.exit(1);
141
+ }
142
+ const groups = [...firstGroupsPage.items];
143
+ let nextGroupCursor = firstGroupsPage.nextCursor;
144
+ let group;
145
+ let initialChoiceIndex = 0;
146
+ while (!group) {
147
+ const choices = groups.map(candidate => ({
148
+ ...(0, serverUpdates_1.describePublishGroup)(candidate),
149
+ value: { kind: 'group', group: candidate },
150
+ }));
151
+ if (nextGroupCursor) {
152
+ choices.push({ title: 'Load more publishes', value: { kind: 'loadMore' } });
153
+ }
154
+ const selectedGroup = await (0, prompts_1.promptAsync)({
155
+ type: 'select',
156
+ name: 'group',
157
+ message: 'Select a publish to republish',
158
+ choices,
159
+ initial: initialChoiceIndex,
160
+ });
161
+ const selection = selectedGroup.group;
162
+ if (selection.kind === 'loadMore') {
163
+ const previousGroupCount = groups.length;
164
+ try {
165
+ const page = await (0, serverUpdates_1.fetchPublishGroups)({
166
+ baseUrl,
167
+ appId,
168
+ branch,
169
+ runtimeVersion: selectedRuntimeVersion.runtimeVersion,
170
+ credentials,
171
+ cursor: nextGroupCursor ?? undefined,
172
+ limit: REPUBLISH_PAGE_SIZE,
173
+ });
174
+ if (!page) {
175
+ throw new Error('Publish group listing is no longer available');
176
+ }
177
+ groups.push(...page.items);
178
+ nextGroupCursor = page.nextCursor;
179
+ }
180
+ catch (e) {
181
+ log_1.default.error(e instanceof Error ? e.message : e);
182
+ process.exit(1);
183
+ }
184
+ initialChoiceIndex = Math.min(previousGroupCount, Math.max(0, groups.length - 1));
185
+ }
186
+ else {
187
+ group = selection.group;
188
+ }
189
+ }
151
190
  const republishUrl = new URL(`${baseUrl}/${appId}/republish/${branch}`);
152
191
  republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
153
192
  republishUrl.searchParams.set('publishGroup', group.publishGroup);
@@ -171,27 +210,78 @@ class Publish extends core_1.Command {
171
210
  : `✅ Republished ${group.platforms.join(' + ')}`);
172
211
  return;
173
212
  }
174
- const platformUpdates = updates.filter(u => platform === 'all' || u.platform === platform);
175
- if (platformUpdates.length === 0) {
213
+ const updates = [];
214
+ let nextCursor;
215
+ const loadNextPage = async () => {
216
+ const previousCount = updates.length;
217
+ do {
218
+ const page = await (0, serverUpdates_1.fetchUpdates)({
219
+ baseUrl,
220
+ appId,
221
+ branch,
222
+ runtimeVersion: selectedRuntimeVersion.runtimeVersion,
223
+ credentials,
224
+ cursor: nextCursor ?? undefined,
225
+ limit: REPUBLISH_PAGE_SIZE,
226
+ });
227
+ // Rollbacks have no files to republish. Apply a requested platform
228
+ // before presenting pages, but do not fetch ahead merely to fill 20.
229
+ updates.push(...page.items.filter(update => update.updateUUID !== 'Rollback to embedded' &&
230
+ (platform === 'all' || update.platform === platform)));
231
+ nextCursor = page.nextCursor;
232
+ } while (updates.length === previousCount && nextCursor);
233
+ };
234
+ try {
235
+ await loadNextPage();
236
+ }
237
+ catch (e) {
238
+ log_1.default.error(e instanceof Error ? e.message : e);
239
+ process.exit(1);
240
+ }
241
+ if (updates.length === 0 && !nextCursor) {
176
242
  log_1.default.error(`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion} on platform ${platform}.`);
177
243
  process.exit(1);
178
244
  }
179
- const selectedUpdated = await (0, prompts_1.promptAsync)({
180
- type: 'select',
181
- name: 'update',
182
- message: 'Select an update to republish',
183
- choices: platformUpdates.map(update => ({
245
+ let selectedUpdate;
246
+ let initialChoiceIndex = 0;
247
+ while (!selectedUpdate) {
248
+ const choices = updates.map(update => ({
184
249
  title: update.updateUUID,
185
- value: update,
250
+ value: { kind: 'update', update },
186
251
  description: `Created at: ${update.createdAt}, Platform: ${update.platform}, Commit hash: ${update.commitHash}`,
187
- })),
188
- });
189
- log_1.default.log(`Re-publishing update: ${selectedUpdated.update.updateUUID}`);
252
+ }));
253
+ if (nextCursor) {
254
+ choices.push({ title: 'Load more updates', value: { kind: 'loadMore' } });
255
+ }
256
+ const answer = await (0, prompts_1.promptAsync)({
257
+ type: 'select',
258
+ name: 'update',
259
+ message: 'Select an update to republish',
260
+ choices,
261
+ initial: initialChoiceIndex,
262
+ });
263
+ const selection = answer.update;
264
+ if (selection.kind === 'loadMore') {
265
+ const firstNewUpdateIndex = updates.length;
266
+ try {
267
+ await loadNextPage();
268
+ }
269
+ catch (e) {
270
+ log_1.default.error(e instanceof Error ? e.message : e);
271
+ process.exit(1);
272
+ }
273
+ initialChoiceIndex = Math.min(firstNewUpdateIndex, Math.max(0, updates.length - 1));
274
+ }
275
+ else {
276
+ selectedUpdate = selection.update;
277
+ }
278
+ }
279
+ log_1.default.log(`Re-publishing update: ${selectedUpdate.updateUUID}`);
190
280
  const republishUrl = new URL(`${baseUrl}/${appId}/republish/${branch}`);
191
- republishUrl.searchParams.set('platform', selectedUpdated.update.platform);
281
+ republishUrl.searchParams.set('platform', selectedUpdate.platform);
192
282
  republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
193
- republishUrl.searchParams.set('updateId', selectedUpdated.update.updateId);
194
- republishUrl.searchParams.set('commitHash', selectedUpdated.update.commitHash);
283
+ republishUrl.searchParams.set('updateId', selectedUpdate.updateId);
284
+ republishUrl.searchParams.set('commitHash', selectedUpdate.commitHash);
195
285
  const republishSpinner = (0, ora_1.ora)('🔄 Republishing update...').start();
196
286
  const republishResponse = await (0, fetch_1.fetchWithRetries)(republishUrl.toString(), {
197
287
  method: 'POST',
@@ -1,2 +1,6 @@
1
1
  import { RequestInit, Response } from 'node-fetch';
2
+ export declare function isRetryableStatus(status: number): boolean;
3
+ export declare function retryAfterMs(header: string | null): number | null;
4
+ export declare function redactUrl(url: string): string;
2
5
  export declare function fetchWithRetries(url: string, options: RequestInit): Promise<Response>;
6
+ export declare function fetchWithRetriesRebuildingBody(url: string, makeOptions: () => RequestInit): Promise<Response>;
package/dist/lib/fetch.js CHANGED
@@ -1,27 +1,103 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.fetchWithRetries = void 0;
3
+ exports.fetchWithRetriesRebuildingBody = exports.fetchWithRetries = exports.redactUrl = exports.retryAfterMs = exports.isRetryableStatus = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const fetch_retry_1 = tslib_1.__importDefault(require("fetch-retry"));
6
6
  const node_fetch_1 = tslib_1.__importDefault(require("node-fetch"));
7
7
  const log_1 = tslib_1.__importDefault(require("./log"));
8
8
  const fetch = (0, fetch_retry_1.default)(node_fetch_1.default);
9
+ function isRetryableStatus(status) {
10
+ return status === 429 || status >= 500;
11
+ }
12
+ exports.isRetryableStatus = isRetryableStatus;
13
+ // Retry-After is either delay-seconds or an HTTP-date.
14
+ function retryAfterMs(header) {
15
+ if (!header) {
16
+ return null;
17
+ }
18
+ const seconds = Number(header);
19
+ if (Number.isFinite(seconds)) {
20
+ return seconds * 1000;
21
+ }
22
+ const dateMs = Date.parse(header);
23
+ if (!Number.isNaN(dateMs)) {
24
+ return dateMs - Date.now();
25
+ }
26
+ return null;
27
+ }
28
+ exports.retryAfterMs = retryAfterMs;
29
+ const MAX_RETRY_DELAY_MS = 60000;
30
+ const MAX_RETRIED_ATTEMPT = 3;
31
+ // Upload URLs carry their credentials in the query string (S3 presign, Azure
32
+ // SAS, the local bucket's upload token), so logs may only show origin and path.
33
+ function redactUrl(url) {
34
+ try {
35
+ const parsed = new URL(url);
36
+ return parsed.origin + parsed.pathname;
37
+ }
38
+ catch {
39
+ return '<unparseable url>';
40
+ }
41
+ }
42
+ exports.redactUrl = redactUrl;
43
+ function retryDelayForResponse(attempt, response) {
44
+ const serverDelay = retryAfterMs(response.headers.get('retry-after'));
45
+ const backoff = Math.pow(2, attempt) * 2000;
46
+ return Math.min(Math.max(serverDelay ?? 0, backoff), MAX_RETRY_DELAY_MS);
47
+ }
9
48
  async function fetchWithRetries(url, options) {
10
49
  return await fetch(url, {
11
50
  ...options,
12
- retryDelay(attempt) {
51
+ retryDelay(attempt, _error, response) {
52
+ if (response) {
53
+ return retryDelayForResponse(attempt, response);
54
+ }
13
55
  return Math.pow(2, attempt) * 500;
14
56
  },
15
- retryOn: (attempt, error) => {
16
- if (attempt > 3) {
57
+ retryOn: (attempt, error, response) => {
58
+ if (attempt > MAX_RETRIED_ATTEMPT) {
17
59
  return false;
18
60
  }
19
61
  if (error) {
20
62
  log_1.default.warn(`Retry ${attempt} after network error:`, error.message);
21
63
  return true;
22
64
  }
65
+ if (response && isRetryableStatus(response.status)) {
66
+ log_1.default.warn(`Retry ${attempt} after HTTP ${response.status} from ${redactUrl(url)}`);
67
+ return true;
68
+ }
23
69
  return false;
24
70
  },
25
71
  });
26
72
  }
27
73
  exports.fetchWithRetries = fetchWithRetries;
74
+ function sleep(ms) {
75
+ return new Promise(resolve => setTimeout(resolve, ms));
76
+ }
77
+ // fetch-retry re-sends the same RequestInit on every attempt, which silently
78
+ // replays an already-consumed stream body as empty. Callers with a one-shot
79
+ // body (multipart uploads) use this variant: makeOptions rebuilds the body
80
+ // for each attempt. Same retry policy as fetchWithRetries.
81
+ async function fetchWithRetriesRebuildingBody(url, makeOptions) {
82
+ for (let attempt = 0;; attempt++) {
83
+ let response;
84
+ try {
85
+ response = await (0, node_fetch_1.default)(url, makeOptions());
86
+ }
87
+ catch (error) {
88
+ if (attempt > MAX_RETRIED_ATTEMPT) {
89
+ throw error;
90
+ }
91
+ log_1.default.warn(`Retry ${attempt} after network error:`, error.message);
92
+ await sleep(Math.pow(2, attempt) * 500);
93
+ continue;
94
+ }
95
+ if (attempt <= MAX_RETRIED_ATTEMPT && isRetryableStatus(response.status)) {
96
+ log_1.default.warn(`Retry ${attempt} after HTTP ${response.status} from ${redactUrl(url)}`);
97
+ await sleep(retryDelayForResponse(attempt, response));
98
+ continue;
99
+ }
100
+ return response;
101
+ }
102
+ }
103
+ exports.fetchWithRetriesRebuildingBody = fetchWithRetriesRebuildingBody;
@@ -0,0 +1,7 @@
1
+ export declare class RateLimiter {
2
+ private readonly key;
3
+ private readonly capacity;
4
+ private readonly refillRate;
5
+ constructor(key: string, capacity: number, refillRate: number);
6
+ take(): Promise<void>;
7
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RateLimiter = void 0;
4
+ const kv = {};
5
+ class RateLimiter {
6
+ key;
7
+ capacity;
8
+ refillRate;
9
+ constructor(key, capacity, refillRate) {
10
+ this.key = key;
11
+ this.capacity = capacity;
12
+ this.refillRate = refillRate;
13
+ }
14
+ async take() {
15
+ const now = Date.now();
16
+ const bucket = kv[this.key];
17
+ let tokens = bucket ? bucket.tokens : this.capacity;
18
+ const lastRefill = bucket ? bucket.lastRefill : now;
19
+ const elapsed = (now - lastRefill) / 1000;
20
+ tokens = Math.min(this.capacity, tokens + elapsed * this.refillRate);
21
+ if (tokens < 1) {
22
+ const waitMs = ((1 - tokens) / this.refillRate) * 1000;
23
+ await new Promise(res => setTimeout(res, waitMs));
24
+ await this.take();
25
+ return;
26
+ }
27
+ tokens -= 1;
28
+ kv[this.key] = {
29
+ tokens,
30
+ lastRefill: now,
31
+ };
32
+ }
33
+ }
34
+ exports.RateLimiter = RateLimiter;
@@ -14,27 +14,52 @@ export interface ServerUpdateItem {
14
14
  message?: string;
15
15
  publishGroup?: string;
16
16
  }
17
+ export interface ServerUpdatesPage {
18
+ items: ServerUpdateItem[];
19
+ nextCursor: string | null;
20
+ }
21
+ export interface PublishGroupUpdateItem {
22
+ updateId: string;
23
+ createdAt: string;
24
+ platform: string;
25
+ commitHash: string;
26
+ }
27
+ export interface PublishGroupSummary {
28
+ publishGroup: string;
29
+ platforms: string[];
30
+ commitHash: string;
31
+ message?: string;
32
+ createdAt: string;
33
+ updates: PublishGroupUpdateItem[];
34
+ }
35
+ export interface ServerPublishGroupsPage {
36
+ items: PublishGroupSummary[];
37
+ nextCursor: string | null;
38
+ }
17
39
  export declare function fetchRuntimeVersions({ baseUrl, appId, branch, credentials, }: {
18
40
  baseUrl: string;
19
41
  appId: string;
20
42
  branch: string;
21
43
  credentials: Credentials;
22
44
  }): Promise<RuntimeVersionInfo[]>;
23
- export declare function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, }: {
45
+ export declare function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, cursor, limit, }: {
24
46
  baseUrl: string;
25
47
  appId: string;
26
48
  branch: string;
27
49
  runtimeVersion: string;
28
50
  credentials: Credentials;
29
- }): Promise<ServerUpdateItem[]>;
30
- export interface PublishGroupSummary {
31
- publishGroup: string;
32
- platforms: string[];
33
- commitHash: string;
34
- message?: string;
35
- createdAt: string;
36
- updates: ServerUpdateItem[];
37
- }
51
+ cursor?: string;
52
+ limit?: number;
53
+ }): Promise<ServerUpdatesPage>;
54
+ export declare function fetchPublishGroups({ baseUrl, appId, branch, runtimeVersion, credentials, cursor, limit, }: {
55
+ baseUrl: string;
56
+ appId: string;
57
+ branch: string;
58
+ runtimeVersion: string;
59
+ credentials: Credentials;
60
+ cursor?: string;
61
+ limit?: number;
62
+ }): Promise<ServerPublishGroupsPage | null>;
38
63
  export declare function groupPublishedUpdates(updates: ServerUpdateItem[]): {
39
64
  groups: PublishGroupSummary[];
40
65
  ungrouped: ServerUpdateItem[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.describePublishGroup = exports.groupPublishedUpdates = exports.fetchUpdates = exports.fetchRuntimeVersions = void 0;
3
+ exports.describePublishGroup = exports.groupPublishedUpdates = exports.fetchPublishGroups = exports.fetchUpdates = exports.fetchRuntimeVersions = void 0;
4
4
  const auth_1 = require("./auth");
5
5
  const fetch_1 = require("./fetch");
6
6
  async function fetchRuntimeVersions({ baseUrl, appId, branch, credentials, }) {
@@ -16,8 +16,13 @@ async function fetchRuntimeVersions({ baseUrl, appId, branch, credentials, }) {
16
16
  return (await response.json());
17
17
  }
18
18
  exports.fetchRuntimeVersions = fetchRuntimeVersions;
19
- async function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, }) {
20
- const response = await (0, fetch_1.fetchWithRetries)(`${baseUrl}/api/apps/${appId}/branch/${branch}/runtimeVersion/${runtimeVersion}/updates`, {
19
+ async function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, cursor, limit = 20, }) {
20
+ const url = new URL(`${baseUrl}/api/apps/${encodeURIComponent(appId)}/branch/${encodeURIComponent(branch)}/runtimeVersion/${encodeURIComponent(runtimeVersion)}/updates`);
21
+ url.searchParams.set('limit', String(limit));
22
+ if (cursor) {
23
+ url.searchParams.set('cursor', cursor);
24
+ }
25
+ const response = await (0, fetch_1.fetchWithRetries)(url.toString(), {
21
26
  headers: {
22
27
  ...(0, auth_1.getAuthHeaders)(credentials),
23
28
  'use-cli-auth': 'true',
@@ -29,6 +34,29 @@ async function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credential
29
34
  return (await response.json());
30
35
  }
31
36
  exports.fetchUpdates = fetchUpdates;
37
+ // Publish groups only exist in control-plane mode. A 404 marks group mode as
38
+ // unavailable without requiring a separate capability negotiation request.
39
+ async function fetchPublishGroups({ baseUrl, appId, branch, runtimeVersion, credentials, cursor, limit = 20, }) {
40
+ const url = new URL(`${baseUrl}/api/apps/${encodeURIComponent(appId)}/branch/${encodeURIComponent(branch)}/runtimeVersion/${encodeURIComponent(runtimeVersion)}/publish-groups`);
41
+ url.searchParams.set('limit', String(limit));
42
+ if (cursor) {
43
+ url.searchParams.set('cursor', cursor);
44
+ }
45
+ const response = await (0, fetch_1.fetchWithRetries)(url.toString(), {
46
+ headers: {
47
+ ...(0, auth_1.getAuthHeaders)(credentials),
48
+ 'use-cli-auth': 'true',
49
+ },
50
+ });
51
+ if (response.status === 404) {
52
+ return null;
53
+ }
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to fetch publish groups: ${await response.text()}`);
56
+ }
57
+ return (await response.json());
58
+ }
59
+ exports.fetchPublishGroups = fetchPublishGroups;
32
60
  // groupPublishedUpdates splits a listing into publish groups (newest first)
33
61
  // and the leftover ungrouped updates (older CLIs, stateless servers). Filter
34
62
  // out rollback markers before calling if they should not be offered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eoas",
3
- "version": "3.1.2-beta",
3
+ "version": "3.1.2-beta3",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "build": "tsc --project tsconfig.json",