@everystack/cli 0.2.9 → 0.2.11

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.11",
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,28 @@ 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
+ // Export mobile platforms only — web static pre-rendering can hang when
103
+ // OUTBOUND_HOST points to a real server (open HTTP handles prevent exit).
104
+ if (needsMobile) {
90
105
  step('Exporting app...');
91
- await exportApp();
106
+ await exportApp('ios,android');
92
107
  success('Export complete');
93
108
  }
94
109
 
95
110
  // Read metadata (mobile exports produce metadata.json; web-only exports don't)
96
- const metadataPath = path.join(distDir, 'metadata.json');
97
111
  let metadata: any = null;
98
- try {
99
- metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
100
- } catch {
101
- // No metadata.json — web-only export
112
+ if (needsMobile) {
113
+ const metadataPath = path.join(distDir, 'metadata.json');
114
+ try {
115
+ metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
116
+ } catch {
117
+ // No metadata.json
118
+ }
102
119
  }
103
120
 
104
121
  // Determine platforms to publish
@@ -228,6 +245,16 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
228
245
 
229
246
  // Web publish — direct S3 upload + Lambda invoke (IAM auth)
230
247
  if (hasWeb) {
248
+ // Re-export with SSR_ENABLED for web server rendering.
249
+ // The first export produced mobile-safe bundles (already uploaded above).
250
+ // This second export enables SSR flags so the web bundle includes
251
+ // server rendering code paths and data loaders.
252
+ step('Exporting web bundle (SSR)...');
253
+ process.env.SSR_ENABLED = '1';
254
+ await exportApp('web');
255
+ delete process.env.SSR_ENABLED;
256
+ success('Web export complete');
257
+
231
258
  const serverDir = path.join(distDir, 'server');
232
259
  const clientDir = path.join(distDir, 'client');
233
260
  const routesJson = path.join(serverDir, '_expo', 'routes.json');
@@ -490,15 +517,6 @@ function mimeFromExt(ext: string): string {
490
517
  return map[ext] || 'application/octet-stream';
491
518
  }
492
519
 
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
520
  async function fileExists(filePath: string): Promise<boolean> {
503
521
  try {
504
522
  const stat = await fs.stat(filePath);
@@ -530,3 +548,61 @@ async function createArchive(sourceDir: string, archivePath: string): Promise<vo
530
548
  await pipeline(tar.stdout!, brotli, output);
531
549
  await tarExit;
532
550
  }
551
+
552
+ /**
553
+ * Read eas.json and find ENVIRONMENT for a given channel.
554
+ * Matches --channel to a build profile's `channel` field, then reads
555
+ * ENVIRONMENT from that profile's env block. Falls back to profile name
556
+ * match (e.g. --channel production matches the "production" profile).
557
+ * @internal Exported for testing.
558
+ */
559
+ export function resolveEasEnvironment(channel: string, cwd?: string): string | undefined {
560
+ try {
561
+ const easPath = path.resolve(cwd || process.cwd(), 'eas.json');
562
+ // Synchronous read — runs once before export, performance irrelevant.
563
+ const eas = JSON.parse(require('fs').readFileSync(easPath, 'utf8'));
564
+ const profiles = eas?.build;
565
+ if (!profiles || typeof profiles !== 'object') return undefined;
566
+
567
+ // First pass: match on explicit channel field
568
+ for (const [, profile] of Object.entries(profiles)) {
569
+ const p = profile as any;
570
+ if (p?.channel === channel && p?.env?.ENVIRONMENT) {
571
+ return p.env.ENVIRONMENT;
572
+ }
573
+ }
574
+
575
+ // Second pass: match on profile name (e.g. "production" profile for --channel production)
576
+ if (profiles[channel]?.env?.ENVIRONMENT) {
577
+ return profiles[channel].env.ENVIRONMENT;
578
+ }
579
+
580
+ return undefined;
581
+ } catch {
582
+ return undefined;
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Extract expo-router SSR flags from the resolved app config.
588
+ * Checks the plugins array for the expo-router entry.
589
+ * @internal Exported for testing.
590
+ */
591
+ export function getExpoRouterSsrFlags(appConfig: any): {
592
+ useServerRendering: boolean;
593
+ useServerDataLoaders: boolean;
594
+ } {
595
+ const plugins: any[] = appConfig?.plugins || [];
596
+ for (const plugin of plugins) {
597
+ // Plugin can be a string or [name, options] tuple
598
+ const name = Array.isArray(plugin) ? plugin[0] : plugin;
599
+ const options = Array.isArray(plugin) ? plugin[1] : undefined;
600
+ if (name === 'expo-router' && options && typeof options === 'object') {
601
+ return {
602
+ useServerRendering: !!options.unstable_useServerRendering,
603
+ useServerDataLoaders: !!options.unstable_useServerDataLoaders,
604
+ };
605
+ }
606
+ }
607
+ return { useServerRendering: false, useServerDataLoaders: false };
608
+ }
@@ -2,8 +2,9 @@ import { execSync } from 'child_process';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
 
5
- export async function exportApp(): Promise<void> {
6
- execSync('npx expo export --output-dir dist', {
5
+ export async function exportApp(platform?: string): Promise<void> {
6
+ const platformFlag = platform ? ` --platform ${platform}` : '';
7
+ execSync(`npx expo export --output-dir dist${platformFlag}`, {
7
8
  stdio: 'inherit',
8
9
  env: { ...process.env },
9
10
  });