@everystack/cli 0.2.8 → 0.2.10

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.8",
3
+ "version": "0.2.10",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -41,6 +41,7 @@
41
41
  "lint": "tsc --noEmit"
42
42
  },
43
43
  "dependencies": {
44
+ "@aws-sdk/client-cloudfront": "3.1053.0",
44
45
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
45
46
  "@aws-sdk/signature-v4a": "3.1048.0",
46
47
  "glob": "13.0.6",
@@ -50,7 +51,6 @@
50
51
  },
51
52
  "peerDependencies": {
52
53
  "@everystack/server": ">=0.1.0",
53
- "@aws-sdk/client-cloudfront": "3.1053.0",
54
54
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
55
55
  "@aws-sdk/client-lambda": "3.1053.0",
56
56
  "@aws-sdk/client-s3": "3.1053.0",
@@ -64,9 +64,6 @@
64
64
  "@everystack/server": {
65
65
  "optional": true
66
66
  },
67
- "@aws-sdk/client-cloudfront": {
68
- "optional": true
69
- },
70
67
  "@aws-sdk/client-cloudwatch-logs": {
71
68
  "optional": true
72
69
  },
@@ -90,7 +87,6 @@
90
87
  }
91
88
  },
92
89
  "devDependencies": {
93
- "@aws-sdk/client-cloudfront": "3.1053.0",
94
90
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
95
91
  "@aws-sdk/client-lambda": "3.1053.0",
96
92
  "@aws-sdk/client-s3": "3.1053.0",
package/src/cli/aws.ts CHANGED
@@ -94,19 +94,13 @@ export async function invokeAction(
94
94
 
95
95
  /**
96
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.
97
+ * Uses the conventional `/aws/lambda/{functionName}` naming — no IAM call needed.
99
98
  */
100
- export async function resolveLogGroup(
101
- region: string,
99
+ export function resolveLogGroup(
100
+ _region: string,
102
101
  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}`;
102
+ ): string {
103
+ return `/aws/lambda/${functionName}`;
110
104
  }
111
105
 
112
106
  /**
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { resolveConfig, type CliConfig } 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, isDistStale } from '../utils/export.js';
11
+ import { exportApp } from '../utils/export.js';
12
12
  import { walkDirectory } from '../utils/walk.js';
13
13
 
14
14
  export interface UpdateFlags {
@@ -27,13 +27,19 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
27
27
  // (e.g. OUTBOUND_HOST=localhost → LAN IP) to leak into the manifest.
28
28
  process.env.EVERYSTACK_UPDATE = '1';
29
29
 
30
- // Set ENVIRONMENT before app.config.js evaluation so the manifest
30
+ // Resolve ENVIRONMENT before app.config.js evaluation so the manifest
31
31
  // snapshots the correct API URLs and feature flags for the target.
32
- // --environment is explicit; --stage is NOT used as a fallback because
33
- // SST stage names (dev, prod) don't match EAS profile names
34
- // (development, production) and conflating them causes config failures.
32
+ // Priority: --environment flag > eas.json channel match > existing env.
35
33
  if (flags.environment) {
36
34
  process.env.ENVIRONMENT ||= flags.environment;
35
+ } else if (!process.env.ENVIRONMENT) {
36
+ // Derive ENVIRONMENT from eas.json by matching --channel to a build profile.
37
+ // EAS profiles define ENVIRONMENT in their env blocks (e.g. production → "production").
38
+ const channel = flags.channel || flags.branch || flags.stage || 'production';
39
+ const easEnv = resolveEasEnvironment(channel);
40
+ if (easEnv) {
41
+ process.env.ENVIRONMENT = easEnv;
42
+ }
37
43
  }
38
44
 
39
45
  // When --stage is provided, resolve deployed config early so HOST_URL
@@ -64,6 +70,21 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
64
70
  const appConfig = await loadAppConfig();
65
71
  const runtimeVersion = appConfig.runtimeVersion;
66
72
 
73
+ // Validate: SSR flags in the expo-router plugin config will crash native devices.
74
+ // The bundle is compiled with server rendering code paths that have no server at runtime.
75
+ const ssrFlags = getExpoRouterSsrFlags(appConfig);
76
+ if (ssrFlags.useServerRendering || ssrFlags.useServerDataLoaders) {
77
+ fail('Export contains server rendering flags that will crash native devices.');
78
+ info('expo-router plugin has unstable_useServerRendering or unstable_useServerDataLoaders enabled.');
79
+ info('Use SSR_ENABLED to opt in only for web server exports:');
80
+ info('');
81
+ info(" ['expo-router', {");
82
+ info(' unstable_useServerRendering: !!process.env.SSR_ENABLED,');
83
+ info(' unstable_useServerDataLoaders: !!process.env.SSR_ENABLED,');
84
+ info(' }]');
85
+ process.exit(1);
86
+ }
87
+
67
88
  if (!runtimeVersion) {
68
89
  fail('No runtimeVersion found in app.json/app.config.js');
69
90
  info('Add "runtimeVersion" to your app.json, e.g.:');
@@ -73,32 +94,26 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
73
94
  }
74
95
  success(`Runtime version: ${runtimeVersion}`);
75
96
 
76
- // Export if needed
77
97
  const distDir = path.resolve('dist');
78
- const distExists = await dirExists(distDir);
79
- let shouldExport = flags.export === 'true' || !distExists;
80
-
81
- // Check if dist/ is stale (source files newer than dist/)
82
- if (!shouldExport && flags['skip-export'] !== 'true' && distExists) {
83
- if (await isDistStale(distDir)) {
84
- step('Source files changed since last export, re-exporting...');
85
- shouldExport = true;
86
- }
87
- }
98
+ const needsMobile = platformFilter !== 'web';
88
99
 
89
- if (shouldExport) {
100
+ // First export: mobile-safe bundles (no SSR_ENABLED).
101
+ // Always re-export — stale dist/ is the #1 cause of broken OTA pushes.
102
+ if (needsMobile) {
90
103
  step('Exporting app...');
91
104
  await exportApp();
92
105
  success('Export complete');
93
106
  }
94
107
 
95
108
  // Read metadata (mobile exports produce metadata.json; web-only exports don't)
96
- const metadataPath = path.join(distDir, 'metadata.json');
97
109
  let metadata: any = null;
98
- try {
99
- metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
100
- } catch {
101
- // No metadata.json — web-only export
110
+ if (needsMobile) {
111
+ const metadataPath = path.join(distDir, 'metadata.json');
112
+ try {
113
+ metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
114
+ } catch {
115
+ // No metadata.json
116
+ }
102
117
  }
103
118
 
104
119
  // Determine platforms to publish
@@ -228,6 +243,16 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
228
243
 
229
244
  // Web publish — direct S3 upload + Lambda invoke (IAM auth)
230
245
  if (hasWeb) {
246
+ // Re-export with SSR_ENABLED for web server rendering.
247
+ // The first export produced mobile-safe bundles (already uploaded above).
248
+ // This second export enables SSR flags so the web bundle includes
249
+ // server rendering code paths and data loaders.
250
+ step('Exporting web bundle (SSR)...');
251
+ process.env.SSR_ENABLED = '1';
252
+ await exportApp();
253
+ delete process.env.SSR_ENABLED;
254
+ success('Web export complete');
255
+
231
256
  const serverDir = path.join(distDir, 'server');
232
257
  const clientDir = path.join(distDir, 'client');
233
258
  const routesJson = path.join(serverDir, '_expo', 'routes.json');
@@ -490,15 +515,6 @@ function mimeFromExt(ext: string): string {
490
515
  return map[ext] || 'application/octet-stream';
491
516
  }
492
517
 
493
- async function dirExists(dir: string): Promise<boolean> {
494
- try {
495
- const stat = await fs.stat(dir);
496
- return stat.isDirectory();
497
- } catch {
498
- return false;
499
- }
500
- }
501
-
502
518
  async function fileExists(filePath: string): Promise<boolean> {
503
519
  try {
504
520
  const stat = await fs.stat(filePath);
@@ -530,3 +546,61 @@ async function createArchive(sourceDir: string, archivePath: string): Promise<vo
530
546
  await pipeline(tar.stdout!, brotli, output);
531
547
  await tarExit;
532
548
  }
549
+
550
+ /**
551
+ * Read eas.json and find ENVIRONMENT for a given channel.
552
+ * Matches --channel to a build profile's `channel` field, then reads
553
+ * ENVIRONMENT from that profile's env block. Falls back to profile name
554
+ * match (e.g. --channel production matches the "production" profile).
555
+ * @internal Exported for testing.
556
+ */
557
+ export function resolveEasEnvironment(channel: string, cwd?: string): string | undefined {
558
+ try {
559
+ const easPath = path.resolve(cwd || process.cwd(), 'eas.json');
560
+ // Synchronous read — runs once before export, performance irrelevant.
561
+ const eas = JSON.parse(require('fs').readFileSync(easPath, 'utf8'));
562
+ const profiles = eas?.build;
563
+ if (!profiles || typeof profiles !== 'object') return undefined;
564
+
565
+ // First pass: match on explicit channel field
566
+ for (const [, profile] of Object.entries(profiles)) {
567
+ const p = profile as any;
568
+ if (p?.channel === channel && p?.env?.ENVIRONMENT) {
569
+ return p.env.ENVIRONMENT;
570
+ }
571
+ }
572
+
573
+ // Second pass: match on profile name (e.g. "production" profile for --channel production)
574
+ if (profiles[channel]?.env?.ENVIRONMENT) {
575
+ return profiles[channel].env.ENVIRONMENT;
576
+ }
577
+
578
+ return undefined;
579
+ } catch {
580
+ return undefined;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Extract expo-router SSR flags from the resolved app config.
586
+ * Checks the plugins array for the expo-router entry.
587
+ * @internal Exported for testing.
588
+ */
589
+ export function getExpoRouterSsrFlags(appConfig: any): {
590
+ useServerRendering: boolean;
591
+ useServerDataLoaders: boolean;
592
+ } {
593
+ const plugins: any[] = appConfig?.plugins || [];
594
+ for (const plugin of plugins) {
595
+ // Plugin can be a string or [name, options] tuple
596
+ const name = Array.isArray(plugin) ? plugin[0] : plugin;
597
+ const options = Array.isArray(plugin) ? plugin[1] : undefined;
598
+ if (name === 'expo-router' && options && typeof options === 'object') {
599
+ return {
600
+ useServerRendering: !!options.unstable_useServerRendering,
601
+ useServerDataLoaders: !!options.unstable_useServerDataLoaders,
602
+ };
603
+ }
604
+ }
605
+ return { useServerRendering: false, useServerDataLoaders: false };
606
+ }
@@ -1,10 +1,13 @@
1
1
  import type { UpdatesHandlerOptions } from './types';
2
2
 
3
+ const debug = process.env.DEBUG_UPDATES === '1';
4
+
3
5
  export async function handleAssets(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
4
6
  const url = new URL(request.url);
5
7
  const key = url.searchParams.get('key');
6
8
 
7
9
  if (!key) {
10
+ console.warn('[updates] asset request rejected: no key');
8
11
  return new Response(JSON.stringify({ error: 'No asset key provided.' }), {
9
12
  status: 400,
10
13
  headers: { 'content-type': 'application/json' },
@@ -15,6 +18,7 @@ export async function handleAssets(request: Request, options: UpdatesHandlerOpti
15
18
  // Lambda function URLs cap responses at 6 MB; Hermes bytecode bundles exceed that.
16
19
  if (options.storage.presignGet) {
17
20
  const presignedUrl = await options.storage.presignGet(key);
21
+ debug && console.log('[updates] asset redirect', { key });
18
22
  return new Response(null, {
19
23
  status: 302,
20
24
  headers: {
@@ -29,6 +33,7 @@ export async function handleAssets(request: Request, options: UpdatesHandlerOpti
29
33
  const result = await options.storage.get(key);
30
34
 
31
35
  if (!result) {
36
+ console.warn('[updates] asset not found', { key });
32
37
  return new Response(JSON.stringify({ error: `Asset "${key}" not found.` }), {
33
38
  status: 404,
34
39
  headers: { 'content-type': 'application/json' },
@@ -71,6 +71,7 @@ export function createUpdatesHandler(options: UpdatesHandlerOptions): (request:
71
71
  return handleEditChannel(request, options, channelName);
72
72
  }
73
73
 
74
+ console.warn('[updates] 404 unmatched route', { method: request.method, pathname });
74
75
  return new Response(JSON.stringify({ error: 'Not found' }), {
75
76
  status: 404,
76
77
  headers: { 'content-type': 'application/json' },
@@ -6,6 +6,8 @@ import { buildMultipartResponse } from './multipart';
6
6
  import { signRSASHA256, convertSHA256HashToUUID, convertToDictionaryItemsRepresentation } from './signing';
7
7
  import { jsonError } from './helpers';
8
8
 
9
+ const debug = process.env.DEBUG_UPDATES === '1';
10
+
9
11
  export async function handleManifest(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
10
12
  const headers = request.headers;
11
13
 
@@ -14,24 +16,46 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
14
16
 
15
17
  const platform = headers.get('expo-platform');
16
18
  if (platform !== 'ios' && platform !== 'android') {
19
+ console.warn('[updates] manifest request rejected: unsupported platform', {
20
+ platform: headers.get('expo-platform'),
21
+ });
17
22
  return jsonError(400, 'Unsupported platform. Expected either ios or android.');
18
23
  }
19
24
 
20
25
  const runtimeVersion = headers.get('expo-runtime-version');
21
26
  if (!runtimeVersion) {
27
+ console.warn('[updates] manifest request rejected: no runtimeVersion');
22
28
  return jsonError(400, 'No runtimeVersion provided.');
23
29
  }
24
30
 
25
31
  const channelName = headers.get('expo-channel-name') || options.defaultChannel || 'production';
26
32
 
33
+ debug && console.log('[updates] manifest request', {
34
+ platform,
35
+ runtimeVersion,
36
+ channelName,
37
+ currentUpdateId: headers.get('expo-current-update-id'),
38
+ protocolVersion,
39
+ expectSignature: !!headers.get('expo-expect-signature'),
40
+ });
41
+
27
42
  // Try S3 flat-file manifest first (works without DB)
43
+ const s3Key = `${channelName}/${platform}/${runtimeVersion}/manifest.json`;
28
44
  const s3Manifest = await tryS3Manifest(options, channelName, platform, runtimeVersion);
29
45
  if (s3Manifest) {
46
+ debug && console.log('[updates] S3 manifest found', {
47
+ key: s3Key,
48
+ updateId: s3Manifest.id,
49
+ assetCount: s3Manifest.assets?.length,
50
+ hasLaunchAsset: !!s3Manifest.launchAsset,
51
+ });
30
52
  return buildManifestResponse(request, s3Manifest, options, protocolVersion);
31
53
  }
54
+ debug && console.log('[updates] S3 manifest not found', { key: s3Key });
32
55
 
33
56
  // Fall back to DB-based lookup (V2)
34
57
  if (!options.db) {
58
+ debug && console.log('[updates] no DB configured, returning 404');
35
59
  return jsonError(404, `No update found for ${channelName}/${platform}/${runtimeVersion}`);
36
60
  }
37
61
 
@@ -50,13 +74,16 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
50
74
  .limit(1);
51
75
 
52
76
  if (!channel) {
77
+ console.warn('[updates] DB channel not found', { channelName });
53
78
  return jsonError(404, `Channel "${channelName}" not found.`);
54
79
  }
55
80
 
56
81
  const branchRef = (channel.data as Record<string, unknown>)?.branchRef as string;
57
82
  if (!branchRef) {
83
+ console.warn('[updates] DB channel has no branch mapping', { channelName });
58
84
  return jsonError(404, `Channel "${channelName}" has no branch mapping.`);
59
85
  }
86
+ debug && console.log('[updates] DB channel resolved', { channelName, branchRef });
60
87
 
61
88
  // Look up latest release (buildArtifact type='ota') via run branchRef
62
89
  const releaseResults = await options.db
@@ -74,9 +101,15 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
74
101
  .limit(1);
75
102
 
76
103
  if (!releaseResults[0]) {
104
+ console.warn('[updates] DB release not found', { branchRef, runtimeVersion, platform });
77
105
  return jsonError(404, `No update found for runtime version: ${runtimeVersion}`);
78
106
  }
79
107
  release = releaseResults[0].release;
108
+ debug && console.log('[updates] DB release found', {
109
+ releaseId: release.id,
110
+ runId: release.runId,
111
+ updateId: (release.data as Record<string, unknown>)?.updateId,
112
+ });
80
113
 
81
114
  // Look up assets (buildArtifacts type='ota_asset') for this release's run
82
115
  assets = await options.db
@@ -86,8 +119,12 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
86
119
  eq(buildArtifacts.type, 'ota_asset'),
87
120
  eq(buildArtifacts.runId, release.runId),
88
121
  ));
89
- } catch {
122
+ } catch (err) {
90
123
  // DB unavailable or tables don't exist — not an error in V1
124
+ console.error('[updates] DB lookup error', {
125
+ error: err instanceof Error ? err.message : String(err),
126
+ channelName, platform, runtimeVersion,
127
+ });
91
128
  return jsonError(404, `No update found for ${channelName}/${platform}/${runtimeVersion}`);
92
129
  }
93
130
 
@@ -95,6 +132,7 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
95
132
 
96
133
  // Handle rollback
97
134
  if (releaseData.isRollback) {
135
+ debug && console.log('[updates] response: rollback', { protocolVersion });
98
136
  if (protocolVersion === 0) {
99
137
  return jsonError(400, 'Rollbacks not supported on protocol version 0');
100
138
  }
@@ -106,6 +144,9 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
106
144
  const updateId = releaseData.updateId as string;
107
145
  const releaseUUID = convertSHA256HashToUUID(updateId);
108
146
  if (currentUpdateId === releaseUUID && protocolVersion === 1) {
147
+ debug && console.log('[updates] response: noUpdateAvailable', {
148
+ currentUpdateId, releaseUUID,
149
+ });
109
150
  return buildNoUpdateAvailableResponse(request, options, protocolVersion);
110
151
  }
111
152
 
@@ -148,6 +189,13 @@ export async function handleManifest(request: Request, options: UpdatesHandlerOp
148
189
  },
149
190
  };
150
191
 
192
+ debug && console.log('[updates] response: manifest', {
193
+ updateId: releaseUUID,
194
+ assetCount: regularAssets.length,
195
+ hasLaunchAsset: !!launchAsset,
196
+ source: 'db',
197
+ });
198
+
151
199
  return buildManifestResponse(request, manifest, options, protocolVersion);
152
200
  }
153
201