@everystack/cli 0.2.22 → 0.2.24

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.22",
3
+ "version": "0.2.24",
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,22 @@ export async function discoverBuckets(
224
226
 
225
227
  const updatesNeedle = `${prefix}updatesbucket-`.toLowerCase();
226
228
  const clientNeedle = `${prefix}clientbundlesbucket-`.toLowerCase();
229
+ const mediaNeedle = `${prefix}mediabucket-`.toLowerCase();
227
230
 
228
231
  let updatesBucket: string | undefined;
229
232
  let clientBundlesBucket: string | undefined;
233
+ let mediaBucket: string | undefined;
230
234
 
231
235
  for (const b of buckets) {
232
236
  if (!b.Name) continue;
233
237
  const lower = b.Name.toLowerCase();
234
238
  if (lower.startsWith(updatesNeedle)) updatesBucket = b.Name;
235
239
  if (lower.startsWith(clientNeedle)) clientBundlesBucket = b.Name;
236
- if (updatesBucket && clientBundlesBucket) break;
240
+ if (lower.startsWith(mediaNeedle)) mediaBucket = b.Name;
241
+ if (updatesBucket && clientBundlesBucket && mediaBucket) break;
237
242
  }
238
243
 
239
- return { updatesBucket, clientBundlesBucket };
244
+ return { updatesBucket, clientBundlesBucket, mediaBucket };
240
245
  }
241
246
 
242
247
  // ---------------------------------------------------------------------------
@@ -295,6 +300,7 @@ export async function discoverConfig(
295
300
  workerFunctionName: workerFn,
296
301
  updatesBucket: bucketsResult.updatesBucket,
297
302
  clientBundlesBucket: bucketsResult.clientBundlesBucket,
303
+ mediaBucket: bucketsResult.mediaBucket,
298
304
  kvsArn: cfResult.kvsArn,
299
305
  distributionId,
300
306
  };
@@ -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
 
package/src/env.ts CHANGED
@@ -37,6 +37,8 @@ export interface EnvConfig {
37
37
  mobile?: string[];
38
38
  /** Safe for all client bundles. Gets EXPO_PUBLIC_* for web delivery. */
39
39
  web?: string[];
40
+ /** SST secret declarations (infrastructure). Falls back to server tier if omitted. */
41
+ secrets?: string[];
40
42
  }
41
43
 
42
44
  /** Build surface — determines which tiers pass through the filter. */
package/src/sst.ts CHANGED
@@ -1,15 +1,85 @@
1
1
  /**
2
- * @everystack/cli/sst — Shared env config for SST infrastructure.
2
+ * @everystack/cli/sst — SST infrastructure helpers.
3
3
  *
4
4
  * Reads the same env.config.js that @everystack/cli/env uses,
5
5
  * ensuring sst.config.ts and app.config.js stay in sync.
6
6
  *
7
7
  * Usage in sst.config.ts:
8
- * import { loadEnvConfig } from '@everystack/cli/sst';
9
- * const env = loadEnvConfig();
10
- * // env.server = ['DATABASE_URL', 'JWT_SECRET']
8
+ * const { createSecrets } = await import('@everystack/cli/sst');
9
+ * const { secrets, link, environment } = createSecrets(sst.Secret);
11
10
  *
12
11
  * @module
13
12
  */
14
13
 
15
- export { loadEnvConfig, type EnvConfig } from './env.js';
14
+ import { loadEnvConfig, type EnvConfig } from './env.js';
15
+
16
+ export { loadEnvConfig, type EnvConfig };
17
+
18
+ /**
19
+ * Minimal interface matching the subset of sst.Secret we use.
20
+ *
21
+ * The real sst.Secret is a global available only inside sst.config.ts's
22
+ * run() function. We accept it as a constructor parameter to avoid
23
+ * coupling this package to SST internals.
24
+ */
25
+ interface SecretInstance {
26
+ value: unknown;
27
+ }
28
+
29
+ /** Return type of createSecrets. */
30
+ export interface CreateSecretsResult {
31
+ /** All secrets keyed by env var name for selective access. */
32
+ secrets: Record<string, SecretInstance>;
33
+ /** Flat array of all secret objects for Lambda `link`. */
34
+ link: SecretInstance[];
35
+ /** { KEY: secret.value } map for Lambda `environment`. */
36
+ environment: Record<string, unknown>;
37
+ }
38
+
39
+ /**
40
+ * Create SST Secret objects from env.config.js.
41
+ *
42
+ * Reads the `secrets` field from env.config.js (falls back to `server`
43
+ * tier if omitted). Creates one SST Secret per declared key name.
44
+ *
45
+ * @param SecretConstructor - The sst.Secret class, passed from run()
46
+ * @param configPath - Optional path to env.config.js (auto-discovers if omitted)
47
+ *
48
+ * @example
49
+ * const { createSecrets } = await import('@everystack/cli/sst');
50
+ * const { secrets, link, environment } = createSecrets(sst.Secret);
51
+ *
52
+ * // API Lambda — all secrets
53
+ * new sst.aws.Function('Api', {
54
+ * link: [...link, database, media],
55
+ * environment: { ENVIRONMENT: $app.stage, ...environment },
56
+ * });
57
+ *
58
+ * // Worker Lambda — selective
59
+ * new sst.aws.Function('Worker', {
60
+ * link: [secrets.DATABASE_URL, secrets.JWT_SECRET],
61
+ * });
62
+ *
63
+ * // CloudFront edge auth
64
+ * secrets.JWT_SECRET.value.apply((secret) => authFeature({ secret }));
65
+ */
66
+ export function createSecrets(
67
+ SecretConstructor: new (name: string) => SecretInstance,
68
+ configPath?: string,
69
+ ): CreateSecretsResult {
70
+ const config = loadEnvConfig(configPath);
71
+ const keys = [...new Set(config.secrets ?? config.server ?? [])];
72
+
73
+ const secrets: Record<string, SecretInstance> = {};
74
+ const link: SecretInstance[] = [];
75
+ const environment: Record<string, unknown> = {};
76
+
77
+ for (const key of keys) {
78
+ const secret = new SecretConstructor(key);
79
+ secrets[key] = secret;
80
+ link.push(secret);
81
+ environment[key] = secret.value;
82
+ }
83
+
84
+ return { secrets, link, environment };
85
+ }