@everystack/cli 0.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.
Files changed (42) hide show
  1. package/README.md +255 -0
  2. package/package.json +104 -0
  3. package/src/cli/aws.ts +121 -0
  4. package/src/cli/commands/analyze.ts +61 -0
  5. package/src/cli/commands/branches.ts +97 -0
  6. package/src/cli/commands/cache.ts +72 -0
  7. package/src/cli/commands/certs.ts +117 -0
  8. package/src/cli/commands/channels.ts +109 -0
  9. package/src/cli/commands/console.ts +68 -0
  10. package/src/cli/commands/db.ts +183 -0
  11. package/src/cli/commands/diag.ts +242 -0
  12. package/src/cli/commands/logs.ts +282 -0
  13. package/src/cli/commands/update.ts +432 -0
  14. package/src/cli/config.ts +98 -0
  15. package/src/cli/discover.ts +321 -0
  16. package/src/cli/hydration-analyzer.ts +224 -0
  17. package/src/cli/index.ts +178 -0
  18. package/src/cli/output.ts +25 -0
  19. package/src/cli/ssr-analyzer.ts +445 -0
  20. package/src/cli/utils/export.ts +8 -0
  21. package/src/cli/utils/table.ts +39 -0
  22. package/src/cli/utils/upload.ts +52 -0
  23. package/src/cli/utils/walk.ts +59 -0
  24. package/src/client/app-state-provider.tsx +83 -0
  25. package/src/client/index.ts +2 -0
  26. package/src/client/updates-provider.tsx +69 -0
  27. package/src/handler/assets.ts +30 -0
  28. package/src/handler/branches.ts +70 -0
  29. package/src/handler/channels-crud.ts +174 -0
  30. package/src/handler/helpers.ts +239 -0
  31. package/src/handler/index.ts +78 -0
  32. package/src/handler/manifest.ts +276 -0
  33. package/src/handler/multipart.ts +74 -0
  34. package/src/handler/publish-web.ts +311 -0
  35. package/src/handler/publish.ts +346 -0
  36. package/src/handler/signing.ts +29 -0
  37. package/src/handler/types.ts +16 -0
  38. package/src/index.ts +4 -0
  39. package/src/schema.ts +245 -0
  40. package/src/storage/filesystem.ts +103 -0
  41. package/src/storage/index.ts +27 -0
  42. package/src/storage/s3.ts +125 -0
@@ -0,0 +1,311 @@
1
+ import crypto from 'crypto';
2
+ import { eq, and } from 'drizzle-orm';
3
+ import type { UpdatesHandlerOptions } from './types';
4
+ import { opsItems, runs, buildArtifacts } from '../schema';
5
+ import { sha256 } from './signing';
6
+ import { checkAuth, jsonError, mirrorToDb, ensureChannelInStorage } from './helpers';
7
+
8
+ // --- Presign endpoint ---
9
+
10
+ interface PresignWebBody {
11
+ channel?: string;
12
+ branch?: string;
13
+ groupId?: string;
14
+ runtimeVersion: string;
15
+ clientAssetPaths?: string[];
16
+ }
17
+
18
+ /**
19
+ * POST /presign-web
20
+ *
21
+ * Authenticates, resolves channel + branch + group, generates presigned
22
+ * S3 PUT URLs for the server bundle archive and client assets. The CLI
23
+ * uploads directly to S3 using these URLs, then calls /register-web.
24
+ */
25
+ export async function handlePresignWeb(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
26
+ const authResult = await checkAuth(request, options);
27
+ if (authResult) return authResult;
28
+
29
+ let body: PresignWebBody;
30
+ try {
31
+ body = await request.json() as PresignWebBody;
32
+ } catch {
33
+ return jsonError(400, 'Invalid JSON body.');
34
+ }
35
+
36
+ if (!body.runtimeVersion) {
37
+ return jsonError(400, 'Missing runtimeVersion.');
38
+ }
39
+
40
+ if (!options.storage.presignPut) {
41
+ return jsonError(500, 'Storage backend does not support presigned URLs.');
42
+ }
43
+
44
+ // Determine branch name
45
+ const channelName = body.channel || options.defaultChannel || 'production';
46
+ const branchName = body.branch || channelName;
47
+
48
+ // Write/update channel metadata in S3
49
+ await ensureChannelInStorage(options.storage, channelName, branchName);
50
+
51
+ // Generate groupId locally or use provided one
52
+ let groupId = body.groupId || crypto.randomUUID();
53
+
54
+ // If groupId was provided and DB is available, validate it exists
55
+ if (body.groupId && options.db) {
56
+ const [existingGroup] = await options.db
57
+ .select()
58
+ .from(runs)
59
+ .where(and(
60
+ eq(runs.id, groupId),
61
+ eq(runs.type, 'release'),
62
+ ))
63
+ .limit(1);
64
+
65
+ if (!existingGroup) {
66
+ return jsonError(404, `Update group "${groupId}" not found.`);
67
+ }
68
+ }
69
+
70
+ // Generate identifiers
71
+ const updateId = sha256(`${channelName}:${body.runtimeVersion}:${Date.now()}`);
72
+ const storagePrefix = `releases/${branchName}/${body.runtimeVersion}/${groupId}/web`;
73
+ const bundleKey = `${storagePrefix}/bundle.tar.br`;
74
+
75
+ // Presign server bundle upload URL
76
+ const bundleUploadUrl = await options.storage.presignPut(bundleKey, 'application/x-tar+br');
77
+
78
+ // Presign client asset upload URLs
79
+ const clientAssetUploadUrls: Record<string, string> = {};
80
+ if (options.clientBundlesStorage?.presignPut && body.clientAssetPaths) {
81
+ for (const assetPath of body.clientAssetPaths) {
82
+ clientAssetUploadUrls[assetPath] = await options.clientBundlesStorage.presignPut(
83
+ assetPath,
84
+ guessMime(assetPath),
85
+ );
86
+ }
87
+ }
88
+
89
+ // Mirror channel + group to DB
90
+ await mirrorToDb(options.db, async () => {
91
+ // Find or create channel
92
+ let [channel] = await options.db
93
+ .select()
94
+ .from(opsItems)
95
+ .where(and(
96
+ eq(opsItems.type, 'release_channel'),
97
+ eq(opsItems.name, channelName),
98
+ ))
99
+ .limit(1);
100
+
101
+ if (!channel) {
102
+ const [newChannel] = await options.db
103
+ .insert(opsItems)
104
+ .values({
105
+ type: 'release_channel',
106
+ name: channelName,
107
+ status: 'active',
108
+ data: { branchRef: branchName },
109
+ })
110
+ .returning();
111
+ channel = newChannel;
112
+ }
113
+
114
+ // Map channel to branch if not already mapped
115
+ const channelData = (channel.data as Record<string, unknown>) || {};
116
+ if (!channelData.branchRef) {
117
+ await options.db
118
+ .update(opsItems)
119
+ .set({
120
+ data: { ...channelData, branchRef: branchName },
121
+ updatedAt: new Date(),
122
+ })
123
+ .where(eq(opsItems.id, channel.id))
124
+ .returning();
125
+ }
126
+
127
+ // Create group if new
128
+ if (!body.groupId) {
129
+ await options.db
130
+ .insert(runs)
131
+ .values({
132
+ id: groupId,
133
+ type: 'release',
134
+ name: null,
135
+ status: 'completed',
136
+ queuedAt: new Date(),
137
+ completedAt: new Date(),
138
+ data: {
139
+ branchRef: branchName,
140
+ message: null,
141
+ },
142
+ })
143
+ .returning();
144
+ }
145
+ });
146
+
147
+ return new Response(JSON.stringify({
148
+ bundleUploadUrl,
149
+ clientAssetUploadUrls,
150
+ storagePrefix,
151
+ bundleKey,
152
+ updateId,
153
+ channelName,
154
+ branchName,
155
+ groupId,
156
+ }), {
157
+ status: 200,
158
+ headers: { 'content-type': 'application/json' },
159
+ });
160
+ }
161
+
162
+ // --- Register endpoint ---
163
+
164
+ interface RegisterWebBody {
165
+ groupId: string;
166
+ runtimeVersion: string;
167
+ updateId: string;
168
+ storagePrefix: string;
169
+ message?: string;
170
+ channel?: string;
171
+ }
172
+
173
+ /**
174
+ * POST /register-web
175
+ *
176
+ * Called by the CLI after uploading the server bundle and client
177
+ * assets to S3. Writes release metadata to S3 and optionally mirrors to DB.
178
+ */
179
+ export async function handleRegisterWeb(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
180
+ const authResult = await checkAuth(request, options);
181
+ if (authResult) return authResult;
182
+
183
+ let body: RegisterWebBody;
184
+ try {
185
+ body = await request.json() as RegisterWebBody;
186
+ } catch {
187
+ return jsonError(400, 'Invalid JSON body.');
188
+ }
189
+
190
+ if (!body.groupId || !body.runtimeVersion || !body.updateId || !body.storagePrefix) {
191
+ return jsonError(400, 'Missing required fields: groupId, runtimeVersion, updateId, storagePrefix.');
192
+ }
193
+
194
+ // Verify the bundle archive exists in storage
195
+ const bundleKey = `${body.storagePrefix}/bundle.tar.br`;
196
+ const bundleExists = await options.storage.exists(bundleKey);
197
+ if (!bundleExists) {
198
+ return jsonError(400, 'Server bundle not found at expected location. Upload before registering.');
199
+ }
200
+
201
+ const createdAt = new Date().toISOString();
202
+
203
+ // Derive branch from storagePrefix: releases/{branch}/{rv}/{groupId}/web
204
+ const prefixParts = body.storagePrefix.split('/');
205
+ const branchName = prefixParts.length >= 2 ? prefixParts[1] : 'production';
206
+ const channelName = body.channel || branchName;
207
+
208
+ // Write web manifest to S3
209
+ const webManifest = {
210
+ updateId: body.updateId,
211
+ runtimeVersion: body.runtimeVersion,
212
+ platform: 'web',
213
+ storagePrefix: body.storagePrefix,
214
+ createdAt,
215
+ };
216
+ await options.storage.put(
217
+ `${body.storagePrefix}/manifest.json`,
218
+ Buffer.from(JSON.stringify(webManifest)),
219
+ 'application/json',
220
+ );
221
+
222
+ // Write channel pointer manifest (mutable — points to latest versioned release)
223
+ // Unlike mobile which has per-runtimeVersion pointers ({channel}/{platform}/{rv}/manifest.json),
224
+ // web uses a single runtimeVersion-agnostic pointer ({channel}/web/manifest.json)
225
+ // because web SSR has no runtimeVersion header from the client.
226
+ const channelPointerKey = `${channelName}/web/manifest.json`;
227
+ await options.storage.put(
228
+ channelPointerKey,
229
+ Buffer.from(JSON.stringify(webManifest)),
230
+ 'application/json',
231
+ );
232
+
233
+ // Write group metadata (merge platforms if meta.json already exists)
234
+ const groupMetaKey = prefixParts.slice(0, 4).join('/') + '/meta.json';
235
+ let platforms = ['web'];
236
+ try {
237
+ const existingMeta = await options.storage.get(groupMetaKey);
238
+ if (existingMeta) {
239
+ const parsed = JSON.parse(existingMeta.data.toString('utf8'));
240
+ if (Array.isArray(parsed.platforms)) {
241
+ const merged = new Set([...parsed.platforms, 'web']);
242
+ platforms = [...merged];
243
+ }
244
+ }
245
+ } catch { /* first platform for this group */ }
246
+
247
+ await options.storage.put(
248
+ groupMetaKey,
249
+ Buffer.from(JSON.stringify({
250
+ message: body.message || null,
251
+ branchRef: branchName,
252
+ platforms,
253
+ createdAt,
254
+ })),
255
+ 'application/json',
256
+ );
257
+
258
+ // Mirror to DB
259
+ let releaseId: string | undefined;
260
+ await mirrorToDb(options.db, async () => {
261
+ const [release] = await options.db
262
+ .insert(buildArtifacts)
263
+ .values({
264
+ type: 'ota',
265
+ runId: body.groupId,
266
+ runtimeVersion: body.runtimeVersion,
267
+ platform: 'web',
268
+ s3Key: body.storagePrefix,
269
+ data: {
270
+ updateId: body.updateId,
271
+ metadata: { storagePrefix: body.storagePrefix },
272
+ isRollback: false,
273
+ },
274
+ })
275
+ .returning();
276
+ releaseId = release.id;
277
+ });
278
+
279
+ return new Response(JSON.stringify({
280
+ releaseId: releaseId || body.groupId,
281
+ updateId: body.updateId,
282
+ groupId: body.groupId,
283
+ }), {
284
+ status: 201,
285
+ headers: { 'content-type': 'application/json' },
286
+ });
287
+ }
288
+
289
+ // --- Local helpers ---
290
+
291
+ function guessMime(filePath: string): string {
292
+ const ext = filePath.split('.').pop()?.toLowerCase();
293
+ const map: Record<string, string> = {
294
+ js: 'application/javascript',
295
+ mjs: 'application/javascript',
296
+ json: 'application/json',
297
+ html: 'text/html',
298
+ css: 'text/css',
299
+ png: 'image/png',
300
+ jpg: 'image/jpeg',
301
+ jpeg: 'image/jpeg',
302
+ gif: 'image/gif',
303
+ svg: 'image/svg+xml',
304
+ webp: 'image/webp',
305
+ ttf: 'font/ttf',
306
+ woff: 'font/woff',
307
+ woff2: 'font/woff2',
308
+ map: 'application/json',
309
+ };
310
+ return map[ext || ''] || 'application/octet-stream';
311
+ }
@@ -0,0 +1,346 @@
1
+ import crypto from 'crypto';
2
+ import { eq, and } from 'drizzle-orm';
3
+ import type { UpdatesHandlerOptions } from './types';
4
+ import { opsItems, runs, buildArtifacts } from '../schema';
5
+ import { sha256, sha256Base64Url, md5Hex } from './signing';
6
+ import {
7
+ checkAuth,
8
+ jsonError,
9
+ mirrorToDb,
10
+ buildManifestJson,
11
+ ensureChannelInStorage,
12
+ type ManifestAssetRecord,
13
+ } from './helpers';
14
+
15
+ interface PublishBody {
16
+ channel?: string;
17
+ branch?: string;
18
+ groupId?: string;
19
+ runtimeVersion: string;
20
+ platform: string;
21
+ message?: string;
22
+ expoConfig?: Record<string, unknown>;
23
+ metadata: Record<string, unknown>;
24
+ assets: PublishAsset[];
25
+ }
26
+
27
+ interface PublishAsset {
28
+ path: string;
29
+ data: string; // base64-encoded
30
+ contentType: string;
31
+ fileExtension: string;
32
+ isLaunchAsset?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Asset record produced AFTER assets have been uploaded to S3.
37
+ * Used by `registerMobileRelease` (called from CLI after direct S3 upload)
38
+ * and built internally by `handlePublish` (legacy HTTP path).
39
+ */
40
+ export interface RegisterMobileAsset {
41
+ path: string;
42
+ s3Key: string;
43
+ contentHash: string; // sha256, base64url
44
+ filename: string; // md5, hex
45
+ mimeType: string;
46
+ fileExtension: string;
47
+ isLaunchAsset: boolean;
48
+ sizeBytes: number;
49
+ }
50
+
51
+ export interface RegisterMobileParams {
52
+ channel?: string;
53
+ branch?: string;
54
+ groupId?: string;
55
+ /** True when groupId refers to a pre-existing release run (skip insert, validate it exists). */
56
+ groupAlreadyExists?: boolean;
57
+ runtimeVersion: string;
58
+ platform: 'ios' | 'android';
59
+ message?: string;
60
+ expoConfig?: Record<string, unknown>;
61
+ metadata: Record<string, unknown>;
62
+ storagePrefix: string;
63
+ assets: RegisterMobileAsset[];
64
+ }
65
+
66
+ export interface RegisterMobileResult {
67
+ releaseId: string;
68
+ updateId: string;
69
+ channel: string;
70
+ branch: string;
71
+ groupId: string;
72
+ platform: string;
73
+ assetCount: number;
74
+ }
75
+
76
+ /**
77
+ * Register a mobile release whose assets have already been uploaded to S3.
78
+ *
79
+ * Writes the manifest, channel pointer, and group meta to S3, then optionally
80
+ * mirrors the release + asset records to the database. Used by both:
81
+ * - `handlePublish` (HTTP /publish, legacy) — uploads assets, then calls this
82
+ * - The example app's `register-mobile` onAction — CLI uploads via IAM/S3
83
+ * directly, then invokes this through Lambda.
84
+ */
85
+ export async function registerMobileRelease(
86
+ options: UpdatesHandlerOptions,
87
+ params: RegisterMobileParams,
88
+ ): Promise<RegisterMobileResult> {
89
+ const channelName = params.channel || options.defaultChannel || 'production';
90
+ const branchName = params.branch || channelName;
91
+ const createdAt = new Date().toISOString();
92
+ const callerProvidedGroup = params.groupAlreadyExists === true;
93
+ const groupId = params.groupId || crypto.randomUUID();
94
+ const basePath = options.basePath || '';
95
+
96
+ // Validate group if provided + DB available
97
+ if (callerProvidedGroup && options.db) {
98
+ const [existingGroup] = await options.db
99
+ .select()
100
+ .from(runs)
101
+ .where(and(
102
+ eq(runs.id, groupId),
103
+ eq(runs.type, 'release'),
104
+ ))
105
+ .limit(1);
106
+ if (!existingGroup) {
107
+ throw new Error(`Update group "${groupId}" not found.`);
108
+ }
109
+ }
110
+
111
+ const updateId = sha256(JSON.stringify(params.metadata));
112
+
113
+ const assetRecords: Array<ManifestAssetRecord & { sizeBytes: number }> =
114
+ params.assets.map(a => ({
115
+ s3Key: a.s3Key,
116
+ contentHash: a.contentHash,
117
+ filename: a.filename,
118
+ mimeType: a.mimeType,
119
+ fileExtension: a.fileExtension.startsWith('.') ? a.fileExtension : `.${a.fileExtension}`,
120
+ isLaunchAsset: a.isLaunchAsset,
121
+ sizeBytes: a.sizeBytes,
122
+ }));
123
+
124
+ const manifest = buildManifestJson(
125
+ updateId, params.runtimeVersion, createdAt, assetRecords,
126
+ params.expoConfig, options.baseUrl, basePath,
127
+ );
128
+ const manifestBuffer = Buffer.from(JSON.stringify(manifest), 'utf8');
129
+
130
+ // Versioned manifest
131
+ await options.storage.put(
132
+ `${params.storagePrefix}/manifest.json`,
133
+ manifestBuffer,
134
+ 'application/json',
135
+ );
136
+
137
+ // Channel pointer
138
+ await options.storage.put(
139
+ `${channelName}/${params.platform}/${params.runtimeVersion}/manifest.json`,
140
+ manifestBuffer,
141
+ 'application/json',
142
+ );
143
+
144
+ // Channel metadata
145
+ await ensureChannelInStorage(options.storage, channelName, branchName);
146
+
147
+ // Group meta (merge platforms across multi-platform publishes)
148
+ const groupMetaKey = `releases/${branchName}/${params.runtimeVersion}/${groupId}/meta.json`;
149
+ let platforms: string[] = [params.platform];
150
+ try {
151
+ const existingMeta = await options.storage.get(groupMetaKey);
152
+ if (existingMeta) {
153
+ const parsed = JSON.parse(existingMeta.data.toString('utf8'));
154
+ if (Array.isArray(parsed.platforms)) {
155
+ platforms = [...new Set([...parsed.platforms, params.platform])];
156
+ }
157
+ }
158
+ } catch { /* first platform for this group */ }
159
+
160
+ await options.storage.put(
161
+ groupMetaKey,
162
+ Buffer.from(JSON.stringify({
163
+ message: params.message || null,
164
+ branchRef: branchName,
165
+ platforms,
166
+ createdAt,
167
+ })),
168
+ 'application/json',
169
+ );
170
+
171
+ // DB mirror — non-blocking, no-op without db
172
+ let releaseId: string | undefined;
173
+ await mirrorToDb(options.db, async () => {
174
+ let [channel] = await options.db
175
+ .select()
176
+ .from(opsItems)
177
+ .where(and(
178
+ eq(opsItems.type, 'release_channel'),
179
+ eq(opsItems.name, channelName),
180
+ ))
181
+ .limit(1);
182
+
183
+ if (!channel) {
184
+ const [newChannel] = await options.db
185
+ .insert(opsItems)
186
+ .values({
187
+ type: 'release_channel',
188
+ name: channelName,
189
+ status: 'active',
190
+ data: { branchRef: branchName },
191
+ })
192
+ .returning();
193
+ channel = newChannel;
194
+ }
195
+
196
+ const channelData = (channel.data as Record<string, unknown>) || {};
197
+ if (!channelData.branchRef) {
198
+ await options.db
199
+ .update(opsItems)
200
+ .set({
201
+ data: { ...channelData, branchRef: branchName },
202
+ updatedAt: new Date(),
203
+ })
204
+ .where(eq(opsItems.id, channel.id))
205
+ .returning();
206
+ }
207
+
208
+ if (!callerProvidedGroup) {
209
+ await options.db
210
+ .insert(runs)
211
+ .values({
212
+ id: groupId,
213
+ type: 'release',
214
+ name: params.message || null,
215
+ status: 'completed',
216
+ queuedAt: new Date(),
217
+ completedAt: new Date(),
218
+ data: {
219
+ branchRef: branchName,
220
+ message: params.message || null,
221
+ },
222
+ })
223
+ .returning();
224
+ }
225
+
226
+ const [release] = await options.db
227
+ .insert(buildArtifacts)
228
+ .values({
229
+ type: 'ota',
230
+ runId: groupId,
231
+ runtimeVersion: params.runtimeVersion,
232
+ platform: params.platform,
233
+ s3Key: params.storagePrefix,
234
+ data: {
235
+ updateId,
236
+ expoConfig: params.expoConfig || null,
237
+ metadata: params.metadata,
238
+ isRollback: false,
239
+ },
240
+ })
241
+ .returning();
242
+ releaseId = release.id;
243
+
244
+ for (const a of assetRecords) {
245
+ await options.db
246
+ .insert(buildArtifacts)
247
+ .values({
248
+ type: 'ota_asset',
249
+ runId: groupId,
250
+ s3Key: a.s3Key,
251
+ contentHash: a.contentHash,
252
+ filename: a.filename,
253
+ mimeType: a.mimeType,
254
+ sizeBytes: a.sizeBytes,
255
+ data: {
256
+ fileExtension: a.fileExtension,
257
+ isLaunchAsset: a.isLaunchAsset,
258
+ },
259
+ })
260
+ .returning();
261
+ }
262
+ });
263
+
264
+ return {
265
+ releaseId: releaseId || groupId,
266
+ updateId,
267
+ channel: channelName,
268
+ branch: branchName,
269
+ groupId,
270
+ platform: params.platform,
271
+ assetCount: assetRecords.length,
272
+ };
273
+ }
274
+
275
+ export async function handlePublish(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
276
+ // Auth check
277
+ const authResult = await checkAuth(request, options);
278
+ if (authResult) return authResult;
279
+
280
+ // Parse body
281
+ let body: PublishBody;
282
+ try {
283
+ body = await request.json() as PublishBody;
284
+ } catch {
285
+ return jsonError(400, 'Invalid JSON body.');
286
+ }
287
+
288
+ // Validate required fields
289
+ if (!body.runtimeVersion) {
290
+ return jsonError(400, 'Missing runtimeVersion.');
291
+ }
292
+ if (!body.platform || !['ios', 'android', 'web'].includes(body.platform)) {
293
+ return jsonError(400, 'Invalid platform. Expected ios, android, or web.');
294
+ }
295
+ if (!body.metadata) {
296
+ return jsonError(400, 'Missing metadata.');
297
+ }
298
+ if (!body.assets || !Array.isArray(body.assets) || body.assets.length === 0) {
299
+ return jsonError(400, 'Missing or empty assets.');
300
+ }
301
+
302
+ const channelName = body.channel || options.defaultChannel || 'production';
303
+ const branchName = body.branch || channelName;
304
+ const groupId = body.groupId || crypto.randomUUID();
305
+ const storagePrefix = `releases/${branchName}/${body.runtimeVersion}/${groupId}/${body.platform}`;
306
+
307
+ // Upload assets to storage, building post-upload records
308
+ const uploadedAssets: RegisterMobileAsset[] = [];
309
+ for (const asset of body.assets) {
310
+ const data = Buffer.from(asset.data, 'base64');
311
+ const s3Key = `${storagePrefix}/${asset.path}`;
312
+ await options.storage.put(s3Key, data, asset.contentType);
313
+ uploadedAssets.push({
314
+ path: asset.path,
315
+ s3Key,
316
+ contentHash: sha256Base64Url(data),
317
+ filename: md5Hex(data),
318
+ mimeType: asset.contentType,
319
+ fileExtension: asset.fileExtension,
320
+ isLaunchAsset: asset.isLaunchAsset ?? false,
321
+ sizeBytes: data.length,
322
+ });
323
+ }
324
+
325
+ try {
326
+ const result = await registerMobileRelease(options, {
327
+ channel: body.channel,
328
+ branch: body.branch,
329
+ groupId,
330
+ groupAlreadyExists: !!body.groupId,
331
+ runtimeVersion: body.runtimeVersion,
332
+ platform: body.platform as 'ios' | 'android',
333
+ message: body.message,
334
+ expoConfig: body.expoConfig,
335
+ metadata: body.metadata,
336
+ storagePrefix,
337
+ assets: uploadedAssets,
338
+ });
339
+ return new Response(JSON.stringify(result), {
340
+ status: 201,
341
+ headers: { 'content-type': 'application/json' },
342
+ });
343
+ } catch (err: any) {
344
+ return jsonError(404, err?.message || 'Registration failed');
345
+ }
346
+ }
@@ -0,0 +1,29 @@
1
+ import crypto from 'crypto';
2
+
3
+ export function signRSASHA256(data: string, privateKey: string): string {
4
+ const sign = crypto.createSign('RSA-SHA256');
5
+ sign.update(data, 'utf8');
6
+ sign.end();
7
+ return sign.sign(privateKey, 'base64');
8
+ }
9
+
10
+ export function convertSHA256HashToUUID(hash: string): string {
11
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
12
+ }
13
+
14
+ export function convertToDictionaryItemsRepresentation(obj: Record<string, string>): any {
15
+ return new Map(Object.entries(obj).map(([k, v]) => [k, [v, new Map()]]));
16
+ }
17
+
18
+ export function sha256(data: string | Buffer): string {
19
+ return crypto.createHash('sha256').update(data).digest('hex');
20
+ }
21
+
22
+ export function sha256Base64Url(data: string | Buffer): string {
23
+ const base64 = crypto.createHash('sha256').update(data).digest('base64');
24
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
25
+ }
26
+
27
+ export function md5Hex(data: string | Buffer): string {
28
+ return crypto.createHash('md5').update(data).digest('hex');
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { StorageAdapter } from '../storage/index';
2
+
3
+ export interface UpdatesHandlerOptions {
4
+ /** Drizzle DB instance. Optional — without it, only S3 flat-file manifests work. */
5
+ db?: any;
6
+ storage: StorageAdapter;
7
+ baseUrl: string;
8
+ basePath?: string;
9
+ auth?: {
10
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
11
+ };
12
+ privateKey?: string;
13
+ defaultChannel?: string;
14
+ /** Storage adapter for client bundles (JS/CSS/images served via CloudFront). */
15
+ clientBundlesStorage?: StorageAdapter;
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createUpdatesHandler } from './handler/index';
2
+ export { createStorage } from './storage/index';
3
+ export type { StorageAdapter, StorageOptions } from './storage/index';
4
+ export type { UpdatesHandlerOptions } from './handler/types';