@everystack/cli 0.1.0 → 0.2.1

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,8 +1,8 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
- "license": "MIT",
5
+ "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -10,7 +10,6 @@
10
10
  "src",
11
11
  "README.md"
12
12
  ],
13
- "type": "module",
14
13
  "exports": {
15
14
  ".": {
16
15
  "types": "./src/index.ts",
@@ -27,30 +26,44 @@
27
26
  "./handler": {
28
27
  "types": "./src/handler/index.ts",
29
28
  "default": "./src/handler/index.ts"
29
+ },
30
+ "./plugin": {
31
+ "types": "./src/plugin.ts",
32
+ "default": "./src/plugin.ts"
30
33
  }
31
34
  },
32
35
  "bin": {
33
36
  "everystack": "./src/cli/index.ts"
34
37
  },
38
+ "scripts": {
39
+ "test": "jest",
40
+ "build": "tsc --build",
41
+ "lint": "tsc --noEmit"
42
+ },
35
43
  "dependencies": {
44
+ "@aws-sdk/client-cloudfront-keyvaluestore": "3.1045.0",
45
+ "@aws-sdk/signature-v4a": "3.1031.0",
36
46
  "glob": "13.0.6",
37
- "node-forge": "^1.4.0",
38
- "structured-headers": "^1.0.0",
39
- "tsx": "^4.0.0"
47
+ "node-forge": "1.4.0",
48
+ "structured-headers": "1.0.1",
49
+ "tsx": "4.21.0"
40
50
  },
41
51
  "peerDependencies": {
42
- "@aws-sdk/client-cloudfront": ">=3.0.0",
43
- "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.0.0",
44
- "@aws-sdk/client-cloudwatch-logs": ">=3.0.0",
45
- "@aws-sdk/client-lambda": ">=3.0.0",
46
- "@aws-sdk/client-s3": ">=3.0.0",
47
- "@aws-sdk/signature-v4a": ">=3.0.0",
48
- "drizzle-orm": ">=0.30.0",
49
- "expo-updates": ">=0.25.0",
50
- "react": ">=18.0.0",
51
- "react-native": ">=0.72.0"
52
+ "@everystack/server": ">=0.1.0",
53
+ "@aws-sdk/client-cloudfront": "3.1045.0",
54
+ "@aws-sdk/client-cloudwatch-logs": "3.1047.0",
55
+ "@aws-sdk/client-lambda": "3.1045.0",
56
+ "@aws-sdk/client-s3": "3.1045.0",
57
+ "@aws-sdk/s3-request-presigner": "3.1045.0",
58
+ "drizzle-orm": "0.41.0",
59
+ "expo-updates": "55.0.21",
60
+ "react": "19.2.6",
61
+ "react-native": "0.83.6"
52
62
  },
53
63
  "peerDependenciesMeta": {
64
+ "@everystack/server": {
65
+ "optional": true
66
+ },
54
67
  "@aws-sdk/client-cloudfront": {
55
68
  "optional": true
56
69
  },
@@ -63,12 +76,6 @@
63
76
  "@aws-sdk/client-lambda": {
64
77
  "optional": true
65
78
  },
66
- "@aws-sdk/client-cloudfront-keyvaluestore": {
67
- "optional": true
68
- },
69
- "@aws-sdk/signature-v4a": {
70
- "optional": true
71
- },
72
79
  "expo-updates": {
73
80
  "optional": true
74
81
  },
@@ -77,28 +84,25 @@
77
84
  },
78
85
  "react-native": {
79
86
  "optional": true
87
+ },
88
+ "@aws-sdk/s3-request-presigner": {
89
+ "optional": true
80
90
  }
81
91
  },
82
92
  "devDependencies": {
83
- "@aws-sdk/client-cloudfront": "^3.700.0",
84
- "@aws-sdk/client-cloudfront-keyvaluestore": "^3.700.0",
85
- "@aws-sdk/client-cloudwatch-logs": "^3.1047.0",
86
- "@aws-sdk/client-lambda": "^3.700.0",
87
- "@aws-sdk/client-s3": "^3.700.0",
88
- "@aws-sdk/signature-v4a": "^3.1031.0",
89
- "@types/jest": "^29.5.14",
90
- "@types/node": "^22.0.0",
91
- "@types/node-forge": "^1.3.14",
92
- "@types/react": "~19.2.14",
93
- "drizzle-orm": "^0.41.0",
94
- "jest": "^29.7.0",
95
- "react": "19.2.0",
96
- "ts-jest": "^29.3.0",
97
- "typescript": "^5.7.0"
98
- },
99
- "scripts": {
100
- "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
101
- "build": "tsc --build",
102
- "lint": "tsc --noEmit"
93
+ "@aws-sdk/client-cloudfront": "3.1045.0",
94
+ "@aws-sdk/client-cloudwatch-logs": "3.1047.0",
95
+ "@aws-sdk/client-lambda": "3.1045.0",
96
+ "@aws-sdk/client-s3": "3.1045.0",
97
+ "@aws-sdk/s3-request-presigner": "3.1045.0",
98
+ "@types/jest": "29.5.14",
99
+ "@types/node": "22.19.18",
100
+ "@types/node-forge": "1.3.14",
101
+ "@types/react": "19.2.14",
102
+ "drizzle-orm": "0.41.0",
103
+ "jest": "29.7.0",
104
+ "react": "19.2.6",
105
+ "ts-jest": "29.4.9",
106
+ "typescript": "5.9.3"
103
107
  }
104
- }
108
+ }
package/src/cli/aws.ts CHANGED
@@ -5,20 +5,23 @@
5
5
  * Authentication uses the developer's IAM credentials (default credential chain).
6
6
  */
7
7
 
8
- import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
9
- import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
8
+ let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
9
+ let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
10
+ let kvsClient: InstanceType<typeof import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient> | null = null;
10
11
 
11
- let s3Client: S3Client | null = null;
12
- let lambdaClient: LambdaClient | null = null;
13
- let kvsClient: import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient | null = null;
14
-
15
- function getS3(region: string): S3Client {
16
- if (!s3Client) s3Client = new S3Client({ region });
12
+ async function getS3(region: string) {
13
+ if (!s3Client) {
14
+ const { S3Client } = await import('@aws-sdk/client-s3');
15
+ s3Client = new S3Client({ region });
16
+ }
17
17
  return s3Client;
18
18
  }
19
19
 
20
- function getLambda(region: string): LambdaClient {
21
- if (!lambdaClient) lambdaClient = new LambdaClient({ region });
20
+ async function getLambda(region: string) {
21
+ if (!lambdaClient) {
22
+ const { LambdaClient } = await import('@aws-sdk/client-lambda');
23
+ lambdaClient = new LambdaClient({ region });
24
+ }
22
25
  return lambdaClient;
23
26
  }
24
27
 
@@ -29,7 +32,8 @@ export async function uploadToS3(
29
32
  body: Buffer | Uint8Array,
30
33
  contentType: string,
31
34
  ): Promise<void> {
32
- const client = getS3(region);
35
+ const client = await getS3(region);
36
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3');
33
37
  await client.send(new PutObjectCommand({
34
38
  Bucket: bucket,
35
39
  Key: key,
@@ -43,7 +47,8 @@ export async function getFromS3(
43
47
  bucket: string,
44
48
  key: string,
45
49
  ): Promise<Buffer | null> {
46
- const client = getS3(region);
50
+ const client = await getS3(region);
51
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
47
52
  try {
48
53
  const response = await client.send(new GetObjectCommand({
49
54
  Bucket: bucket,
@@ -66,7 +71,8 @@ export async function invokeAction(
66
71
  action: string,
67
72
  payload: unknown,
68
73
  ): Promise<unknown> {
69
- const client = getLambda(region);
74
+ const client = await getLambda(region);
75
+ const { InvokeCommand } = await import('@aws-sdk/client-lambda');
70
76
  const response = await client.send(new InvokeCommand({
71
77
  FunctionName: functionName,
72
78
  Payload: new TextEncoder().encode(JSON.stringify({
@@ -86,6 +92,47 @@ export async function invokeAction(
86
92
  return JSON.parse(new TextDecoder().decode(response.Payload));
87
93
  }
88
94
 
95
+ /**
96
+ * Resolve the CloudWatch log group for a Lambda function.
97
+ * Queries GetFunctionConfiguration for LoggingConfig.LogGroup, falling back to
98
+ * the conventional `/aws/lambda/{functionName}` pattern if not set.
99
+ */
100
+ export async function resolveLogGroup(
101
+ region: string,
102
+ functionName: string,
103
+ ): Promise<string> {
104
+ const client = await getLambda(region);
105
+ const { GetFunctionConfigurationCommand } = await import('@aws-sdk/client-lambda');
106
+ const config = await client.send(
107
+ new GetFunctionConfigurationCommand({ FunctionName: functionName })
108
+ );
109
+ return config.LoggingConfig?.LogGroup ?? `/aws/lambda/${functionName}`;
110
+ }
111
+
112
+ /**
113
+ * Create a CloudFront cache invalidation.
114
+ * Used by cache:purge --invalidate to clear edge-cached responses (e.g. stale 404s).
115
+ */
116
+ export async function createInvalidation(
117
+ region: string,
118
+ distributionId: string,
119
+ paths: string[] = ['/*'],
120
+ ): Promise<string | undefined> {
121
+ const { CloudFrontClient, CreateInvalidationCommand } = await import('@aws-sdk/client-cloudfront');
122
+ const client = new CloudFrontClient({ region });
123
+ const result = await client.send(new CreateInvalidationCommand({
124
+ DistributionId: distributionId,
125
+ InvalidationBatch: {
126
+ CallerReference: `cache-purge-${Date.now()}`,
127
+ Paths: {
128
+ Quantity: paths.length,
129
+ Items: paths,
130
+ },
131
+ },
132
+ }));
133
+ return result.Invalidation?.Id;
134
+ }
135
+
89
136
  /**
90
137
  * Write a versioning key to CloudFront KeyValueStore.
91
138
  * KVS requires ETag for optimistic concurrency — DescribeKeyValueStore first.
@@ -4,13 +4,14 @@
4
4
  * No args → global epoch bump → all cached content refreshes.
5
5
  * --origin api|media|web → per-origin epoch bump → only that origin refreshes.
6
6
  * --path "/api/posts" → per-URL version bump → only that path refreshes.
7
+ * --invalidate → also create a CloudFront edge invalidation (for stale 404s).
7
8
  *
8
- * Writes directly to CloudFront KVS — no Lambda invoke, no CF invalidation API.
9
+ * Writes directly to CloudFront KVS — no Lambda invoke.
9
10
  */
10
11
 
11
12
  import { resolveConfig } from '../config.js';
12
- import { putKvsKey } from '../aws.js';
13
- import { step, success, fail, info } from '../output.js';
13
+ import { putKvsKey, createInvalidation } from '../aws.js';
14
+ import { step, success, warn, fail, info } from '../output.js';
14
15
 
15
16
  const VALID_ORIGINS = ['api', 'media', 'web'] as const;
16
17
  type CacheOrigin = typeof VALID_ORIGINS[number];
@@ -69,4 +70,24 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
69
70
  success(`Cache epoch updated to ${epoch}`);
70
71
  info('All cached content will refresh on next request.');
71
72
  }
73
+
74
+ // CloudFront edge invalidation (optional — clears cached responses without _v param)
75
+ if (flags.invalidate === 'true') {
76
+ const distributionId = flags['distribution-id'] || config.distributionId;
77
+ if (!distributionId) {
78
+ warn('No distributionId found. Add `distributionId` to sst.config.ts return block, or pass --distribution-id.');
79
+ info(' return { ..., distributionId: router.nodes.distribution.id }');
80
+ } else {
81
+ step('Creating CloudFront invalidation...');
82
+ try {
83
+ const invalidationPaths = flags.path ? [flags.path] : ['/*'];
84
+ const invalidationId = await createInvalidation(config.region, distributionId, invalidationPaths);
85
+ success(`CloudFront invalidation created: ${invalidationId || 'submitted'}`);
86
+ info('Edge caches will clear within 1-2 minutes.');
87
+ } catch (err: any) {
88
+ warn(`CloudFront invalidation failed: ${err.message}`);
89
+ info('Ensure your IAM user/role has cloudfront:CreateInvalidation permission.');
90
+ }
91
+ }
92
+ }
72
93
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { resolveConfig } from '../config.js';
10
- import { invokeAction } from '../aws.js';
10
+ import { invokeAction, resolveLogGroup } from '../aws.js';
11
11
  import { step, success, fail, info } from '../output.js';
12
12
 
13
13
  export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
@@ -104,13 +104,13 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
104
104
 
105
105
  try {
106
106
  // Import AWS SDK dynamically
107
- const { CloudWatchLogsClient, FilterLogEventsCommand, DescribeLogStreamsCommand } =
107
+ const { CloudWatchLogsClient, FilterLogEventsCommand } =
108
108
  await import('@aws-sdk/client-cloudwatch-logs');
109
109
 
110
110
  const client = new CloudWatchLogsClient({ region: config.region });
111
111
 
112
- // Determine log group name from function name
113
- const logGroupName = `/aws/lambda/${config.apiFunctionName}`;
112
+ // Resolve actual log group from Lambda config (handles SST name changes)
113
+ const logGroupName = await resolveLogGroup(config.region, config.apiFunctionName);
114
114
 
115
115
  // Parse since duration to milliseconds
116
116
  const sinceMs = parseDuration(since);
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { resolveConfig } from '../config.js';
9
9
  import { uploadToS3, invokeAction } from '../aws.js';
10
10
  import { step, success, warn, fail, info } from '../output.js';
11
- import { exportApp } from '../utils/export.js';
11
+ import { exportApp, isDistStale } from '../utils/export.js';
12
12
  import { walkDirectory } from '../utils/walk.js';
13
13
 
14
14
  export interface UpdateFlags {
@@ -32,13 +32,26 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
32
32
 
33
33
  if (!runtimeVersion) {
34
34
  fail('No runtimeVersion found in app.json/app.config.js');
35
+ info('Add "runtimeVersion" to your app.json, e.g.:');
36
+ info(' { "expo": { "runtimeVersion": { "policy": "fingerprint" } } }');
37
+ info('See: https://docs.expo.dev/eas-update/runtime-versions/');
35
38
  process.exit(1);
36
39
  }
37
40
  success(`Runtime version: ${runtimeVersion}`);
38
41
 
39
42
  // Export if needed
40
43
  const distDir = path.resolve('dist');
41
- const shouldExport = flags.export === 'true' || !(await dirExists(distDir));
44
+ const distExists = await dirExists(distDir);
45
+ let shouldExport = flags.export === 'true' || !distExists;
46
+
47
+ // Check if dist/ is stale (source files newer than dist/)
48
+ if (!shouldExport && flags['skip-export'] !== 'true' && distExists) {
49
+ if (await isDistStale(distDir)) {
50
+ step('Source files changed since last export, re-exporting...');
51
+ shouldExport = true;
52
+ }
53
+ }
54
+
42
55
  if (shouldExport) {
43
56
  step('Exporting app...');
44
57
  await exportApp();
@@ -264,12 +277,43 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
264
277
  success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
265
278
  }
266
279
 
267
- // 4. Register release via Lambda invoke (IAM auth)
268
- // Writes versioned manifest, channel pointer, meta.json, and optional DB mirror.
269
- step('Registering release...');
280
+ // 4. Write release manifests directly to S3 (source of truth for SSR).
281
+ step('Writing release manifests...');
270
282
  const updateId = crypto.createHash('sha256')
271
283
  .update(`${channel}:${runtimeVersion}:${Date.now()}`)
272
284
  .digest('hex');
285
+ const createdAt = new Date().toISOString();
286
+ const webManifest = JSON.stringify({
287
+ updateId,
288
+ runtimeVersion,
289
+ platform: 'web',
290
+ storagePrefix,
291
+ createdAt,
292
+ });
293
+ const manifestBody = Buffer.from(webManifest);
294
+
295
+ try {
296
+ await Promise.all([
297
+ // Versioned manifest: releases/{branch}/{rv}/{groupId}/web/manifest.json
298
+ uploadToS3(
299
+ config.region, config.updatesBucket,
300
+ `${storagePrefix}/manifest.json`, manifestBody, 'application/json',
301
+ ),
302
+ // Channel pointer: {channel}/web/manifest.json (mutable, points to latest)
303
+ uploadToS3(
304
+ config.region, config.updatesBucket,
305
+ `${channel}/web/manifest.json`, manifestBody, 'application/json',
306
+ ),
307
+ ]);
308
+ success(`Manifests written (channel=${channel}, updateId=${updateId.slice(0, 12)}...)`);
309
+ } catch (err: any) {
310
+ fail(`Manifest write failed: ${err.message}`);
311
+ info('Ensure your IAM user/role has s3:PutObject on the Updates bucket.');
312
+ process.exit(1);
313
+ }
314
+
315
+ // 5. Register release via Lambda invoke (optional — for DB mirroring only).
316
+ // The S3 manifests above are the source of truth for SSR resolution.
273
317
  try {
274
318
  const result: any = await invokeAction(
275
319
  config.region,
@@ -286,12 +330,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
286
330
  },
287
331
  );
288
332
  if (result?.error) {
289
- warn(`Registration failed: ${result.error}`);
333
+ info(`DB mirror skipped: ${result.error}`);
290
334
  } else {
291
- success(`Release registered: channel=${result?.channel || channel}, updateId=${result?.updateId || 'n/a'}`);
335
+ info(`Release mirrored to DB: channel=${result?.channel || channel}`);
292
336
  }
293
337
  } catch (err: any) {
294
- warn(`Registration failed: ${err.message}`);
338
+ // register-web is optional — the S3 manifests are the source of truth.
339
+ if (err.message?.includes('Unknown action')) {
340
+ info('Tip: add a "register-web" case to onAction for DB-backed release tracking.');
341
+ } else {
342
+ info(`DB mirror skipped: ${err.message}`);
343
+ }
295
344
  }
296
345
 
297
346
  // Clean up archive
package/src/cli/config.ts CHANGED
@@ -18,6 +18,7 @@ export interface CliConfig {
18
18
  updatesBucket: string;
19
19
  clientBundlesBucket: string;
20
20
  kvsArn?: string;
21
+ distributionId?: string;
21
22
  }
22
23
 
23
24
  interface SstOutputs {
@@ -28,6 +29,7 @@ interface SstOutputs {
28
29
  updatesBucket?: string;
29
30
  clientBundlesBucket?: string;
30
31
  kvsArn?: string;
32
+ distributionId?: string;
31
33
  }
32
34
 
33
35
  export async function resolveConfig(stage?: string): Promise<CliConfig> {
@@ -94,5 +96,6 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
94
96
  updatesBucket: outputs.updatesBucket,
95
97
  clientBundlesBucket: outputs.clientBundlesBucket,
96
98
  kvsArn: outputs.kvsArn,
99
+ distributionId: outputs.distributionId,
97
100
  };
98
101
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * CloudFront distribution discovery.
3
+ *
4
+ * Separated from discover.ts to avoid Jest ESM module resolution issues
5
+ * with the @aws-sdk/client-cloudfront ListDistributions types.
6
+ */
7
+
8
+ /**
9
+ * Find the CloudFront distribution for a stage by matching the Comment field.
10
+ * SST sets the Comment to include the app name and stage.
11
+ * Best-effort: returns undefined if not found.
12
+ */
13
+ export async function discoverDistribution(
14
+ region: string,
15
+ prefix: string,
16
+ ): Promise<string | undefined> {
17
+ const { CloudFrontClient, ListDistributionsCommand } = await import('@aws-sdk/client-cloudfront');
18
+ const client = new CloudFrontClient({ region });
19
+ const distNeedle = prefix.toLowerCase();
20
+
21
+ let marker: string | undefined;
22
+ do {
23
+ const res = await client.send(
24
+ new ListDistributionsCommand({ Marker: marker, MaxItems: 50 }),
25
+ );
26
+ for (const dist of res.DistributionList?.Items || []) {
27
+ const comment = (dist.Comment || '').toLowerCase();
28
+ if (comment.includes(distNeedle)) {
29
+ return dist.Id;
30
+ }
31
+ }
32
+ marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
33
+ } while (marker);
34
+
35
+ return undefined;
36
+ }
@@ -22,6 +22,7 @@ interface DiscoveredConfig {
22
22
  updatesBucket: string;
23
23
  clientBundlesBucket: string;
24
24
  kvsArn?: string;
25
+ distributionId?: string;
25
26
  }
26
27
 
27
28
  // ---------------------------------------------------------------------------
@@ -61,6 +62,7 @@ export async function parseAppName(configPath?: string): Promise<string> {
61
62
 
62
63
  interface CfFunctionResult {
63
64
  kvsArn?: string;
65
+ distributionId?: string;
64
66
  }
65
67
 
66
68
  /**
@@ -240,6 +242,15 @@ export async function discoverConfig(
240
242
  discoverBuckets(region, prefix),
241
243
  ]);
242
244
 
245
+ // Distribution discovery is best-effort (not critical for most CLI operations)
246
+ let distributionId: string | undefined;
247
+ try {
248
+ const { discoverDistribution } = await import('./discover-distribution.js');
249
+ distributionId = await discoverDistribution(region, prefix);
250
+ } catch {
251
+ // Non-fatal — only needed for cache:purge --invalidate
252
+ }
253
+
243
254
  if (!bucketsResult.updatesBucket) {
244
255
  throw new Error(
245
256
  `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
@@ -261,6 +272,7 @@ export async function discoverConfig(
261
272
  updatesBucket: bucketsResult.updatesBucket,
262
273
  clientBundlesBucket: bucketsResult.clientBundlesBucket,
263
274
  kvsArn: cfResult.kvsArn,
275
+ distributionId,
264
276
  };
265
277
  }
266
278
 
@@ -277,6 +289,7 @@ interface CachedOutputs {
277
289
  updatesBucket?: string;
278
290
  clientBundlesBucket?: string;
279
291
  kvsArn?: string;
292
+ distributionId?: string;
280
293
  }
281
294
 
282
295
  function cachePath(stage: string): string {
@@ -298,6 +311,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
298
311
  updatesBucket: cached.updatesBucket,
299
312
  clientBundlesBucket: cached.clientBundlesBucket,
300
313
  kvsArn: cached.kvsArn,
314
+ distributionId: cached.distributionId,
301
315
  };
302
316
  } catch {
303
317
  return null;
@@ -312,6 +326,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
312
326
  updatesBucket: config.updatesBucket,
313
327
  clientBundlesBucket: config.clientBundlesBucket,
314
328
  kvsArn: config.kvsArn,
329
+ distributionId: config.distributionId,
315
330
  };
316
331
  try {
317
332
  await fs.writeFile(cachePath(stage), JSON.stringify(data, null, 2));
package/src/cli/index.ts CHANGED
@@ -131,8 +131,8 @@ function printHelp() {
131
131
  everystack - CLI for Expo apps on everystack
132
132
 
133
133
  Usage:
134
- everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>]
135
- everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>]
134
+ everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
135
+ everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
136
136
  everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
137
137
  everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
138
138
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
@@ -143,6 +143,7 @@ Usage:
143
143
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
144
144
  everystack cache:purge [--stage <name>] --origin api Bust API origin cache (api|media|web)
145
145
  everystack cache:purge [--stage <name>] --path "/api/posts" Bust specific path cache
146
+ everystack cache:purge [--stage <name>] --invalidate Also create CloudFront edge invalidation
146
147
  everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
147
148
  everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
148
149
  everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
@@ -1,4 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
2
4
 
3
5
  export async function exportApp(): Promise<void> {
4
6
  execSync('npx expo export --output-dir dist', {
@@ -6,3 +8,20 @@ export async function exportApp(): Promise<void> {
6
8
  env: { ...process.env },
7
9
  });
8
10
  }
11
+
12
+ /**
13
+ * Check if dist/ is stale by comparing its mtime against known source files.
14
+ * Returns true if any source file (app.json, app.config.js, app.config.ts, app/)
15
+ * was modified after dist/ was last written.
16
+ */
17
+ export async function isDistStale(distDir: string, projectDir?: string): Promise<boolean> {
18
+ const baseDir = projectDir ?? process.cwd();
19
+ const distMtime = (await fs.stat(distDir)).mtimeMs;
20
+ const sourceChecks = ['app.json', 'app.config.js', 'app.config.ts', 'app'].map(async (f) => {
21
+ try {
22
+ const s = await fs.stat(path.resolve(baseDir, f));
23
+ return s.mtimeMs > distMtime;
24
+ } catch { return false; }
25
+ });
26
+ return (await Promise.all(sourceChecks)).some(Boolean);
27
+ }
@@ -8,6 +8,7 @@ import { handleListChannels, handleCreateChannel, handleEditChannel } from './ch
8
8
 
9
9
  export { handleRegisterWeb } from './publish-web';
10
10
  export { registerMobileRelease } from './publish';
11
+ export { handleListChannels, handleCreateChannel } from './channels-crud';
11
12
  export type { RegisterMobileParams, RegisterMobileResult, RegisterMobileAsset } from './publish';
12
13
  export type { UpdatesHandlerOptions } from './types';
13
14
 
@@ -22,7 +23,7 @@ export function createUpdatesHandler(options: UpdatesHandlerOptions): (request:
22
23
  pathname = pathname.slice(basePath.length) || '/';
23
24
  }
24
25
 
25
- if (request.method === 'GET' && pathname === '/manifest') {
26
+ if (request.method === 'GET' && (pathname === '/manifest' || pathname === '/')) {
26
27
  return handleManifest(request, options);
27
28
  }
28
29
 
package/src/plugin.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @everystack/cli/plugin — OTA updates handler + CLI actions as a composable plugin.
3
+ *
4
+ * Registers the updates manifest/asset endpoint for Expo OTA updates,
5
+ * plus CLI actions for registering builds and managing channels.
6
+ *
7
+ * Note: Types are inlined to avoid circular dependency with @everystack/server.
8
+ */
9
+
10
+ import type { StorageAdapter } from './storage/index';
11
+
12
+ /** Minimal plugin context (compatible with @everystack/server/plugin PluginContext) */
13
+ interface PluginContext {
14
+ db: any;
15
+ schema: Record<string, any>;
16
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
17
+ environment: string;
18
+ publishJob: (type: string, payload: unknown) => Promise<string>;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /** Minimal route type (compatible with @everystack/server Route) */
23
+ interface Route {
24
+ path: string;
25
+ method?: string;
26
+ exact?: boolean;
27
+ handler: (req: Request) => Promise<Response>;
28
+ }
29
+
30
+ /** Action handler type (compatible with @everystack/server/plugin ActionHandler) */
31
+ type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
32
+
33
+ /** Plugin factory function */
34
+ type Plugin = (ctx: PluginContext) => Promise<{
35
+ routes?: Route[];
36
+ actions?: Record<string, ActionHandler>;
37
+ }>;
38
+
39
+ export interface UpdatesPluginOptions {
40
+ /** Base URL for update asset downloads (e.g., CDN_URL or SITE_URL) */
41
+ baseUrl?: string;
42
+ /** Base path for updates routes (default: '/api/updates') */
43
+ basePath?: string;
44
+ /** Ed25519 private key for signing manifests */
45
+ privateKey?: string;
46
+ /** Default channel name (default: 'production') */
47
+ defaultChannel?: string;
48
+ }
49
+
50
+ interface UpdatesContext extends PluginContext {
51
+ updatesStorage: StorageAdapter;
52
+ clientBundlesStorage?: StorageAdapter;
53
+ }
54
+
55
+ export function updatesPlugin(options: UpdatesPluginOptions = {}): Plugin {
56
+ const basePath = options.basePath ?? '/api/updates';
57
+
58
+ return async (ctx) => {
59
+ const {
60
+ createUpdatesHandler,
61
+ handleRegisterWeb,
62
+ registerMobileRelease,
63
+ handleListChannels,
64
+ handleCreateChannel,
65
+ } = await import('./handler/index');
66
+ const appCtx = ctx as UpdatesContext;
67
+
68
+ const handlerOptions = {
69
+ db: ctx.db,
70
+ storage: appCtx.updatesStorage,
71
+ baseUrl: options.baseUrl ?? process.env.CDN_URL ?? process.env.SITE_URL ?? 'http://localhost:8081',
72
+ basePath,
73
+ auth: { verifyToken: ctx.verifyToken },
74
+ privateKey: options.privateKey ?? process.env.UPDATES_PRIVATE_KEY,
75
+ defaultChannel: options.defaultChannel ?? 'production',
76
+ clientBundlesStorage: appCtx.clientBundlesStorage,
77
+ };
78
+
79
+ const handler = createUpdatesHandler(handlerOptions);
80
+
81
+ return {
82
+ routes: [{ path: basePath, handler }],
83
+ actions: {
84
+ 'register-web': async (payload) => {
85
+ const req = new Request('http://internal/register-web', {
86
+ method: 'POST',
87
+ headers: { 'content-type': 'application/json' },
88
+ body: JSON.stringify(payload),
89
+ });
90
+ const res = await handleRegisterWeb(req, handlerOptions);
91
+ const body = await res.json().catch(() => ({}));
92
+ if (!res.ok) return { error: (body as any).error || `register-web failed (${res.status})` };
93
+ return body;
94
+ },
95
+ 'register-mobile': async (payload) => {
96
+ try {
97
+ return await registerMobileRelease(handlerOptions, payload as any);
98
+ } catch (err: any) {
99
+ return { error: err?.message || 'register-mobile failed' };
100
+ }
101
+ },
102
+ 'channels-list': async () => {
103
+ const req = new Request('http://internal/channels', { method: 'GET' });
104
+ const res = await handleListChannels(req, handlerOptions);
105
+ const body = await res.json().catch(() => ({}));
106
+ if (!res.ok) return { error: (body as any).error || `channels-list failed (${res.status})` };
107
+ return body;
108
+ },
109
+ 'channels-create': async (payload) => {
110
+ const req = new Request('http://internal/channels', {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify(payload),
114
+ });
115
+ const res = await handleCreateChannel(req, handlerOptions);
116
+ const body = await res.json().catch(() => ({}));
117
+ if (!res.ok) return { error: (body as any).error || `channels-create failed (${res.status})` };
118
+ return body;
119
+ },
120
+ },
121
+ };
122
+ };
123
+ }
@@ -14,12 +14,12 @@ export type StorageOptions =
14
14
  | { type: 'filesystem'; directory: string }
15
15
  | { type: 's3'; bucket: string; region?: string; endpoint?: string };
16
16
 
17
- export function createStorage(options: StorageOptions): StorageAdapter {
17
+ export async function createStorage(options: StorageOptions): Promise<StorageAdapter> {
18
18
  if (options.type === 'filesystem') {
19
- const { FilesystemStorageAdapter } = require('./filesystem') as typeof import('./filesystem');
19
+ const { FilesystemStorageAdapter } = await import('./filesystem');
20
20
  return new FilesystemStorageAdapter(options.directory);
21
21
  }
22
- const { S3StorageAdapter } = require('./s3') as typeof import('./s3');
22
+ const { S3StorageAdapter } = await import('./s3');
23
23
  return new S3StorageAdapter(options.bucket, options.region, options.endpoint);
24
24
  }
25
25