@everystack/cli 0.2.31 → 0.2.33

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.31",
3
+ "version": "0.2.33",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -15,7 +15,7 @@ import * as readline from 'node:readline';
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as os from 'node:os';
18
- import { resolveConfig, type CliConfig } from '../config.js';
18
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
19
19
  import { invokeAction } from '../aws.js';
20
20
  import { success, fail, info } from '../output.js';
21
21
  import { formatResult } from '../utils/table.js';
@@ -189,7 +189,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
189
189
  process.exit(1);
190
190
  }
191
191
 
192
- success(`Connected to ${stage} (${config.region}) — ${config.apiFunctionName}`);
192
+ success(`Connected to ${stage} (${config.region}) — ${opsFunction(config)}`);
193
193
  if (sandbox) {
194
194
  info('Sandbox mode: all changes will be rolled back after each expression.');
195
195
  }
@@ -219,7 +219,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
219
219
  if (cachedMeta) return cachedMeta;
220
220
  cachedMeta = await invokeAction(
221
221
  config.region,
222
- config.apiFunctionName,
222
+ opsFunction(config),
223
223
  'console:meta',
224
224
  {},
225
225
  ) as ConsoleMeta;
@@ -342,7 +342,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
342
342
 
343
343
  const loginResult: any = await invokeAction(
344
344
  config.region,
345
- config.apiFunctionName,
345
+ opsFunction(config),
346
346
  'console',
347
347
  loginPayload,
348
348
  );
@@ -423,7 +423,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
423
423
 
424
424
  const result: any = await invokeAction(
425
425
  config.region,
426
- config.apiFunctionName,
426
+ opsFunction(config),
427
427
  'console',
428
428
  payload,
429
429
  );
@@ -5,7 +5,7 @@
5
5
  * No HTTP endpoints, no shared secrets — IAM is the authorization layer.
6
6
  */
7
7
 
8
- import { resolveConfig } from '../config.js';
8
+ import { resolveConfig, opsFunction } from '../config.js';
9
9
  import { invokeAction } from '../aws.js';
10
10
  import { step, success, fail, info } from '../output.js';
11
11
 
@@ -19,13 +19,13 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
19
19
  process.exit(1);
20
20
  }
21
21
 
22
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
22
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
23
23
  step('Running migrations via Lambda invoke...');
24
24
 
25
25
  try {
26
26
  const result: any = await invokeAction(
27
27
  config.region,
28
- config.apiFunctionName,
28
+ opsFunction(config),
29
29
  'migrate',
30
30
  {},
31
31
  );
@@ -51,13 +51,13 @@ export async function dbSeedCommand(flags: Record<string, string>): Promise<void
51
51
  process.exit(1);
52
52
  }
53
53
 
54
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
54
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
55
55
  step('Running seed via Lambda invoke...');
56
56
 
57
57
  try {
58
58
  const result: any = await invokeAction(
59
59
  config.region,
60
- config.apiFunctionName,
60
+ opsFunction(config),
61
61
  'seed',
62
62
  {},
63
63
  );
@@ -87,14 +87,14 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
87
87
  process.exit(1);
88
88
  }
89
89
 
90
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
90
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
91
91
  info('This will DROP every schema (auth, dist, logs, ops, public) and re-run migrations.');
92
92
  step('Resetting database via Lambda invoke...');
93
93
 
94
94
  try {
95
95
  const result: any = await invokeAction(
96
96
  config.region,
97
- config.apiFunctionName,
97
+ opsFunction(config),
98
98
  'db:reset',
99
99
  {},
100
100
  );
@@ -120,7 +120,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
120
120
  process.exit(1);
121
121
  }
122
122
 
123
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
123
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
124
124
 
125
125
  // If -c flag is provided, execute SQL via Lambda and return results
126
126
  if (flags.c) {
@@ -128,7 +128,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
128
128
  try {
129
129
  const result: any = await invokeAction(
130
130
  config.region,
131
- config.apiFunctionName,
131
+ opsFunction(config),
132
132
  'db:query',
133
133
  { sql: flags.c },
134
134
  );
package/src/cli/config.ts CHANGED
@@ -15,6 +15,8 @@ export interface CliConfig {
15
15
  baseUrl: string;
16
16
  region: string;
17
17
  apiFunctionName: string;
18
+ /** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
19
+ opsFunctionName?: string;
18
20
  imageFunctionName?: string;
19
21
  workerFunctionName?: string;
20
22
  updatesBucket: string;
@@ -24,11 +26,17 @@ export interface CliConfig {
24
26
  distributionId?: string;
25
27
  }
26
28
 
29
+ /** Operator invoke target — dedicated ops function when deployed, else the API function. */
30
+ export function opsFunction(config: CliConfig): string {
31
+ return config.opsFunctionName ?? config.apiFunctionName;
32
+ }
33
+
27
34
  interface SstOutputs {
28
35
  routerUrl?: string;
29
36
  apiUrl?: string;
30
37
  imageUrl?: string;
31
38
  apiFunctionName?: string;
39
+ opsFunctionName?: string;
32
40
  imageFunctionName?: string;
33
41
  workerFunctionName?: string;
34
42
  updatesBucket?: string;
@@ -99,6 +107,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
99
107
  baseUrl: outputs.routerUrl,
100
108
  region,
101
109
  apiFunctionName: outputs.apiFunctionName,
110
+ opsFunctionName: outputs.opsFunctionName,
102
111
  imageFunctionName: outputs.imageFunctionName,
103
112
  workerFunctionName: outputs.workerFunctionName,
104
113
  updatesBucket: outputs.updatesBucket,
@@ -19,6 +19,7 @@ interface DiscoveredConfig {
19
19
  baseUrl: string;
20
20
  region: string;
21
21
  apiFunctionName: string;
22
+ opsFunctionName?: string;
22
23
  imageFunctionName?: string;
23
24
  workerFunctionName?: string;
24
25
  updatesBucket: string;
@@ -261,10 +262,11 @@ export async function discoverConfig(
261
262
  const prefix = `${appName}-${stage}-`;
262
263
 
263
264
  // Run CF function + Lambda + S3 discovery in parallel
264
- const [cfResult, lambdaResult, bucketsResult, imageFn, workerFn] = await Promise.all([
265
+ const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
265
266
  discoverCfFunction(region, prefix),
266
267
  discoverLambda(region, prefix),
267
268
  discoverBuckets(region, prefix),
269
+ discoverFunction(region, prefix, 'OpsFunction').catch(() => undefined),
268
270
  discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
269
271
  discoverFunction(region, prefix, 'WorkerFunction')
270
272
  .then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
@@ -311,6 +313,7 @@ export async function discoverConfig(
311
313
  baseUrl: lambdaResult.routerUrl || '',
312
314
  region,
313
315
  apiFunctionName: lambdaResult.functionName,
316
+ opsFunctionName: opsFn,
314
317
  imageFunctionName: imageFn,
315
318
  workerFunctionName: workerFn,
316
319
  updatesBucket: bucketsResult.updatesBucket,
@@ -326,13 +329,14 @@ export async function discoverConfig(
326
329
  // ---------------------------------------------------------------------------
327
330
 
328
331
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
329
- const CACHE_VERSION = 2; // Bump when CachedOutputs schema changes to invalidate stale caches
332
+ const CACHE_VERSION = 3; // Bump when CachedOutputs schema changes to invalidate stale caches
330
333
 
331
334
  interface CachedOutputs {
332
335
  _cachedAt: number;
333
336
  _version?: number;
334
337
  routerUrl?: string;
335
338
  apiFunctionName?: string;
339
+ opsFunctionName?: string;
336
340
  imageFunctionName?: string;
337
341
  workerFunctionName?: string;
338
342
  updatesBucket?: string;
@@ -359,6 +363,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
359
363
  baseUrl: cached.routerUrl || '',
360
364
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
361
365
  apiFunctionName: cached.apiFunctionName,
366
+ opsFunctionName: cached.opsFunctionName,
362
367
  imageFunctionName: cached.imageFunctionName,
363
368
  workerFunctionName: cached.workerFunctionName,
364
369
  updatesBucket: cached.updatesBucket,
@@ -378,6 +383,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
378
383
  _version: CACHE_VERSION,
379
384
  routerUrl: config.baseUrl,
380
385
  apiFunctionName: config.apiFunctionName,
386
+ opsFunctionName: config.opsFunctionName,
381
387
  imageFunctionName: config.imageFunctionName,
382
388
  workerFunctionName: config.workerFunctionName,
383
389
  updatesBucket: config.updatesBucket,
package/src/env.js CHANGED
@@ -120,14 +120,25 @@ function filterEnv(surface, config) {
120
120
  * way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
121
121
  * which Metro inlines as literal strings at compile time.
122
122
  *
123
- * Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
124
- * delivery path (extra on native, EXPO_PUBLIC_* on web).
123
+ * When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
124
+ * The inline path exists solely because web has no `extra` so it must
125
+ * carry only web-tier values. Mobile-tier keys are delivered via `extra`
126
+ * on native and must never reach the Metro inline path: a mis-surfaced
127
+ * web export would otherwise bake them into public JS.
125
128
  */
126
- function setExpoPublicVars(filtered) {
129
+ function setExpoPublicVars(filtered, webKeys) {
130
+ const allowed = webKeys ? new Set(webKeys) : null;
127
131
  for (const [key, value] of Object.entries(filtered)) {
132
+ if (allowed && !allowed.has(key))
133
+ continue;
128
134
  process.env[`EXPO_PUBLIC_${key}`] = value;
129
135
  }
130
136
  }
137
+ /** Stages where the permissive `local` tier set is acceptable. */
138
+ const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
139
+ function isDeployableStage(stage) {
140
+ return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
141
+ }
131
142
  /**
132
143
  * Load and filter environment variables for the current build surface.
133
144
  *
@@ -148,7 +159,23 @@ function setExpoPublicVars(filtered) {
148
159
  function load(options) {
149
160
  const config = loadEnvConfig(options?.path);
150
161
  const surface = detectSurface();
162
+ // Fail closed: surface fell through to `local` (the most permissive tier
163
+ // set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
164
+ // misconfigured build — refuse to bundle rather than risk shipping the
165
+ // mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
166
+ // escape hatch for debugging.
167
+ if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
168
+ if (process.env.EVERYSTACK_SURFACE === 'local') {
169
+ console.warn(`[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
170
+ + 'bundling with the local tier set (mobile keys included). Debug builds only.');
171
+ }
172
+ else {
173
+ throw new Error(`[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
174
+ + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
175
+ + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.');
176
+ }
177
+ }
151
178
  const filtered = filterEnv(surface, config);
152
- setExpoPublicVars(filtered);
179
+ setExpoPublicVars(filtered, config.web ?? []);
153
180
  return { extra: filtered };
154
181
  }
package/src/env.ts CHANGED
@@ -148,15 +148,30 @@ export function filterEnv(surface: Surface, config: EnvConfig): Record<string, s
148
148
  * way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
149
149
  * which Metro inlines as literal strings at compile time.
150
150
  *
151
- * Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
152
- * delivery path (extra on native, EXPO_PUBLIC_* on web).
151
+ * When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
152
+ * The inline path exists solely because web has no `extra` so it must
153
+ * carry only web-tier values. Mobile-tier keys are delivered via `extra`
154
+ * on native and must never reach the Metro inline path: a mis-surfaced
155
+ * web export would otherwise bake them into public JS.
153
156
  */
154
- export function setExpoPublicVars(filtered: Record<string, string>): void {
157
+ export function setExpoPublicVars(
158
+ filtered: Record<string, string>,
159
+ webKeys?: string[]
160
+ ): void {
161
+ const allowed = webKeys ? new Set(webKeys) : null;
155
162
  for (const [key, value] of Object.entries(filtered)) {
163
+ if (allowed && !allowed.has(key)) continue;
156
164
  process.env[`EXPO_PUBLIC_${key}`] = value;
157
165
  }
158
166
  }
159
167
 
168
+ /** Stages where the permissive `local` tier set is acceptable. */
169
+ const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
170
+
171
+ function isDeployableStage(stage: string | undefined): boolean {
172
+ return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
173
+ }
174
+
160
175
  /**
161
176
  * Load and filter environment variables for the current build surface.
162
177
  *
@@ -177,7 +192,28 @@ export function setExpoPublicVars(filtered: Record<string, string>): void {
177
192
  export function load(options?: { path?: string }): { extra: Record<string, string> } {
178
193
  const config = loadEnvConfig(options?.path);
179
194
  const surface = detectSurface();
195
+
196
+ // Fail closed: surface fell through to `local` (the most permissive tier
197
+ // set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
198
+ // misconfigured build — refuse to bundle rather than risk shipping the
199
+ // mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
200
+ // escape hatch for debugging.
201
+ if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
202
+ if (process.env.EVERYSTACK_SURFACE === 'local') {
203
+ console.warn(
204
+ `[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
205
+ + 'bundling with the local tier set (mobile keys included). Debug builds only.'
206
+ );
207
+ } else {
208
+ throw new Error(
209
+ `[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
210
+ + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
211
+ + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.'
212
+ );
213
+ }
214
+ }
215
+
180
216
  const filtered = filterEnv(surface, config);
181
- setExpoPublicVars(filtered);
217
+ setExpoPublicVars(filtered, config.web ?? []);
182
218
  return { extra: filtered };
183
219
  }