@everystack/cli 0.2.17 → 0.2.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/cli/aws.ts CHANGED
@@ -42,6 +42,35 @@ export async function uploadToS3(
42
42
  }));
43
43
  }
44
44
 
45
+ export async function headS3(
46
+ region: string,
47
+ bucket: string,
48
+ key: string,
49
+ ): Promise<{ etag: string; size?: number } | null> {
50
+ const client = await getS3(region);
51
+ const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
52
+ try {
53
+ const response = await client.send(new HeadObjectCommand({
54
+ Bucket: bucket,
55
+ Key: key,
56
+ }));
57
+ return {
58
+ etag: response.ETag || '',
59
+ size: response.ContentLength,
60
+ };
61
+ } catch (err: unknown) {
62
+ if (err && typeof err === 'object' && 'name' in err) {
63
+ const name = (err as { name: string }).name;
64
+ if (name === 'NotFound' || name === '404') return null;
65
+ }
66
+ if (err && typeof err === 'object' && '$metadata' in err) {
67
+ const meta = (err as { $metadata: { httpStatusCode?: number } }).$metadata;
68
+ if (meta?.httpStatusCode === 404) return null;
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
45
74
  export async function getFromS3(
46
75
  region: string,
47
76
  bucket: string,
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { resolveConfig, type CliConfig } from '../config.js';
9
- import { uploadToS3, invokeAction } from '../aws.js';
9
+ import { uploadToS3, headS3, invokeAction } from '../aws.js';
10
10
  import { step, success, warn, fail, info } from '../output.js';
11
11
  import { exportApp } from '../utils/export.js';
12
12
  import { walkDirectory } from '../utils/walk.js';
@@ -186,7 +186,10 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
186
186
  const branchName = branch || channel;
187
187
  const storagePrefix = `releases/${branchName}/${runtimeVersion}/${groupId}/${platform}`;
188
188
 
189
- // Upload each asset directly to S3 with content hashing
189
+ // Upload each asset directly to S3 with content hashing.
190
+ // Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
191
+ // so identical files across releases are stored once. Launch assets (JS bundles)
192
+ // remain per-release for rollback isolation.
190
193
  const uploaded: Array<{
191
194
  path: string;
192
195
  s3Key: string;
@@ -199,12 +202,42 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
199
202
  }> = [];
200
203
 
201
204
  const UPLOAD_CONCURRENCY = 6;
205
+ let newAssets = 0;
206
+ let skippedAssets = 0;
202
207
  for (let i = 0; i < assets.length; i += UPLOAD_CONCURRENCY) {
203
208
  const batch = assets.slice(i, i + UPLOAD_CONCURRENCY);
204
209
  try {
205
210
  await Promise.all(batch.map(async (asset) => {
206
211
  const data = Buffer.from(asset.data, 'base64');
207
- const s3Key = `${storagePrefix}/${asset.path}`;
212
+ const sha256 = crypto.createHash('sha256').update(data).digest('base64')
213
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
214
+ const md5 = crypto.createHash('md5').update(data).digest('hex');
215
+
216
+ // Launch assets → per-release path (changes every build, needs rollback isolation)
217
+ // Non-launch assets → shared content-addressed path (fonts, images — rarely change)
218
+ const s3Key = asset.isLaunchAsset
219
+ ? `${storagePrefix}/${asset.path}`
220
+ : `assets/${sha256}${asset.fileExtension}`;
221
+
222
+ // Skip upload if shared asset already exists
223
+ if (!asset.isLaunchAsset) {
224
+ const existing = await headS3(config.region, config.updatesBucket, s3Key);
225
+ if (existing) {
226
+ skippedAssets++;
227
+ uploaded.push({
228
+ path: asset.path,
229
+ s3Key,
230
+ contentHash: sha256,
231
+ filename: md5,
232
+ mimeType: asset.contentType,
233
+ fileExtension: asset.fileExtension,
234
+ isLaunchAsset: asset.isLaunchAsset,
235
+ sizeBytes: data.length,
236
+ });
237
+ return;
238
+ }
239
+ }
240
+
208
241
  await uploadToS3(
209
242
  config.region,
210
243
  config.updatesBucket,
@@ -212,9 +245,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
212
245
  data,
213
246
  asset.contentType,
214
247
  );
215
- const sha256 = crypto.createHash('sha256').update(data).digest('base64')
216
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
217
- const md5 = crypto.createHash('md5').update(data).digest('hex');
248
+ newAssets++;
218
249
  uploaded.push({
219
250
  path: asset.path,
220
251
  s3Key,
@@ -258,7 +289,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
258
289
  process.exit(1);
259
290
  }
260
291
  if (result?.groupId && !groupId) groupId = result.groupId;
261
- success(`${platform}: ${uploaded.length} assets`);
292
+ if (skippedAssets > 0) {
293
+ success(`${platform}: ${newAssets} new, ${skippedAssets} unchanged (skipped)`);
294
+ } else {
295
+ success(`${platform}: ${uploaded.length} assets`);
296
+ }
262
297
  } catch (err: any) {
263
298
  fail(`Registration failed for ${platform}: ${err.message}`);
264
299
  info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
@@ -345,11 +380,18 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
345
380
  step(`Uploading ${clientAssets.length} client assets...`);
346
381
  const UPLOAD_CONCURRENCY = 10;
347
382
  let uploaded = 0;
383
+ let skipped = 0;
348
384
 
349
385
  for (let i = 0; i < clientAssets.length; i += UPLOAD_CONCURRENCY) {
350
386
  const batch = clientAssets.slice(i, i + UPLOAD_CONCURRENCY);
351
387
  try {
352
388
  await Promise.all(batch.map(async (asset) => {
389
+ // Skip upload if asset already exists (content-hash filenames = identical content)
390
+ const existing = await headS3(config.region, config.clientBundlesBucket, asset.relativePath);
391
+ if (existing) {
392
+ skipped++;
393
+ return;
394
+ }
353
395
  const data = Buffer.from(asset.data, 'base64');
354
396
  await uploadToS3(
355
397
  config.region,
@@ -367,7 +409,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
367
409
  process.exit(1);
368
410
  }
369
411
  }
370
- success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
412
+ if (skipped > 0) {
413
+ success(`${uploaded} new, ${skipped} unchanged (skipped)`);
414
+ } else {
415
+ success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
416
+ }
371
417
  }
372
418
 
373
419
  // 4. Write release manifests directly to S3 (source of truth for SSR).
@@ -304,20 +304,40 @@ export async function handlePublish(request: Request, options: UpdatesHandlerOpt
304
304
  const groupId = body.groupId || crypto.randomUUID();
305
305
  const storagePrefix = `releases/${branchName}/${body.runtimeVersion}/${groupId}/${body.platform}`;
306
306
 
307
- // Upload assets to storage, building post-upload records
307
+ // Upload assets to storage, building post-upload records.
308
+ // Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
309
+ // so identical files across releases are stored once. Launch assets (JS bundles)
310
+ // remain per-release for rollback isolation.
308
311
  const uploadedAssets: RegisterMobileAsset[] = [];
309
312
  for (const asset of body.assets) {
310
313
  const data = Buffer.from(asset.data, 'base64');
311
- const s3Key = `${storagePrefix}/${asset.path}`;
312
- await options.storage.put(s3Key, data, asset.contentType);
314
+ const isLaunch = asset.isLaunchAsset ?? false;
315
+ const contentHash = sha256Base64Url(data);
316
+ const ext = asset.fileExtension.startsWith('.') ? asset.fileExtension : `.${asset.fileExtension}`;
317
+
318
+ // Launch assets → per-release path; non-launch → shared content-addressed path
319
+ const s3Key = isLaunch
320
+ ? `${storagePrefix}/${asset.path}`
321
+ : `assets/${contentHash}${ext}`;
322
+
323
+ // Skip upload if shared asset already exists
324
+ if (!isLaunch) {
325
+ const exists = await options.storage.exists(s3Key);
326
+ if (!exists) {
327
+ await options.storage.put(s3Key, data, asset.contentType);
328
+ }
329
+ } else {
330
+ await options.storage.put(s3Key, data, asset.contentType);
331
+ }
332
+
313
333
  uploadedAssets.push({
314
334
  path: asset.path,
315
335
  s3Key,
316
- contentHash: sha256Base64Url(data),
336
+ contentHash,
317
337
  filename: md5Hex(data),
318
338
  mimeType: asset.contentType,
319
339
  fileExtension: asset.fileExtension,
320
- isLaunchAsset: asset.isLaunchAsset ?? false,
340
+ isLaunchAsset: isLaunch,
321
341
  sizeBytes: data.length,
322
342
  });
323
343
  }