@everystack/cli 0.4.18 → 0.4.20

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.4.18",
3
+ "version": "0.4.20",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -76,6 +76,11 @@
76
76
  "dependencies": {
77
77
  "@aws-sdk/client-cloudfront": "3.1053.0",
78
78
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
79
+ "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
80
+ "@aws-sdk/client-lambda": "3.1053.0",
81
+ "@aws-sdk/client-s3": "3.1053.0",
82
+ "@aws-sdk/client-ssm": "3.1053.0",
83
+ "@aws-sdk/client-sts": "3.1053.0",
79
84
  "@aws-sdk/signature-v4a": "3.1048.0",
80
85
  "glob": "13.0.6",
81
86
  "node-forge": "1.4.0",
@@ -87,18 +92,13 @@
87
92
  "peerDependencies": {
88
93
  "@everystack/server": ">=0.1.0",
89
94
  "@aws-sdk/client-cloudwatch": "3.1053.0",
90
- "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
91
- "@aws-sdk/client-lambda": "3.1053.0",
92
95
  "@aws-sdk/client-rds": "3.1053.0",
93
- "@aws-sdk/client-s3": "3.1053.0",
94
- "@aws-sdk/client-ssm": "3.1053.0",
95
96
  "@aws-sdk/s3-request-presigner": "3.1053.0",
96
97
  "drizzle-orm": "0.41.0",
97
98
  "expo-updates": "55.0.21",
98
99
  "postgres": "3.4.9",
99
100
  "react": "19.2.0",
100
- "react-native": "0.83.6",
101
- "@aws-sdk/client-sts": "3.1053.0"
101
+ "react-native": "0.83.6"
102
102
  },
103
103
  "peerDependenciesMeta": {
104
104
  "postgres": {
@@ -110,21 +110,9 @@
110
110
  "@aws-sdk/client-cloudwatch": {
111
111
  "optional": true
112
112
  },
113
- "@aws-sdk/client-cloudwatch-logs": {
114
- "optional": true
115
- },
116
- "@aws-sdk/client-s3": {
117
- "optional": true
118
- },
119
- "@aws-sdk/client-lambda": {
120
- "optional": true
121
- },
122
113
  "@aws-sdk/client-rds": {
123
114
  "optional": true
124
115
  },
125
- "@aws-sdk/client-ssm": {
126
- "optional": true
127
- },
128
116
  "expo-updates": {
129
117
  "optional": true
130
118
  },
@@ -136,18 +124,11 @@
136
124
  },
137
125
  "@aws-sdk/s3-request-presigner": {
138
126
  "optional": true
139
- },
140
- "@aws-sdk/client-sts": {
141
- "optional": true
142
127
  }
143
128
  },
144
129
  "devDependencies": {
145
130
  "@aws-sdk/client-cloudwatch": "3.1053.0",
146
- "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
147
- "@aws-sdk/client-lambda": "3.1053.0",
148
131
  "@aws-sdk/client-rds": "3.1053.0",
149
- "@aws-sdk/client-s3": "3.1053.0",
150
- "@aws-sdk/client-ssm": "3.1053.0",
151
132
  "@aws-sdk/s3-request-presigner": "3.1053.0",
152
133
  "@types/jest": "29.5.14",
153
134
  "@types/node": "22.19.18",
@@ -156,8 +137,7 @@
156
137
  "drizzle-orm": "0.41.0",
157
138
  "jest": "29.7.0",
158
139
  "react": "19.2.0",
159
- "ts-jest": "29.4.9",
160
- "@aws-sdk/client-sts": "3.1053.0"
140
+ "ts-jest": "29.4.9"
161
141
  },
162
142
  "scripts": {
163
143
  "test": "jest",
@@ -24,6 +24,8 @@
24
24
  * by the command shell.
25
25
  */
26
26
 
27
+ import { importOptionalAws } from './aws.js';
28
+
27
29
  /** The SSM parameter holding the approver set — a JSON array of entries. */
28
30
  export function approverParamName(appName: string, stage: string): string {
29
31
  return `/everystack/${appName}/${stage}/db-approvers`;
@@ -89,7 +91,9 @@ export function checkDestructiveAuthority({ approvers, identity }: AuthorityInpu
89
91
 
90
92
  /** Read the approver set from SSM. Null when the parameter does not exist. */
91
93
  export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
92
- const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
94
+ const { SSMClient, GetParameterCommand } = await importOptionalAws(
95
+ () => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
96
+ );
93
97
  const client = new SSMClient({ region });
94
98
  try {
95
99
  const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
@@ -116,7 +120,9 @@ export function parseApproverValue(value: string): string[] {
116
120
 
117
121
  /** Declare (or replace) the approver set. Stored as a JSON array. */
118
122
  export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
119
- const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
123
+ const { SSMClient, PutParameterCommand } = await importOptionalAws(
124
+ () => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
125
+ );
120
126
  const client = new SSMClient({ region });
121
127
  await client.send(new PutParameterCommand({
122
128
  Name: approverParamName(appName, stage),
@@ -128,7 +134,9 @@ export async function writeApproverParam(region: string, appName: string, stage:
128
134
 
129
135
  /** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
130
136
  export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
131
- const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
137
+ const { SSMClient, DeleteParameterCommand } = await importOptionalAws(
138
+ () => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
139
+ );
132
140
  const client = new SSMClient({ region });
133
141
  try {
134
142
  await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
@@ -139,7 +147,9 @@ export async function removeApproverParam(region: string, appName: string, stage
139
147
 
140
148
  /** The caller's real identity — the STS ARN. Null when credentials can't answer. */
141
149
  export async function resolveCallerIdentity(region: string): Promise<string | null> {
142
- const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
150
+ const { STSClient, GetCallerIdentityCommand } = await importOptionalAws(
151
+ () => import('@aws-sdk/client-sts'), '@aws-sdk/client-sts', 'destructive apply authorization',
152
+ );
143
153
  const client = new STSClient({ region });
144
154
  try {
145
155
  const result = await client.send(new GetCallerIdentityCommand({}));
package/src/cli/aws.ts CHANGED
@@ -15,7 +15,9 @@ let rdsClient: InstanceType<typeof import('@aws-sdk/client-rds').RDSClient> | nu
15
15
 
16
16
  async function getS3(region: string) {
17
17
  if (!s3Client) {
18
- const { S3Client } = await import('@aws-sdk/client-s3');
18
+ const { S3Client } = await importOptionalAws(
19
+ () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'this command',
20
+ );
19
21
  s3Client = new S3Client({ region });
20
22
  }
21
23
  return s3Client;
@@ -23,12 +25,49 @@ async function getS3(region: string) {
23
25
 
24
26
  async function getLambda(region: string) {
25
27
  if (!lambdaClient) {
26
- const { LambdaClient } = await import('@aws-sdk/client-lambda');
28
+ const { LambdaClient } = await importOptionalAws(
29
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'this command',
30
+ );
27
31
  lambdaClient = new LambdaClient({ region });
28
32
  }
29
33
  return lambdaClient;
30
34
  }
31
35
 
36
+ /**
37
+ * A missing optional-peer dependency, marked so a command's catch block can print the install
38
+ * remedy INSTEAD of its default AWS/IAM advice. Blaming IAM for a missing npm package sends the
39
+ * operator to debug permissions they already have (a consumer field report).
40
+ */
41
+ export class MissingOptionalDependencyError extends Error {
42
+ readonly missing: string[];
43
+ constructor(feature: string, missing: string[]) {
44
+ const plural = missing.length > 1;
45
+ super(
46
+ `${feature} needs ${missing.join(', ')}, which ${plural ? 'are' : 'is'} not installed `
47
+ + `(optional peer ${plural ? 'dependencies' : 'dependency'}). Install ${plural ? 'them' : 'it'}:\n`
48
+ + ` pnpm add ${missing.join(' ')}`,
49
+ );
50
+ this.name = 'MissingOptionalDependencyError';
51
+ this.missing = missing;
52
+ }
53
+ }
54
+
55
+ /** True when `err` is a missing optional dependency — the signal to skip IAM advice. */
56
+ export function isMissingOptionalDependencyError(err: unknown): boolean {
57
+ return err instanceof MissingOptionalDependencyError;
58
+ }
59
+
60
+ /** Whether a caught error is Node's "package/module not found" (ESM or CJS). */
61
+ function isModuleNotFound(err: unknown): boolean {
62
+ const code = (err as { code?: string })?.code;
63
+ const message = String((err as { message?: string })?.message ?? err);
64
+ return (
65
+ code === 'ERR_MODULE_NOT_FOUND' ||
66
+ code === 'MODULE_NOT_FOUND' ||
67
+ /Cannot find (package|module)/.test(message)
68
+ );
69
+ }
70
+
32
71
  /**
33
72
  * Lazily load an optional-peer AWS SDK client, turning a missing dependency into a one-line
34
73
  * install remedy instead of a raw `ERR_MODULE_NOT_FOUND` at the call site. The `@aws-sdk/*`
@@ -44,21 +83,35 @@ export async function importOptionalAws<T>(
44
83
  try {
45
84
  return await load();
46
85
  } catch (err: unknown) {
47
- const code = (err as { code?: string })?.code;
48
- const message = String((err as { message?: string })?.message ?? err);
49
- if (
50
- code === 'ERR_MODULE_NOT_FOUND' ||
51
- code === 'MODULE_NOT_FOUND' ||
52
- /Cannot find (package|module)/.test(message)
53
- ) {
54
- throw new Error(
55
- `${feature} needs ${pkg}, which isn't installed (it's an optional peer dependency). Install it:\n pnpm add ${pkg}`,
56
- );
57
- }
86
+ if (isModuleNotFound(err)) throw new MissingOptionalDependencyError(feature, [pkg]);
58
87
  throw err;
59
88
  }
60
89
  }
61
90
 
91
+ /**
92
+ * Preflight EVERY optional-peer package a command needs, and name the FULL missing set in one
93
+ * error — not one crash at a time. A command that uses ssm+s3 (secrets) reports both missing at
94
+ * once with a single `pnpm add` line, so the operator installs everything the step needs in one
95
+ * go instead of discovering them serially through repeated failures (a consumer field report).
96
+ * Each `load` is a literal `() => import('@aws-sdk/...')` so the specifiers stay statically
97
+ * analyzable; ESM caches the module, so the real call site re-imports for free.
98
+ */
99
+ export async function ensureOptionalAws(
100
+ deps: Array<{ load: () => Promise<unknown>; pkg: string }>,
101
+ feature: string,
102
+ ): Promise<void> {
103
+ const missing: string[] = [];
104
+ for (const { load, pkg } of deps) {
105
+ try {
106
+ await load();
107
+ } catch (err: unknown) {
108
+ if (isModuleNotFound(err)) missing.push(pkg);
109
+ else throw err;
110
+ }
111
+ }
112
+ if (missing.length > 0) throw new MissingOptionalDependencyError(feature, missing);
113
+ }
114
+
62
115
  async function getRds(region: string) {
63
116
  if (!rdsClient) {
64
117
  const { RDSClient } = await importOptionalAws(
@@ -206,7 +259,9 @@ export async function resolveFunctionName(
206
259
  // Include the trailing dash so the prefix can't match a longer resource name.
207
260
  const basePrefix = functionName.slice(0, lastDash + 1).toLowerCase();
208
261
 
209
- const { LambdaClient, ListFunctionsCommand } = await import('@aws-sdk/client-lambda');
262
+ const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
263
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'this command',
264
+ );
210
265
  const client = new LambdaClient({ region });
211
266
 
212
267
  const candidates: Array<{ name: string; modified: number }> = [];
@@ -315,7 +370,9 @@ export async function invokeAction(
315
370
 
316
371
  /** Recent CloudWatch log text for a function's log group (last `windowMs`, capped). */
317
372
  async function fetchRecentLogText(region: string, logGroupName: string, windowMs = 5 * 60_000): Promise<string> {
318
- const { CloudWatchLogsClient, FilterLogEventsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
373
+ const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
374
+ () => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
375
+ );
319
376
  const client = new CloudWatchLogsClient({ region });
320
377
  const res = await client.send(new FilterLogEventsCommand({
321
378
  logGroupName,
@@ -331,7 +388,9 @@ export async function fetchRecentLogEvents(
331
388
  logGroupName: string,
332
389
  windowMs: number,
333
390
  ): Promise<Array<{ timestamp?: number; message?: string }>> {
334
- const { CloudWatchLogsClient, FilterLogEventsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
391
+ const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
392
+ () => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
393
+ );
335
394
  const client = new CloudWatchLogsClient({ region });
336
395
  const res = await client.send(new FilterLogEventsCommand({
337
396
  logGroupName,
@@ -376,7 +435,9 @@ async function defaultLogGroupIo(region: string): Promise<LogGroupIo> {
376
435
  return cfg.LoggingConfig?.LogGroup ?? undefined;
377
436
  },
378
437
  describeLogGroups: async (prefix, limit) => {
379
- const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
438
+ const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await importOptionalAws(
439
+ () => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
440
+ );
380
441
  const client = new CloudWatchLogsClient({ region });
381
442
  const res = await client.send(new DescribeLogGroupsCommand({ logGroupNamePrefix: prefix, limit }));
382
443
  return res.logGroups ?? [];
@@ -528,7 +589,9 @@ export async function presignGet(
528
589
  ): Promise<string> {
529
590
  const client = await getS3(region);
530
591
  const { GetObjectCommand } = await import('@aws-sdk/client-s3');
531
- const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
592
+ const { getSignedUrl } = await importOptionalAws(
593
+ () => import('@aws-sdk/s3-request-presigner'), '@aws-sdk/s3-request-presigner', 'db:backup:download',
594
+ );
532
595
  return await getSignedUrl(client as any, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn });
533
596
  }
534
597
 
@@ -207,9 +207,54 @@ export function buildProvisionSecretPlan(args: {
207
207
  return { updates, notes, warnings };
208
208
  }
209
209
 
210
+ /** The provisioning core's shape (mirrors @everystack/server/provision — no compile-time dep,
211
+ * same seam as pipeline-loader). */
212
+ export interface ServerProvision {
213
+ runProvision(
214
+ payload: { authPassword?: string; adminPassword?: string },
215
+ deps: {
216
+ execute: (sql: string) => Promise<unknown>;
217
+ connection?: { host: string; port?: number | string; database: string } | null;
218
+ connectProbe?: (url: string) => Promise<void>;
219
+ },
220
+ ): Promise<any>;
221
+ }
222
+
223
+ /**
224
+ * Lazily load the provisioning core from the @everystack/server peer (runtime only —
225
+ * the CLI keeps no compile-time dependency on the server package). A missing peer gets
226
+ * the install remedy, not a raw ERR_MODULE_NOT_FOUND.
227
+ */
228
+ export async function loadServerProvision(importer?: () => Promise<unknown>): Promise<ServerProvision> {
229
+ const spec = '@everystack/server/provision';
230
+ try {
231
+ return (await (importer ? importer() : import(spec))) as ServerProvision;
232
+ } catch (err: unknown) {
233
+ const code = (err as { code?: string })?.code;
234
+ const message = String((err as { message?: string })?.message ?? err);
235
+ if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(message)) {
236
+ throw new Error(
237
+ 'db:provision --database-url needs @everystack/server (>= 0.4.6) installed in this app — '
238
+ + 'it carries the provisioning logic:\n pnpm add @everystack/server',
239
+ );
240
+ }
241
+ throw err;
242
+ }
243
+ }
244
+
245
+ /** host/port/database from a postgres URL — the direct venue's connection info. */
246
+ export function parseUrlConnection(url: string): { host: string; port: string; database: string } {
247
+ const u = new URL(url);
248
+ return {
249
+ host: u.hostname,
250
+ port: u.port || '5432',
251
+ database: u.pathname.replace(/^\//, ''),
252
+ };
253
+ }
254
+
210
255
  export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
211
256
  if (!flags.stage) {
212
- fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
257
+ fail('--stage is required for db:provision (it writes the stage\'s DATABASE_URL secret the new credentials are never displayed, so they must land in the secret store)');
213
258
  process.exit(1);
214
259
  }
215
260
 
@@ -222,7 +267,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
222
267
  process.exit(1);
223
268
  }
224
269
 
225
- info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
270
+ // Venue: an explicit --database-url runs the role chain over a DIRECT connection — the
271
+ // bring-your-own-RDS shape, where the operator holds the master URL and the deployed app
272
+ // has no dbPlugin/Ops Lambda to dispatch to. --stage still names the secret store the new
273
+ // credentials are written to. No flag = the ops-Lambda venue, unchanged.
274
+ const directUrl = flags['database-url'];
275
+ if (directUrl) {
276
+ info(`Region: ${config.region}, venue: direct connection (bring-your-own database)`);
277
+ } else {
278
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
279
+ }
226
280
  info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
227
281
  step('Provisioning roles...');
228
282
 
@@ -233,17 +287,58 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
233
287
  const adminPassword = randomBytes(24).toString('hex');
234
288
 
235
289
  let result: any;
236
- try {
237
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
238
- } catch (err: any) {
239
- fail(`db:provision failed: ${err.message}`);
240
- for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
241
- process.exit(1);
290
+ let conn: any;
291
+ if (directUrl) {
292
+ try {
293
+ const { runProvision } = await loadServerProvision();
294
+ const { createUrlRunner } = await import('../db-source.js');
295
+ const { runner, end } = await createUrlRunner(directUrl);
296
+ try {
297
+ result = await runProvision({ authPassword, adminPassword }, {
298
+ execute: async (statement) => runner(statement),
299
+ connection: parseUrlConnection(directUrl),
300
+ // Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
301
+ connectProbe: async (probeUrl) => {
302
+ const { default: postgres } = await import('postgres');
303
+ const probe = postgres(probeUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
304
+ try {
305
+ await probe`SELECT 1`;
306
+ await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
307
+ await probe.unsafe('DROP TABLE _everystack_provision_probe');
308
+ } finally {
309
+ await probe.end({ timeout: 1 });
310
+ }
311
+ },
312
+ });
313
+ } finally {
314
+ await end?.();
315
+ }
316
+ } catch (err: any) {
317
+ fail(`db:provision failed: ${err.message}`);
318
+ process.exit(1);
319
+ }
320
+ conn = parseUrlConnection(directUrl);
321
+ } else {
322
+ try {
323
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
324
+ } catch (err: any) {
325
+ fail(`db:provision failed: ${err.message}`);
326
+ if (/Unknown action/i.test(String(err.message))) {
327
+ info('The deployed handler has no dbPlugin (no Ops Lambda). For a bring-your-own database,');
328
+ info('run the DIRECT venue instead: everystack db:provision --stage <stage> --database-url <master-url>');
329
+ }
330
+ for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
331
+ process.exit(1);
332
+ }
242
333
  }
243
334
  if (result?.error) {
244
335
  // Includes the admin-verification refusal: the action set NO authenticator password,
245
336
  // so nothing here rewrites DATABASE_URL — the working credentials are untouched.
246
337
  fail(`db:provision failed: ${result.error}`);
338
+ if (!directUrl && /Unknown action/i.test(String(result.error))) {
339
+ info('The deployed handler has no dbPlugin (no Ops Lambda). For a bring-your-own database,');
340
+ info('run the DIRECT venue instead: everystack db:provision --stage <stage> --database-url <master-url>');
341
+ }
247
342
  process.exit(1);
248
343
  }
249
344
  success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
@@ -252,11 +347,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
252
347
  }
253
348
 
254
349
  // Resolve host/port/db (not secret) to build the connection strings in memory.
255
- let conn: any;
256
- try {
257
- conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
258
- } catch {
259
- /* handled below */
350
+ // Direct venue already knows them from the URL itself.
351
+ if (!directUrl) {
352
+ try {
353
+ conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
354
+ } catch {
355
+ /* handled below */
356
+ }
260
357
  }
261
358
  if (!conn?.host) {
262
359
  fail('Could not resolve the database host to build DATABASE_URL.');
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { resolveConfig } from '../config.js';
14
- import { resolveLogGroup, fetchRecentLogEvents } from '../aws.js';
14
+ import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws.js';
15
15
  import { summarizeRuntimeFailures } from '../ops-diagnostics.js';
16
16
  import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
17
17
  import { step, success, fail, info } from '../output.js';
@@ -281,9 +281,9 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
281
281
  step(`Tailing logs (filter: ${filterPattern || 'none'}, since: ${since})...`);
282
282
 
283
283
  try {
284
- // Import AWS SDK dynamically
285
- const { CloudWatchLogsClient, FilterLogEventsCommand } =
286
- await import('@aws-sdk/client-cloudwatch-logs');
284
+ const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
285
+ () => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'logs:tail',
286
+ );
287
287
 
288
288
  const client = new CloudWatchLogsClient({ region: config.region });
289
289
 
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { step, success, fail, info, warn } from '../output.js';
19
19
  import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
20
20
  import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
21
+ import { isMissingOptionalDependencyError } from '../aws.js';
21
22
 
22
23
  interface SecretsContext {
23
24
  appName: string;
@@ -296,7 +297,11 @@ export async function secretsCommand(
296
297
  }
297
298
  } catch (err: any) {
298
299
  fail(err.message);
299
- info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
300
+ // A missing npm package is not an IAM problem the message already names the install.
301
+ // Printing IAM advice here is the field-reported misdirection.
302
+ if (!isMissingOptionalDependencyError(err)) {
303
+ info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
304
+ }
300
305
  process.exit(1);
301
306
  }
302
307
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
15
- import { invokeAction } from '../aws.js';
15
+ import { invokeAction, importOptionalAws } from '../aws.js';
16
16
  import { fail, info, step } from '../output.js';
17
17
 
18
18
  export interface MetricSpec {
@@ -70,7 +70,9 @@ async function defaultGetMetric(
70
70
  spec: MetricSpec,
71
71
  hours: number,
72
72
  ): Promise<number | null> {
73
- const { CloudWatchClient, GetMetricDataCommand } = await import('@aws-sdk/client-cloudwatch');
73
+ const { CloudWatchClient, GetMetricDataCommand } = await importOptionalAws(
74
+ () => import('@aws-sdk/client-cloudwatch'), '@aws-sdk/client-cloudwatch', 'status metrics',
75
+ );
74
76
  const client = new CloudWatchClient({ region: spec.region ?? region });
75
77
  const end = new Date();
76
78
  const start = new Date(end.getTime() - hours * 3600_000);
@@ -32,6 +32,46 @@ export interface UpdateFlags {
32
32
  export?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * The runtimeVersion as a STRING — the only shape the release path can carry.
37
+ * Expo's config allows a policy object; interpolating one into the S3 key
38
+ * produced `releases/dev/[object Object]/…` (a consumer field report). Locally
39
+ * derivable policies resolve here; the build-time ones (fingerprint,
40
+ * nativeVersion — computed by native tooling, undefined for a web publish)
41
+ * are refused with the remedies instead of silently corrupting the key.
42
+ */
43
+ export function resolveRuntimeVersion(appConfig: {
44
+ runtimeVersion?: unknown;
45
+ version?: string;
46
+ sdkVersion?: string;
47
+ }): string {
48
+ const rv = appConfig.runtimeVersion;
49
+ if (typeof rv === 'string') return rv;
50
+ const policy = (rv as { policy?: string } | undefined)?.policy;
51
+ if (policy === 'appVersion') {
52
+ if (!appConfig.version) {
53
+ throw new Error("runtimeVersion policy 'appVersion' needs a \"version\" field in app.json — add one, or set an explicit string runtimeVersion.");
54
+ }
55
+ return appConfig.version;
56
+ }
57
+ if (policy === 'sdkVersion') {
58
+ if (!appConfig.sdkVersion) {
59
+ throw new Error("runtimeVersion policy 'sdkVersion' needs an \"sdkVersion\" field in app.json — add one, or set an explicit string runtimeVersion.");
60
+ }
61
+ return appConfig.sdkVersion;
62
+ }
63
+ if (policy === 'fingerprint' || policy === 'nativeVersion') {
64
+ throw new Error(
65
+ `runtimeVersion policy '${policy}' is computed by the native build tooling (EAS / @expo/fingerprint) — `
66
+ + 'everystack update cannot resolve it, and for a web publish it is undefined. '
67
+ + 'Use an explicit string runtimeVersion, or { "policy": "appVersion" } to track your app version.',
68
+ );
69
+ }
70
+ throw new Error(
71
+ `Unsupported runtimeVersion ${JSON.stringify(rv)} — use an explicit string, { "policy": "appVersion" }, or { "policy": "sdkVersion" }.`,
72
+ );
73
+ }
74
+
35
75
  export async function updateCommand(flags: UpdateFlags & Record<string, string>): Promise<void> {
36
76
  // Signal publish context so app.config.js can detect it's not a dev session.
37
77
  // Without this, dotenv guards that skip .env.local for EAS_BUILD/EAS_UPDATE
@@ -98,15 +138,22 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
98
138
  // Read app config
99
139
  step('Reading app config...');
100
140
  const appConfig = await loadAppConfig();
101
- const runtimeVersion = appConfig.runtimeVersion;
102
141
 
103
- if (!runtimeVersion) {
142
+ if (!appConfig.runtimeVersion) {
104
143
  fail('No runtimeVersion found in app.json/app.config.js');
105
144
  info('Add "runtimeVersion" to your app.json, e.g.:');
106
- info(' { "expo": { "runtimeVersion": { "policy": "fingerprint" } } }');
145
+ info(' { "expo": { "runtimeVersion": "1.0.0" } } (explicit string)');
146
+ info(' { "expo": { "runtimeVersion": { "policy": "appVersion" } } } (tracks "version")');
107
147
  info('See: https://docs.expo.dev/eas-update/runtime-versions/');
108
148
  process.exit(1);
109
149
  }
150
+ let runtimeVersion: string;
151
+ try {
152
+ runtimeVersion = resolveRuntimeVersion(appConfig);
153
+ } catch (err: any) {
154
+ fail(err.message);
155
+ process.exit(1);
156
+ }
110
157
  success(`Runtime version: ${runtimeVersion}`);
111
158
 
112
159
  const distDir = path.resolve('dist');
@@ -561,13 +608,20 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
561
608
  );
562
609
  if (result?.error) {
563
610
  info(`DB mirror skipped: ${result.error}`);
611
+ // The dispatcher answers Unknown action as result.error, not a throw — explain
612
+ // there too, or the miss reads like a failure with no remedy.
613
+ if (/Unknown action/i.test(String(result.error))) {
614
+ info('This is optional — the S3 manifests are the source of truth for serving.');
615
+ info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
616
+ }
564
617
  } else {
565
618
  info(`Release mirrored to DB: channel=${result?.channel || channel}`);
566
619
  }
567
620
  } catch (err: any) {
568
621
  // register-web is optional — the S3 manifests are the source of truth.
569
622
  if (err.message?.includes('Unknown action')) {
570
- info('Tip: add a "register-web" case to onAction for DB-backed release tracking.');
623
+ info('DB mirror skipped (optional S3 manifests are the source of truth).');
624
+ info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
571
625
  } else {
572
626
  info(`DB mirror skipped: ${err.message}`);
573
627
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { isOpsHandlerCrash } from './ops-diagnostics.js';
20
- import { diagnoseOpsCrash } from './aws.js';
20
+ import { diagnoseOpsCrash, importOptionalAws } from './aws.js';
21
21
  import { step, success, fail, info } from './output.js';
22
22
 
23
23
  /** The raw outcome of one probe invoke, before classification. */
@@ -80,7 +80,9 @@ export function probeTargets(config: {
80
80
  /** One raw `_health` invoke → outcome. Never throws; every failure mode lands in the outcome. */
81
81
  async function probeFunction(region: string, functionName: string): Promise<ProbeOutcome> {
82
82
  try {
83
- const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
83
+ const { LambdaClient, InvokeCommand } = await importOptionalAws(
84
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'the deploy health probe',
85
+ );
84
86
  const client = new LambdaClient({ region });
85
87
  const response = await client.send(new InvokeCommand({
86
88
  FunctionName: functionName,
@@ -160,7 +162,9 @@ async function invokeHttpGet(
160
162
  queryString = '',
161
163
  ): Promise<{ statusCode?: number; body?: string } | null> {
162
164
  try {
163
- const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
165
+ const { LambdaClient, InvokeCommand } = await importOptionalAws(
166
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'the deploy health probe',
167
+ );
164
168
  const client = new LambdaClient({ region });
165
169
  const response = await client.send(new InvokeCommand({
166
170
  FunctionName: functionName,
@@ -13,6 +13,7 @@
13
13
 
14
14
  import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
+ import { importOptionalAws } from './aws.js';
16
17
 
17
18
  /** Mirrors CliConfig from config.ts — defined locally to avoid circular imports. */
18
19
  interface DiscoveredConfig {
@@ -141,10 +142,9 @@ export async function discoverFunction(
141
142
  prefix: string,
142
143
  resourceName: string,
143
144
  ): Promise<string | undefined> {
144
- const {
145
- LambdaClient,
146
- ListFunctionsCommand,
147
- } = await import('@aws-sdk/client-lambda');
145
+ const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
146
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'resource discovery',
147
+ );
148
148
 
149
149
  const client = new LambdaClient({ region });
150
150
  const needle = `${prefix}${resourceName}`.toLowerCase();
@@ -185,10 +185,9 @@ export async function discoverLambda(
185
185
  }
186
186
 
187
187
  // Get env vars for routerUrl (CDN_URL)
188
- const {
189
- LambdaClient,
190
- GetFunctionConfigurationCommand,
191
- } = await import('@aws-sdk/client-lambda');
188
+ const { LambdaClient, GetFunctionConfigurationCommand } = await importOptionalAws(
189
+ () => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'resource discovery',
190
+ );
192
191
 
193
192
  const client = new LambdaClient({ region });
194
193
  const config = await client.send(
@@ -219,7 +218,9 @@ export async function discoverBuckets(
219
218
  region: string,
220
219
  prefix: string,
221
220
  ): Promise<BucketsResult> {
222
- const { S3Client, ListBucketsCommand } = await import('@aws-sdk/client-s3');
221
+ const { S3Client, ListBucketsCommand } = await importOptionalAws(
222
+ () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'resource discovery',
223
+ );
223
224
 
224
225
  const client = new S3Client({ region });
225
226
  const res = await client.send(new ListBucketsCommand({}));
package/src/cli/index.ts CHANGED
@@ -353,7 +353,7 @@ Usage:
353
353
  everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
354
354
  everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
355
355
  everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
356
- everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
356
+ everystack db:provision --stage <name> [--database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). --database-url = DIRECT venue for a bring-your-own database (no Ops Lambda needed); --stage names the secret store either way
357
357
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
358
358
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
359
359
  everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
@@ -15,6 +15,22 @@
15
15
 
16
16
  import crypto from 'node:crypto';
17
17
  import { gunzipSync } from 'node:zlib';
18
+ import { ensureOptionalAws } from '../aws.js';
19
+
20
+ /**
21
+ * Preflight the two optional-peer clients EVERY secrets operation needs (ssm for the passphrase,
22
+ * s3 for the blob), naming both at once if missing — the operator installs everything in one
23
+ * `pnpm add` instead of discovering them one crash at a time (a consumer field report).
24
+ */
25
+ function ensureSecretsAws(): Promise<void> {
26
+ return ensureOptionalAws(
27
+ [
28
+ { load: () => import('@aws-sdk/client-ssm'), pkg: '@aws-sdk/client-ssm' },
29
+ { load: () => import('@aws-sdk/client-s3'), pkg: '@aws-sdk/client-s3' },
30
+ ],
31
+ 'secrets',
32
+ );
33
+ }
18
34
 
19
35
  export interface LoadSecretsOptions {
20
36
  appName: string;
@@ -130,6 +146,8 @@ export async function loadSstSecrets(
130
146
  ): Promise<Record<string, string>> {
131
147
  const { appName, stage, region } = options;
132
148
 
149
+ await ensureSecretsAws();
150
+
133
151
  // 1. Get the state bucket name from /sst/bootstrap
134
152
  const bucket = await getStateBucket(region);
135
153
 
@@ -166,6 +184,8 @@ export async function putSstSecrets(
166
184
  ): Promise<void> {
167
185
  const { appName, stage, region } = options;
168
186
 
187
+ await ensureSecretsAws();
188
+
169
189
  const bucket = await getStateBucket(region);
170
190
  const passphrase = await getPassphrase(region, appName, stage);
171
191