@everystack/cli 0.2.9 → 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.9",
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": {
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
+ }