@everystack/cli 0.2.18 → 0.2.21

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.18",
3
+ "version": "0.2.21",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -30,6 +30,14 @@
30
30
  "./plugin": {
31
31
  "types": "./src/plugin.ts",
32
32
  "default": "./src/plugin.ts"
33
+ },
34
+ "./env": {
35
+ "types": "./src/env.ts",
36
+ "default": "./src/env.ts"
37
+ },
38
+ "./sst": {
39
+ "types": "./src/sst.ts",
40
+ "default": "./src/sst.ts"
33
41
  }
34
42
  },
35
43
  "bin": {
package/src/cli/aws.ts CHANGED
@@ -123,13 +123,67 @@ export async function invokeAction(
123
123
 
124
124
  /**
125
125
  * Resolve the CloudWatch log group for a Lambda function.
126
- * Uses the conventional `/aws/lambda/{functionName}` naming — no IAM call needed.
126
+ *
127
+ * First checks if the conventional `/aws/lambda/{functionName}` log group exists.
128
+ * If not (e.g. SST replaced the function during deploy, changing the hash suffix),
129
+ * searches for a log group matching the function's base name prefix.
127
130
  */
128
- export function resolveLogGroup(
129
- _region: string,
131
+ export async function resolveLogGroup(
132
+ region: string,
130
133
  functionName: string,
131
- ): string {
132
- return `/aws/lambda/${functionName}`;
134
+ ): Promise<string> {
135
+ const conventional = `/aws/lambda/${functionName}`;
136
+
137
+ const {
138
+ CloudWatchLogsClient,
139
+ DescribeLogGroupsCommand,
140
+ } = await import('@aws-sdk/client-cloudwatch-logs');
141
+
142
+ const client = new CloudWatchLogsClient({ region });
143
+
144
+ // Fast path: conventional name exists
145
+ try {
146
+ const res = await client.send(
147
+ new DescribeLogGroupsCommand({
148
+ logGroupNamePrefix: conventional,
149
+ limit: 1,
150
+ }),
151
+ );
152
+ if (res.logGroups?.some(lg => lg.logGroupName === conventional)) {
153
+ return conventional;
154
+ }
155
+ } catch {
156
+ // Fall through to prefix search
157
+ }
158
+
159
+ // SST names functions like {app}-{stage}-{ResourceName}{Random}-{hash}.
160
+ // After deploy, the hash changes but the base prefix stays the same.
161
+ // Strip the final -{hash} segment and search by prefix.
162
+ const lastDash = functionName.lastIndexOf('-');
163
+ if (lastDash > 0) {
164
+ const basePrefix = `/aws/lambda/${functionName.slice(0, lastDash)}`;
165
+ try {
166
+ const res = await client.send(
167
+ new DescribeLogGroupsCommand({
168
+ logGroupNamePrefix: basePrefix,
169
+ limit: 5,
170
+ }),
171
+ );
172
+ if (res.logGroups?.length) {
173
+ // Return the most recently created matching log group
174
+ const sorted = res.logGroups
175
+ .filter(lg => lg.logGroupName)
176
+ .sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
177
+ if (sorted.length > 0) {
178
+ return sorted[0].logGroupName!;
179
+ }
180
+ }
181
+ } catch {
182
+ // Fall through to conventional name
183
+ }
184
+ }
185
+
186
+ return conventional;
133
187
  }
134
188
 
135
189
  /**
@@ -25,10 +25,7 @@ interface DiagResult {
25
25
  cacheStatus?: string;
26
26
  cacheAge?: string;
27
27
  cacheControl?: string;
28
- bundleKey?: string;
29
- updateId?: string;
30
- runtimeVersion?: string;
31
- releaseCreated?: string;
28
+ serverDebug?: string;
32
29
  }
33
30
 
34
31
  interface Manifest {
@@ -38,13 +35,6 @@ interface Manifest {
38
35
  storagePrefix?: string;
39
36
  }
40
37
 
41
- function extractStoragePrefix(bundleKey: string): string | null {
42
- // Bundle key format: releases/{branch}/{version}/{groupId}/{platform}/bundle.tar.br
43
- // Storage prefix: releases/{branch}/{version}/{groupId}
44
- const match = bundleKey.match(/^(.+)\/web\/bundle\.tar\.br$/);
45
- return match?.[1] ?? null;
46
- }
47
-
48
38
  export async function diagCommand(
49
39
  positionalUrl: string | undefined,
50
40
  flags: Record<string, string>,
@@ -95,10 +85,7 @@ export async function diagCommand(
95
85
  cacheStatus: response.headers.get('x-cache') ?? undefined,
96
86
  cacheAge: response.headers.get('age') ?? undefined,
97
87
  cacheControl: response.headers.get('cache-control') ?? undefined,
98
- bundleKey: response.headers.get('x-bundle-key') ?? undefined,
99
- updateId: response.headers.get('x-update-id') ?? undefined,
100
- runtimeVersion: response.headers.get('x-runtime-version') ?? undefined,
101
- releaseCreated: response.headers.get('x-release-created') ?? undefined,
88
+ serverDebug: response.headers.get('x-server-debug') ?? undefined,
102
89
  };
103
90
 
104
91
  // Fetch full HTML if hydration analysis requested
@@ -148,20 +135,10 @@ export async function diagCommand(
148
135
  }
149
136
 
150
137
  // Served release info
151
- if (diag.bundleKey) {
152
- console.log(` Bundle Key: ${diag.bundleKey}`);
153
- }
154
- if (diag.updateId) {
155
- console.log(` Update ID: ${diag.updateId}`);
156
- }
157
- if (diag.runtimeVersion) {
158
- console.log(` Runtime Version: ${diag.runtimeVersion}`);
159
- }
160
- if (diag.releaseCreated) {
161
- console.log(` Release Created: ${diag.releaseCreated}`);
162
- }
163
- if (!diag.bundleKey && !diag.updateId) {
164
- warn(' No release headers found. Update @everystack/server to get version headers on SSR responses.');
138
+ if (diag.serverDebug) {
139
+ console.log(` Serving Release: ${diag.serverDebug}`);
140
+ } else {
141
+ warn(' No X-Server-Debug header found. Update @everystack/server to get release timestamp on SSR responses.');
165
142
  }
166
143
  console.log('');
167
144
 
@@ -191,27 +168,15 @@ export async function diagCommand(
191
168
  // 4. Version comparison
192
169
  if (!manifest) {
193
170
  warn(' Cannot compare: no manifest available.');
194
- } else if (diag.updateId && manifest.updateId) {
195
- // Primary comparison: updateId
196
- if (diag.updateId === manifest.updateId) {
171
+ } else if (diag.serverDebug && manifest.createdAt) {
172
+ if (diag.serverDebug === manifest.createdAt) {
197
173
  success(' Version match: serving the latest release');
198
174
  } else {
199
- fail(` Version mismatch: serving ${diag.updateId}, latest is ${manifest.updateId}`);
175
+ fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.createdAt}`);
200
176
  info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
201
177
  }
202
- } else if (diag.bundleKey && manifest.storagePrefix) {
203
- // Fallback comparison: storagePrefix from bundle key
204
- const servedPrefix = extractStoragePrefix(diag.bundleKey);
205
- if (servedPrefix && servedPrefix === manifest.storagePrefix) {
206
- success(' Version match: serving the latest release (matched via bundle key)');
207
- } else if (servedPrefix) {
208
- fail(` Version mismatch: serving ${servedPrefix}, latest is ${manifest.storagePrefix}`);
209
- info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
210
- } else {
211
- warn(' Cannot determine served version from bundle key.');
212
- }
213
178
  } else {
214
- warn(' Cannot compare: missing version headers. Update @everystack/server to emit release metadata.');
179
+ warn(' Cannot compare: missing X-Server-Debug header or manifest timestamp.');
215
180
  }
216
181
 
217
182
  console.log('');
@@ -13,8 +13,11 @@
13
13
  * Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
14
14
  */
15
15
 
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
16
18
  import { step, success, fail, info, warn } from '../output.js';
17
19
  import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
20
+ import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
18
21
 
19
22
  interface SecretsContext {
20
23
  appName: string;
@@ -112,6 +115,101 @@ async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
112
115
  success(`${name} removed from stage "${ctx.stage}"`);
113
116
  }
114
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Import
120
+ // ---------------------------------------------------------------------------
121
+
122
+ async function importSecrets(
123
+ ctx: SecretsContext,
124
+ filePath: string,
125
+ flags: Record<string, string>,
126
+ ): Promise<void> {
127
+ step(`Importing secrets from ${filePath}...`);
128
+
129
+ let content: string;
130
+ try {
131
+ content = await fs.readFile(path.resolve(filePath), 'utf8');
132
+ } catch (err: any) {
133
+ if (err.code === 'ENOENT') {
134
+ fail(`File not found: ${filePath}`);
135
+ } else {
136
+ fail(`Could not read file: ${err.message}`);
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ const parsed = parseDotenv(content);
142
+ const parsedCount = Object.keys(parsed).length;
143
+
144
+ if (parsedCount === 0) {
145
+ warn(`No secrets found in ${filePath}`);
146
+ return;
147
+ }
148
+
149
+ let merged: Record<string, string>;
150
+
151
+ if (flags.replace === 'true') {
152
+ // Replace mode: parsed entries become the entire secrets map
153
+ const existing = await loadSstSecrets(ctx);
154
+ const existingCount = Object.keys(existing).length;
155
+ if (existingCount > 0) {
156
+ warn(`Replacing all ${existingCount} existing secret(s)`);
157
+ }
158
+ merged = parsed;
159
+ } else {
160
+ // Merge mode (default): add new keys, overwrite matching keys, preserve the rest
161
+ const existing = await loadSstSecrets(ctx);
162
+ const existingKeys = new Set(Object.keys(existing));
163
+ let updated = 0;
164
+ for (const key of Object.keys(parsed)) {
165
+ if (existingKeys.has(key)) updated++;
166
+ }
167
+ merged = { ...existing, ...parsed };
168
+ const newCount = parsedCount - updated;
169
+ const detail = updated > 0 ? ` (${newCount} new, ${updated} updated)` : '';
170
+ await putSstSecrets(ctx, merged);
171
+ success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"${detail}`);
172
+ return;
173
+ }
174
+
175
+ await putSstSecrets(ctx, merged);
176
+ success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"`);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Export
181
+ // ---------------------------------------------------------------------------
182
+
183
+ async function exportSecrets(ctx: SecretsContext, filePath: string | undefined): Promise<void> {
184
+ if (filePath) {
185
+ step(`Exporting secrets from ${ctx.appName}/${ctx.stage}...`);
186
+ }
187
+
188
+ const secrets = await loadSstSecrets(ctx);
189
+ const count = Object.keys(secrets).length;
190
+
191
+ if (count === 0) {
192
+ warn('No secrets to export');
193
+ return;
194
+ }
195
+
196
+ const serialized = serializeDotenv(secrets);
197
+
198
+ if (filePath) {
199
+ const resolved = path.resolve(filePath);
200
+ try {
201
+ await fs.writeFile(resolved, serialized, 'utf8');
202
+ } catch (err: any) {
203
+ fail(`Could not write file: ${err.message}`);
204
+ process.exit(1);
205
+ }
206
+ success(`${count} secret(s) exported to ${filePath}`);
207
+ } else {
208
+ // Stdout mode: raw output, pipeable
209
+ process.stdout.write(serialized);
210
+ }
211
+ }
212
+
115
213
  // ---------------------------------------------------------------------------
116
214
  // Command entry point
117
215
  // ---------------------------------------------------------------------------
@@ -174,6 +272,23 @@ export async function secretsCommand(
174
272
  break;
175
273
  }
176
274
 
275
+ case 'import': {
276
+ const filePath = positionalArgs[0];
277
+ if (!filePath) {
278
+ fail('Missing file path');
279
+ info('Usage: everystack secrets import <file> --stage <stage> [--replace]');
280
+ process.exit(1);
281
+ }
282
+ await importSecrets(ctx, filePath, flags);
283
+ break;
284
+ }
285
+
286
+ case 'export': {
287
+ const filePath = positionalArgs[0]; // optional — stdout if omitted
288
+ await exportSecrets(ctx, filePath);
289
+ break;
290
+ }
291
+
177
292
  default:
178
293
  fail(`Unknown secrets subcommand: ${subcommand}`);
179
294
  printSecretsHelp();
@@ -195,12 +310,21 @@ Usage:
195
310
  everystack secrets get <name> --stage <name> Print a secret value
196
311
  everystack secrets set <name> <value> --stage <name> Set or update a secret
197
312
  everystack secrets remove <name> --stage <name> Delete a secret
313
+ everystack secrets import <file> --stage <name> Import secrets from .env file (merge)
314
+ everystack secrets export [file] --stage <name> Export secrets to .env file (or stdout)
198
315
 
199
316
  Examples:
200
317
  everystack secrets list --stage production
201
318
  everystack secrets set MAPBOX_API_KEY "pk.abc123" --stage production
202
319
  everystack secrets get JWT_SECRET --stage dev
203
320
  everystack secrets remove OLD_KEY --stage dev
321
+ everystack secrets import .env.production --stage production
322
+ everystack secrets import .env --stage dev --replace
323
+ everystack secrets export --stage dev > .env.local
324
+ everystack secrets export secrets.env --stage production
325
+
326
+ Flags:
327
+ --replace (import only) Replace all secrets instead of merging
204
328
 
205
329
  Interop:
206
330
  Secrets are stored in the SST Ion S3 state backend with AES-256-GCM encryption.
@@ -17,7 +17,6 @@ export interface UpdateFlags {
17
17
  branch?: string;
18
18
  message?: string;
19
19
  platform?: string;
20
- environment?: string;
21
20
  export?: string;
22
21
  }
23
22
 
@@ -28,18 +27,13 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
28
27
  // (e.g. localhost overrides → LAN IP) to leak into the manifest.
29
28
  process.env.EVERYSTACK_UPDATE = '1';
30
29
 
31
- // Resolve ENVIRONMENT before app.config.js evaluation so the manifest
32
- // snapshots the correct API URLs and feature flags for the target.
33
- // Priority: --environment flag > eas.json channel match > existing env.
34
- if (flags.environment) {
35
- process.env.ENVIRONMENT ||= flags.environment;
36
- } else if (!process.env.ENVIRONMENT) {
37
- // Derive ENVIRONMENT from eas.json by matching --channel to a build profile.
38
- // EAS profiles define ENVIRONMENT in their env blocks (e.g. production → "production").
39
- const channel = flags.channel || flags.branch || flags.stage || 'production';
40
- const easEnv = resolveEasEnvironment(channel);
41
- if (easEnv) {
42
- process.env.ENVIRONMENT = easEnv;
30
+ // stage === environment. Set ENVIRONMENT before app.config.js evaluation
31
+ // so the manifest snapshots the correct API URLs and feature flags.
32
+ // --stage is the canonical source; fall back to --channel/--branch.
33
+ if (!process.env.ENVIRONMENT) {
34
+ const stage = flags.stage || flags.channel || flags.branch;
35
+ if (stage) {
36
+ process.env.ENVIRONMENT = stage;
43
37
  }
44
38
  }
45
39
 
@@ -61,9 +55,9 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
61
55
  }
62
56
 
63
57
  // Inject SST secrets into process.env so app.config.js / Metro can read them.
64
- // The CLI injects ALL secrets — the app's deny-by-default filter (e.g.
65
- // expo-secrets.config.js) is the security boundary that gates what reaches
66
- // the bundle. This avoids requiring `sst shell` in deploy scripts.
58
+ // The CLI injects ALL secrets — @everystack/cli/env is the deny-by-default
59
+ // filter that gates what reaches the bundle (see env.config.js tiers).
60
+ // This avoids requiring `sst shell` in deploy scripts.
67
61
  try {
68
62
  const { parseAppName } = await import('../discover.js');
69
63
  const appName = await parseAppName();
@@ -619,40 +613,6 @@ async function createArchive(sourceDir: string, archivePath: string): Promise<vo
619
613
  await tarExit;
620
614
  }
621
615
 
622
- /**
623
- * Read eas.json and find ENVIRONMENT for a given channel.
624
- * Matches --channel to a build profile's `channel` field, then reads
625
- * ENVIRONMENT from that profile's env block. Falls back to profile name
626
- * match (e.g. --channel production matches the "production" profile).
627
- * @internal Exported for testing.
628
- */
629
- export function resolveEasEnvironment(channel: string, cwd?: string): string | undefined {
630
- try {
631
- const easPath = path.resolve(cwd || process.cwd(), 'eas.json');
632
- // Synchronous read — runs once before export, performance irrelevant.
633
- const eas = JSON.parse(require('fs').readFileSync(easPath, 'utf8'));
634
- const profiles = eas?.build;
635
- if (!profiles || typeof profiles !== 'object') return undefined;
636
-
637
- // First pass: match on explicit channel field
638
- for (const [, profile] of Object.entries(profiles)) {
639
- const p = profile as any;
640
- if (p?.channel === channel && p?.env?.ENVIRONMENT) {
641
- return p.env.ENVIRONMENT;
642
- }
643
- }
644
-
645
- // Second pass: match on profile name (e.g. "production" profile for --channel production)
646
- if (profiles[channel]?.env?.ENVIRONMENT) {
647
- return profiles[channel].env.ENVIRONMENT;
648
- }
649
-
650
- return undefined;
651
- } catch {
652
- return undefined;
653
- }
654
- }
655
-
656
616
  /**
657
617
  * Extract expo-router SSR flags from the resolved app config.
658
618
  * Checks the plugins array for the expo-router entry.
@@ -145,7 +145,7 @@ export async function discoverFunction(
145
145
  } = await import('@aws-sdk/client-lambda');
146
146
 
147
147
  const client = new LambdaClient({ region });
148
- const needle = `${prefix}${resourceName}-`.toLowerCase();
148
+ const needle = `${prefix}${resourceName}`.toLowerCase();
149
149
 
150
150
  let marker: string | undefined;
151
151
 
@@ -259,7 +259,9 @@ export async function discoverConfig(
259
259
  discoverLambda(region, prefix),
260
260
  discoverBuckets(region, prefix),
261
261
  discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
262
- discoverFunction(region, prefix, 'WorkerFunction').catch(() => undefined),
262
+ discoverFunction(region, prefix, 'WorkerFunction')
263
+ .then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
264
+ .catch(() => undefined),
263
265
  ]);
264
266
 
265
267
  // Distribution discovery is best-effort (not critical for most CLI operations)
package/src/cli/index.ts CHANGED
@@ -167,8 +167,8 @@ function printHelp() {
167
167
  everystack - CLI for Expo apps on everystack
168
168
 
169
169
  Usage:
170
- everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--environment <name>] [--skip-export]
171
- everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--environment <name>] [--skip-export]
170
+ everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
171
+ everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
172
172
  everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
173
173
  everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
174
174
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
@@ -196,6 +196,8 @@ Usage:
196
196
  everystack secrets get <name> --stage <name> Print a secret value
197
197
  everystack secrets set <name> <value> --stage <name> Set or update a secret
198
198
  everystack secrets remove <name> --stage <name> Delete a secret
199
+ everystack secrets import <file> --stage <name> [--replace] Import secrets from .env file
200
+ everystack secrets export [file] --stage <name> Export secrets to .env format
199
201
 
200
202
  Stage resolution:
201
203
  --stage <name> Discover AWS resources for the given stage by querying AWS APIs.
@@ -204,8 +206,7 @@ Stage resolution:
204
206
 
205
207
  Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
206
208
 
207
- --environment <name> Set ENVIRONMENT for app.config.js evaluation (e.g. development, staging, production).
208
- Use this when your SST stage name differs from your EAS build profile name.
209
+ --stage also sets ENVIRONMENT for app.config.js evaluation. stage === environment.
209
210
 
210
211
  Auth:
211
212
  All commands use AWS IAM credentials (default credential chain).
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pure .env parser and serializer. No dependencies.
3
+ *
4
+ * Supports:
5
+ * KEY=value
6
+ * KEY="double quoted value"
7
+ * KEY='single quoted value'
8
+ * # comments (full-line)
9
+ * Empty/whitespace-only lines (skipped)
10
+ * export KEY=value (optional prefix)
11
+ * Escape sequences in double quotes: \n, \r, \t, \\, \"
12
+ * UTF-8 BOM stripping
13
+ */
14
+
15
+ /**
16
+ * Parse a .env-formatted string into key-value pairs.
17
+ * Duplicate keys: last one wins.
18
+ */
19
+ export function parseDotenv(content: string): Record<string, string> {
20
+ const result: Record<string, string> = {};
21
+
22
+ // Strip UTF-8 BOM
23
+ const clean = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
24
+
25
+ for (const rawLine of clean.split('\n')) {
26
+ const line = rawLine.trim();
27
+
28
+ // Skip empty lines and comments
29
+ if (!line || line.startsWith('#')) continue;
30
+
31
+ // Strip optional `export ` prefix
32
+ const stripped = line.startsWith('export ') ? line.slice(7) : line;
33
+
34
+ // Find first `=`
35
+ const eqIndex = stripped.indexOf('=');
36
+ if (eqIndex === -1) continue;
37
+
38
+ const key = stripped.slice(0, eqIndex).trim();
39
+ if (!key) continue;
40
+
41
+ let value = stripped.slice(eqIndex + 1);
42
+
43
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
44
+ // Double-quoted: strip quotes and process escapes
45
+ value = value.slice(1, -1);
46
+ // Process escapes — use a single pass to avoid double-expansion
47
+ value = value.replace(/\\([nrt"\\])/g, (_, ch) => {
48
+ switch (ch) {
49
+ case 'n': return '\n';
50
+ case 'r': return '\r';
51
+ case 't': return '\t';
52
+ case '"': return '"';
53
+ case '\\': return '\\';
54
+ default: return ch;
55
+ }
56
+ });
57
+ } else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
58
+ // Single-quoted: strip quotes, literal (no escape processing)
59
+ value = value.slice(1, -1);
60
+ } else {
61
+ // Unquoted: trim whitespace
62
+ value = value.trim();
63
+ }
64
+
65
+ result[key] = value;
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Serialize a key-value map to .env format.
73
+ *
74
+ * Keys are sorted alphabetically for deterministic output.
75
+ * Values are double-quoted when they contain whitespace, quotes, #, backslashes, or newlines.
76
+ */
77
+ export function serializeDotenv(secrets: Record<string, string>): string {
78
+ const keys = Object.keys(secrets).sort();
79
+ if (keys.length === 0) return '';
80
+
81
+ const lines: string[] = [];
82
+
83
+ for (const key of keys) {
84
+ const value = secrets[key];
85
+
86
+ if (needsQuoting(value)) {
87
+ const escaped = value
88
+ .replace(/\\/g, '\\\\')
89
+ .replace(/"/g, '\\"')
90
+ .replace(/\n/g, '\\n')
91
+ .replace(/\r/g, '\\r')
92
+ .replace(/\t/g, '\\t');
93
+ lines.push(`${key}="${escaped}"`);
94
+ } else {
95
+ lines.push(`${key}=${value}`);
96
+ }
97
+ }
98
+
99
+ return lines.join('\n') + '\n';
100
+ }
101
+
102
+ function needsQuoting(value: string): boolean {
103
+ if (value === '') return true;
104
+ return /[\s"'#\\]/.test(value) || value.includes('\n');
105
+ }
package/src/env.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @everystack/cli/env — Deny-by-default environment variable filtering.
3
+ *
4
+ * One import in app.config.js. Auto-discovers env.config.js.
5
+ * Detects build surface, filters secrets by tier, sets EXPO_PUBLIC_* vars.
6
+ *
7
+ * Usage:
8
+ * const { extra } = require('@everystack/cli/env').load();
9
+ * module.exports = { expo: { extra } };
10
+ *
11
+ * With explicit config path:
12
+ * const { extra } = require('@everystack/cli/env').load({ path: './env.config' });
13
+ *
14
+ * Multiple stages. Four buckets. One gate. One file.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import path from 'path';
20
+ import fs from 'fs';
21
+
22
+ /**
23
+ * Environment tier configuration.
24
+ *
25
+ * Each key is declared in exactly one tier:
26
+ * - server: Lambda runtime only. NEVER in any client bundle.
27
+ * - build: CI/build tooling only (source maps, etc). NEVER in any client bundle.
28
+ * - mobile: Native binary (iOS/Android). Excluded from web bundles.
29
+ * - web: Safe for all client bundles. Gets EXPO_PUBLIC_* for web delivery.
30
+ */
31
+ export interface EnvConfig {
32
+ /** Lambda/server secrets. NEVER in any client bundle. */
33
+ server?: string[];
34
+ /** CI/build-time only. NEVER in any client bundle. */
35
+ build?: string[];
36
+ /** Native binary only. Excluded from web bundles. */
37
+ mobile?: string[];
38
+ /** Safe for all client bundles. Gets EXPO_PUBLIC_* for web delivery. */
39
+ web?: string[];
40
+ }
41
+
42
+ /** Build surface — determines which tiers pass through the filter. */
43
+ export type Surface = 'local' | 'native' | 'web';
44
+
45
+ /**
46
+ * Detect the current build surface from environment signals.
47
+ *
48
+ * EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
49
+ * EAS_UPDATE -> native (EAS OTA update)
50
+ * EVERYSTACK_UPDATE -> web (everystack update)
51
+ * none of the above -> local (pnpm dev)
52
+ */
53
+ export function detectSurface(): Surface {
54
+ if (process.env.EAS_BUILD || process.env.EAS_BUILD_PLATFORM || process.env.EAS_UPDATE) {
55
+ return 'native';
56
+ }
57
+ if (process.env.EVERYSTACK_UPDATE) {
58
+ return 'web';
59
+ }
60
+ return 'local';
61
+ }
62
+
63
+ /**
64
+ * Load env config from a file path or auto-discover env.config.js in cwd.
65
+ */
66
+ export function loadEnvConfig(configPath?: string): EnvConfig {
67
+ const resolved = configPath
68
+ ? path.resolve(configPath)
69
+ : findEnvConfig();
70
+
71
+ // Synchronous require — same pattern used throughout the CLI package.
72
+ // Works in Metro (CJS interop), tsx (SST), and Node CJS.
73
+ delete require.cache?.[resolved];
74
+ const mod = require(resolved);
75
+ return mod.default || mod;
76
+ }
77
+
78
+ /**
79
+ * Auto-discover env.config.js in the current working directory.
80
+ */
81
+ function findEnvConfig(): string {
82
+ const name = 'env.config.js';
83
+ const fullPath = path.resolve(process.cwd(), name);
84
+ if (fs.existsSync(fullPath)) {
85
+ return fullPath;
86
+ }
87
+ throw new Error(
88
+ 'No env.config.js found. Create one:\n\n'
89
+ + ' module.exports = {\n'
90
+ + ' server: [],\n'
91
+ + ' build: [],\n'
92
+ + ' mobile: [],\n'
93
+ + ' web: [],\n'
94
+ + ' };\n'
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Filter process.env through tier allowlists based on the detected surface.
100
+ *
101
+ * Deny-by-default: undeclared keys are dropped.
102
+ * Server and build tiers are NEVER passed through, even if duplicated in other tiers.
103
+ *
104
+ * Surface behavior:
105
+ * local -> web + mobile tiers pass
106
+ * native -> web + mobile tiers pass
107
+ * web -> web tier only
108
+ */
109
+ export function filterEnv(surface: Surface, config: EnvConfig): Record<string, string> {
110
+ // Build the allowlist: web tier always passes
111
+ const allowed = new Set(config.web || []);
112
+
113
+ // Mobile tier: passes on native and local, blocked on web
114
+ if (surface !== 'web') {
115
+ for (const k of (config.mobile || [])) {
116
+ allowed.add(k);
117
+ }
118
+ }
119
+
120
+ // server and build tiers are NEVER added to the allowlist.
121
+ // Actively remove them in case a key appears in multiple tiers.
122
+ const blocked = new Set([
123
+ ...(config.server || []),
124
+ ...(config.build || []),
125
+ ]);
126
+ for (const k of blocked) {
127
+ allowed.delete(k);
128
+ }
129
+
130
+ // Collect values for allowed keys from process.env
131
+ const result: Record<string, string> = {};
132
+ for (const key of allowed) {
133
+ if (process.env[key] !== undefined) {
134
+ result[key] = process.env[key]!;
135
+ }
136
+ }
137
+
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Set EXPO_PUBLIC_* process.env vars for web delivery.
143
+ *
144
+ * Constants.expoConfig.extra does NOT work on web — Metro never sets
145
+ * APP_MANIFEST, so Constants.expoConfig is null in the browser. The only
146
+ * way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
147
+ * which Metro inlines as literal strings at compile time.
148
+ *
149
+ * Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
150
+ * delivery path (extra on native, EXPO_PUBLIC_* on web).
151
+ */
152
+ export function setExpoPublicVars(filtered: Record<string, string>): void {
153
+ for (const [key, value] of Object.entries(filtered)) {
154
+ process.env[`EXPO_PUBLIC_${key}`] = value;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Load and filter environment variables for the current build surface.
160
+ *
161
+ * Auto-discovers env.config.js in the current working directory,
162
+ * or accepts an explicit path. Detects the build surface, filters
163
+ * process.env through the tier allowlists, sets EXPO_PUBLIC_* vars,
164
+ * and returns the filtered object for expo.extra.
165
+ *
166
+ * @example
167
+ * // app.config.js — auto-discover env.config.js
168
+ * const { extra } = require('@everystack/cli/env').load();
169
+ * module.exports = { expo: { extra } };
170
+ *
171
+ * @example
172
+ * // app.config.js — explicit path
173
+ * const { extra } = require('@everystack/cli/env').load({ path: './env.config' });
174
+ */
175
+ export function load(options?: { path?: string }): { extra: Record<string, string> } {
176
+ const config = loadEnvConfig(options?.path);
177
+ const surface = detectSurface();
178
+ const filtered = filterEnv(surface, config);
179
+ setExpoPublicVars(filtered);
180
+ return { extra: filtered };
181
+ }
package/src/sst.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @everystack/cli/sst — Shared env config for SST infrastructure.
3
+ *
4
+ * Reads the same env.config.js that @everystack/cli/env uses,
5
+ * ensuring sst.config.ts and app.config.js stay in sync.
6
+ *
7
+ * Usage in sst.config.ts:
8
+ * import { loadEnvConfig } from '@everystack/cli/sst';
9
+ * const env = loadEnvConfig();
10
+ * // env.server = ['DATABASE_URL', 'JWT_SECRET']
11
+ *
12
+ * @module
13
+ */
14
+
15
+ export { loadEnvConfig, type EnvConfig } from './env.js';