@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.
- package/README.md +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- 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