@everystack/cli 0.2.23 → 0.2.25

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.23",
3
+ "version": "0.2.25",
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
@@ -31,6 +31,7 @@ export async function uploadToS3(
31
31
  key: string,
32
32
  body: Buffer | Uint8Array,
33
33
  contentType: string,
34
+ cacheControl?: string,
34
35
  ): Promise<void> {
35
36
  const client = await getS3(region);
36
37
  const { PutObjectCommand } = await import('@aws-sdk/client-s3');
@@ -39,6 +40,7 @@ export async function uploadToS3(
39
40
  Key: key,
40
41
  Body: body,
41
42
  ContentType: contentType,
43
+ ...(cacheControl && { CacheControl: cacheControl }),
42
44
  }));
43
45
  }
44
46
 
@@ -410,6 +410,71 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
410
410
  }
411
411
  }
412
412
 
413
+ // 3.5. Sync media assets to the media bucket (email logos, OG images, etc.)
414
+ const assetDirs: Array<{ from: string; to: string; include?: string }> | undefined =
415
+ appConfig.extra?.everystack?.assets;
416
+ if (assetDirs?.length) {
417
+ if (!config.mediaBucket) {
418
+ warn('everystack.assets configured but no mediaBucket found.');
419
+ info('Add `mediaBucket: media.name` to the return block in sst.config.ts and redeploy.');
420
+ } else {
421
+ step('Syncing media assets...');
422
+ // @ts-ignore — minimatch lacks type declarations in this project
423
+ const minimatchMod = await import('minimatch');
424
+ const minimatch: (p: string, pattern: string, opts?: { matchBase?: boolean }) => boolean =
425
+ minimatchMod.minimatch || minimatchMod.default || minimatchMod;
426
+ const MEDIA_CONCURRENCY = 10;
427
+ let mediaUploaded = 0;
428
+ let mediaSkipped = 0;
429
+
430
+ for (const { from: fromDir, to: toPrefix, include } of assetDirs) {
431
+ const absFrom = path.resolve(fromDir);
432
+ let files: Awaited<ReturnType<typeof walkDirectory>>;
433
+ try {
434
+ files = await walkDirectory(absFrom);
435
+ } catch {
436
+ warn(`Media asset directory not found: ${fromDir}`);
437
+ continue;
438
+ }
439
+
440
+ // Filter by include glob when specified
441
+ if (include) {
442
+ files = files.filter(f => minimatch(f.relativePath, include, { matchBase: true }));
443
+ }
444
+
445
+ for (let i = 0; i < files.length; i += MEDIA_CONCURRENCY) {
446
+ const batch = files.slice(i, i + MEDIA_CONCURRENCY);
447
+ await Promise.all(batch.map(async (file) => {
448
+ const s3Key = `${toPrefix}${file.relativePath}`;
449
+ const data = Buffer.from(file.data, 'base64');
450
+
451
+ // Compare MD5 against S3 ETag for diffing
452
+ const md5 = `"${crypto.createHash('md5').update(data).digest('hex')}"`;
453
+ const existing = await headS3(config.region, config.mediaBucket!, s3Key);
454
+ if (existing?.etag === md5) {
455
+ mediaSkipped++;
456
+ return;
457
+ }
458
+
459
+ await uploadToS3(
460
+ config.region,
461
+ config.mediaBucket!,
462
+ s3Key,
463
+ data,
464
+ file.contentType,
465
+ 'public, max-age=31536000, immutable',
466
+ );
467
+ mediaUploaded++;
468
+ }));
469
+ }
470
+ }
471
+
472
+ if (mediaUploaded > 0 || mediaSkipped > 0) {
473
+ success(`Media: ${mediaUploaded} new, ${mediaSkipped} unchanged (skipped)`);
474
+ }
475
+ }
476
+ }
477
+
413
478
  // 4. Write release manifests directly to S3 (source of truth for SSR).
414
479
  step('Writing release manifests...');
415
480
  const updateId = crypto.createHash('sha256')
package/src/cli/config.ts CHANGED
@@ -19,6 +19,7 @@ export interface CliConfig {
19
19
  workerFunctionName?: string;
20
20
  updatesBucket: string;
21
21
  clientBundlesBucket: string;
22
+ mediaBucket?: string;
22
23
  kvsArn?: string;
23
24
  distributionId?: string;
24
25
  }
@@ -32,6 +33,7 @@ interface SstOutputs {
32
33
  workerFunctionName?: string;
33
34
  updatesBucket?: string;
34
35
  clientBundlesBucket?: string;
36
+ mediaBucket?: string;
35
37
  kvsArn?: string;
36
38
  distributionId?: string;
37
39
  }
@@ -101,6 +103,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
101
103
  workerFunctionName: outputs.workerFunctionName,
102
104
  updatesBucket: outputs.updatesBucket,
103
105
  clientBundlesBucket: outputs.clientBundlesBucket,
106
+ mediaBucket: outputs.mediaBucket,
104
107
  kvsArn: outputs.kvsArn,
105
108
  distributionId: outputs.distributionId,
106
109
  };
@@ -23,6 +23,7 @@ interface DiscoveredConfig {
23
23
  workerFunctionName?: string;
24
24
  updatesBucket: string;
25
25
  clientBundlesBucket: string;
26
+ mediaBucket?: string;
26
27
  kvsArn?: string;
27
28
  distributionId?: string;
28
29
  }
@@ -207,6 +208,7 @@ export async function discoverLambda(
207
208
  interface BucketsResult {
208
209
  updatesBucket?: string;
209
210
  clientBundlesBucket?: string;
211
+ mediaBucket?: string;
210
212
  }
211
213
 
212
214
  /**
@@ -224,19 +226,24 @@ export async function discoverBuckets(
224
226
 
225
227
  const updatesNeedle = `${prefix}updatesbucket-`.toLowerCase();
226
228
  const clientNeedle = `${prefix}clientbundlesbucket-`.toLowerCase();
229
+ // Media bucket resource is named 'Media' (not 'MediaBucket'), so match both patterns
230
+ const mediaNeedle = `${prefix}media-`.toLowerCase();
231
+ const mediaBucketNeedle = `${prefix}mediabucket-`.toLowerCase();
227
232
 
228
233
  let updatesBucket: string | undefined;
229
234
  let clientBundlesBucket: string | undefined;
235
+ let mediaBucket: string | undefined;
230
236
 
231
237
  for (const b of buckets) {
232
238
  if (!b.Name) continue;
233
239
  const lower = b.Name.toLowerCase();
234
240
  if (lower.startsWith(updatesNeedle)) updatesBucket = b.Name;
235
241
  if (lower.startsWith(clientNeedle)) clientBundlesBucket = b.Name;
236
- if (updatesBucket && clientBundlesBucket) break;
242
+ if (!mediaBucket && (lower.startsWith(mediaNeedle) || lower.startsWith(mediaBucketNeedle))) mediaBucket = b.Name;
243
+ if (updatesBucket && clientBundlesBucket && mediaBucket) break;
237
244
  }
238
245
 
239
- return { updatesBucket, clientBundlesBucket };
246
+ return { updatesBucket, clientBundlesBucket, mediaBucket };
240
247
  }
241
248
 
242
249
  // ---------------------------------------------------------------------------
@@ -295,6 +302,7 @@ export async function discoverConfig(
295
302
  workerFunctionName: workerFn,
296
303
  updatesBucket: bucketsResult.updatesBucket,
297
304
  clientBundlesBucket: bucketsResult.clientBundlesBucket,
305
+ mediaBucket: bucketsResult.mediaBucket,
298
306
  kvsArn: cfResult.kvsArn,
299
307
  distributionId,
300
308
  };
@@ -314,6 +322,7 @@ interface CachedOutputs {
314
322
  workerFunctionName?: string;
315
323
  updatesBucket?: string;
316
324
  clientBundlesBucket?: string;
325
+ mediaBucket?: string;
317
326
  kvsArn?: string;
318
327
  distributionId?: string;
319
328
  }
@@ -338,6 +347,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
338
347
  workerFunctionName: cached.workerFunctionName,
339
348
  updatesBucket: cached.updatesBucket,
340
349
  clientBundlesBucket: cached.clientBundlesBucket,
350
+ mediaBucket: cached.mediaBucket,
341
351
  kvsArn: cached.kvsArn,
342
352
  distributionId: cached.distributionId,
343
353
  };
@@ -355,6 +365,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
355
365
  workerFunctionName: config.workerFunctionName,
356
366
  updatesBucket: config.updatesBucket,
357
367
  clientBundlesBucket: config.clientBundlesBucket,
368
+ mediaBucket: config.mediaBucket,
358
369
  kvsArn: config.kvsArn,
359
370
  distributionId: config.distributionId,
360
371
  };
@@ -20,10 +20,14 @@ const MIME_MAP: Record<string, string> = {
20
20
  '.gif': 'image/gif',
21
21
  '.svg': 'image/svg+xml',
22
22
  '.webp': 'image/webp',
23
+ '.avif': 'image/avif',
24
+ '.ico': 'image/x-icon',
23
25
  '.ttf': 'font/ttf',
24
26
  '.otf': 'font/otf',
25
27
  '.woff': 'font/woff',
26
28
  '.woff2': 'font/woff2',
29
+ '.mp4': 'video/mp4',
30
+ '.pdf': 'application/pdf',
27
31
  '.map': 'application/json',
28
32
  };
29
33