@everystack/cli 0.2.28 → 0.2.30

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/README.md CHANGED
@@ -132,6 +132,22 @@ everystack update \
132
132
 
133
133
  This bundles your app, uploads assets to storage, creates a release record, and signs the manifest.
134
134
 
135
+ Media assets configured in `app.config.js` under `extra.everystack.assets` are fingerprinted with 8-char content hashes (e.g., `logo.png` → `logo-a1b2c3d4.png`) and synced to the media bucket. A `media-manifest.json` mapping logical names to fingerprinted paths is written to the bucket root. Stale fingerprinted files from previous deploys are pruned automatically.
136
+
137
+ ```javascript
138
+ // app.config.js
139
+ extra: {
140
+ everystack: {
141
+ assets: [
142
+ { from: 'server/email/assets', to: 'email/', include: '*.{png,jpg}' },
143
+ { from: 'assets/og', to: 'og/' },
144
+ ],
145
+ },
146
+ }
147
+ ```
148
+
149
+ Use `@everystack/server/media` to resolve fingerprinted URLs at runtime in SSR, email templates, and workers.
150
+
135
151
  ### Code Signing
136
152
 
137
153
  Generate RSA key pair for manifest signing:
@@ -164,6 +180,24 @@ everystack db:psql --stage dev # Open a psql session via Lambda
164
180
 
165
181
  `db:migrate` and `db:seed` dispatch to your Lambda's `onAction` handler. `db:psql` proxies a PostgreSQL session through the Lambda, so your database credentials never leave AWS.
166
182
 
183
+ ### Interactive Console
184
+
185
+ ```bash
186
+ everystack console --stage dev # Connect to deployed database
187
+ everystack console --stage dev --sandbox # Sandbox mode: rolls back after each expression
188
+ ```
189
+
190
+ A REPL connected to your deployed database with `db`, `schema`, and `eq`/`and`/`or` helpers in scope. Supports multiline input, persistent history, and tab completion.
191
+
192
+ **Dot commands:**
193
+ - `.tables` / `.schema [table]` — Inspect database schema
194
+ - `.login` — Authenticate with email/password (hidden input)
195
+ - `.user` — Show current auth context
196
+ - `.logout` — Clear auth context
197
+ - `.sandbox` — Toggle sandbox mode
198
+
199
+ After `.login`, queries run with RLS policies applied via `pgSettings`. The `current_user` variable is available in scope. Passwords are redacted from history.
200
+
167
201
  ## Client: React Native
168
202
 
169
203
  ### UpdatesProvider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
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
@@ -96,6 +96,51 @@ export async function getFromS3(
96
96
  }
97
97
  }
98
98
 
99
+ export async function listS3(
100
+ region: string,
101
+ bucket: string,
102
+ prefix?: string,
103
+ ): Promise<string[]> {
104
+ const client = await getS3(region);
105
+ const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
106
+ const keys: string[] = [];
107
+ let continuationToken: string | undefined;
108
+ do {
109
+ const response = await client.send(new ListObjectsV2Command({
110
+ Bucket: bucket,
111
+ ...(prefix && { Prefix: prefix }),
112
+ ContinuationToken: continuationToken,
113
+ }));
114
+ if (response.Contents) {
115
+ for (const obj of response.Contents) {
116
+ if (obj.Key) keys.push(obj.Key);
117
+ }
118
+ }
119
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
120
+ } while (continuationToken);
121
+ return keys;
122
+ }
123
+
124
+ export async function deleteS3(
125
+ region: string,
126
+ bucket: string,
127
+ keys: string[],
128
+ ): Promise<void> {
129
+ if (keys.length === 0) return;
130
+ const client = await getS3(region);
131
+ const { DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
132
+ for (let i = 0; i < keys.length; i += 1000) {
133
+ const batch = keys.slice(i, i + 1000);
134
+ await client.send(new DeleteObjectsCommand({
135
+ Bucket: bucket,
136
+ Delete: {
137
+ Objects: batch.map(key => ({ Key: key })),
138
+ Quiet: true,
139
+ },
140
+ }));
141
+ }
142
+ }
143
+
99
144
  export async function invokeAction(
100
145
  region: string,
101
146
  functionName: string,
@@ -67,7 +67,7 @@ export function formatScope(meta: ConsoleMeta): string {
67
67
 
68
68
  /** Format .user output: current auth context. */
69
69
  export function formatUser(user: Record<string, unknown> | null): string {
70
- if (!user) return 'No user context. Use login(email, password) or asUser(id) to authenticate.';
70
+ if (!user) return 'No user context. Use .login or asUser(id) to authenticate.';
71
71
  return Object.entries(user).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n');
72
72
  }
73
73
 
@@ -78,12 +78,13 @@ export function formatHelp(): string {
78
78
  ' .columns <Table> Show columns for a table',
79
79
  ' .rpc List registered RPC functions',
80
80
  ' .scope List all variables in scope',
81
+ ' .login Log in with hidden password prompt',
81
82
  ' .user Show current auth context',
82
83
  ' .logout Clear auth context',
83
84
  ' .clear Clear the terminal',
84
85
  ' .exit Exit the console',
85
86
  '',
86
- ' Auth: login(email, password), asUser(id), logout()',
87
+ ' Auth: asUser(id), current_user, logout()',
87
88
  ].join('\n');
88
89
  }
89
90
 
@@ -104,6 +105,44 @@ interface ConsoleMeta {
104
105
  scope: string[];
105
106
  }
106
107
 
108
+ // ─── Password Prompt ────────────────────────────────────────────────────────
109
+
110
+ /** Prompt for a value with output suppressed (password entry). */
111
+ export function questionHidden(
112
+ rl: readline.Interface,
113
+ prompt: string,
114
+ ): Promise<string> {
115
+ return new Promise((resolve) => {
116
+ const output = (rl as any).output as NodeJS.WritableStream;
117
+ const origWrite = output.write.bind(output);
118
+ let promptShown = false;
119
+
120
+ (output as any).write = function (str: string, ...args: any[]) {
121
+ if (!promptShown) {
122
+ promptShown = true;
123
+ return origWrite(str, ...args);
124
+ }
125
+ // Suppress echo of password characters
126
+ return true;
127
+ };
128
+
129
+ rl.question(prompt, (answer) => {
130
+ (output as any).write = origWrite;
131
+ origWrite('\n');
132
+ resolve(answer);
133
+ });
134
+ });
135
+ }
136
+
137
+ /** Redact password arguments in login() calls for history storage. */
138
+ export function redactLogin(expression: string): string {
139
+ // Match login('email', 'password') or login("email", "password")
140
+ return expression.replace(
141
+ /(login\s*\([^,]+,\s*)(['"`]).*?\2(\s*\))/g,
142
+ "$1$2***$2$3",
143
+ );
144
+ }
145
+
107
146
  // ─── History ─────────────────────────────────────────────────────────────────
108
147
 
109
148
  /** Load history from disk, truncating to HISTORY_MAX lines. */
@@ -278,6 +317,51 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
278
317
  return true;
279
318
  }
280
319
 
320
+ case '.login': {
321
+ const email = await new Promise<string>((resolve) => {
322
+ rl.question(' Email: ', resolve);
323
+ });
324
+ if (!email.trim()) {
325
+ fail('Email is required');
326
+ rl.setPrompt(getPrompt());
327
+ rl.prompt();
328
+ return true;
329
+ }
330
+ const password = await questionHidden(rl, ' Password: ');
331
+ if (!password) {
332
+ rl.setPrompt(getPrompt());
333
+ rl.prompt();
334
+ return true;
335
+ }
336
+ try {
337
+ const loginPayload: Record<string, unknown> = {
338
+ expression: `await login(${JSON.stringify(email.trim())}, ${JSON.stringify(password)})`,
339
+ };
340
+ if (currentUser) loginPayload.user = currentUser;
341
+ if (sandbox) loginPayload.sandbox = true;
342
+
343
+ const loginResult: any = await invokeAction(
344
+ config.region,
345
+ config.apiFunctionName,
346
+ 'console',
347
+ loginPayload,
348
+ );
349
+ if (loginResult?._auth !== undefined) {
350
+ currentUser = loginResult._auth.user;
351
+ }
352
+ if (loginResult?.error) {
353
+ fail(loginResult.error);
354
+ } else {
355
+ console.log(formatResult(loginResult?.result));
356
+ }
357
+ } catch (err: any) {
358
+ fail(err.message);
359
+ }
360
+ rl.setPrompt(getPrompt());
361
+ rl.prompt();
362
+ return true;
363
+ }
364
+
281
365
  case '.user':
282
366
  console.log(formatUser(currentUser));
283
367
  rl.prompt();
@@ -355,7 +439,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
355
439
  } else {
356
440
  console.log(formatResult(result?.result));
357
441
  }
358
- appendHistory(expression);
442
+ appendHistory(redactLogin(expression));
359
443
  } catch (err: any) {
360
444
  fail(err.message);
361
445
  }
@@ -6,12 +6,23 @@ import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { resolveConfig, type CliConfig } from '../config.js';
9
- import { uploadToS3, headS3, invokeAction } from '../aws.js';
9
+ import { uploadToS3, headS3, listS3, deleteS3, invokeAction } from '../aws.js';
10
10
  import { step, success, warn, fail, info } from '../output.js';
11
11
  import { exportApp } from '../utils/export.js';
12
12
  import { walkDirectory } from '../utils/walk.js';
13
13
  import { loadSstSecrets } from '../utils/secrets.js';
14
14
 
15
+ /**
16
+ * Embed an 8-char content hash in a filename for cache busting.
17
+ * `logo.png` + data → `logo-a1b2c3d4.png`
18
+ */
19
+ export function fingerprintPath(relativePath: string, data: Buffer): string {
20
+ const hash8 = crypto.createHash('md5').update(data).digest('hex').slice(0, 8);
21
+ const ext = path.extname(relativePath);
22
+ const base = ext ? relativePath.slice(0, -ext.length) : relativePath;
23
+ return `${base}-${hash8}${ext}`;
24
+ }
25
+
15
26
  export interface UpdateFlags {
16
27
  channel?: string;
17
28
  branch?: string;
@@ -410,7 +421,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
410
421
  }
411
422
  }
412
423
 
413
- // 3.5. Sync media assets to the media bucket (email logos, OG images, etc.)
424
+ // 3.5. Sync fingerprinted media assets to the media bucket (email logos, OG images, etc.)
414
425
  const assetDirs: Array<{ from: string; to: string; include?: string }> | undefined =
415
426
  appConfig.extra?.everystack?.assets;
416
427
  if (assetDirs?.length) {
@@ -426,6 +437,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
426
437
  const MEDIA_CONCURRENCY = 10;
427
438
  let mediaUploaded = 0;
428
439
  let mediaSkipped = 0;
440
+ const manifestFiles: Record<string, string> = {};
429
441
 
430
442
  for (const { from: fromDir, to: toPrefix, include } of assetDirs) {
431
443
  const absFrom = path.resolve(fromDir);
@@ -445,13 +457,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
445
457
  for (let i = 0; i < files.length; i += MEDIA_CONCURRENCY) {
446
458
  const batch = files.slice(i, i + MEDIA_CONCURRENCY);
447
459
  await Promise.all(batch.map(async (file) => {
448
- const s3Key = `${toPrefix}${file.relativePath}`;
449
460
  const data = Buffer.from(file.data, 'base64');
461
+ const fingerprinted = fingerprintPath(file.relativePath, data);
462
+ const logicalKey = `${toPrefix}${file.relativePath}`;
463
+ const s3Key = `${toPrefix}${fingerprinted}`;
464
+
465
+ manifestFiles[logicalKey] = s3Key;
450
466
 
451
- // Compare MD5 against S3 ETag for diffing
452
- const md5 = `"${crypto.createHash('md5').update(data).digest('hex')}"`;
467
+ // Fingerprinted key encodes the content hash — if it exists, content is identical
453
468
  const existing = await headS3(config.region, config.mediaBucket!, s3Key);
454
- if (existing?.etag === md5) {
469
+ if (existing) {
470
+ info(` ${logicalKey} → ${s3Key} (unchanged)`);
455
471
  mediaSkipped++;
456
472
  return;
457
473
  }
@@ -464,11 +480,51 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
464
480
  file.contentType,
465
481
  'public, max-age=31536000, immutable',
466
482
  );
483
+ info(` ${logicalKey} → ${s3Key}`);
467
484
  mediaUploaded++;
468
485
  }));
469
486
  }
470
487
  }
471
488
 
489
+ // Write manifest (no-cache so CDN always revalidates)
490
+ const manifest = {
491
+ version: 1,
492
+ generatedAt: new Date().toISOString(),
493
+ files: manifestFiles,
494
+ };
495
+ await uploadToS3(
496
+ config.region,
497
+ config.mediaBucket!,
498
+ 'media-manifest.json',
499
+ Buffer.from(JSON.stringify(manifest, null, 2)),
500
+ 'application/json',
501
+ 'no-cache',
502
+ );
503
+
504
+ // Prune stale fingerprinted files — scoped to configured prefixes only.
505
+ // Skip pruning entirely if any prefix is empty (would list the whole bucket).
506
+ const FINGERPRINT_RE = /-[0-9a-f]{8}\.[^.]+$/;
507
+ const prefixes = [...new Set(assetDirs.map(d => d.to))];
508
+ const allKeys: string[] = [];
509
+ const hasSafePrefix = prefixes.every(p => p.length > 0);
510
+ if (hasSafePrefix) {
511
+ for (const prefix of prefixes) {
512
+ const keys = await listS3(config.region, config.mediaBucket!, prefix);
513
+ allKeys.push(...keys);
514
+ }
515
+ } else {
516
+ warn('Skipping media prune: asset prefix is empty (would list entire bucket).');
517
+ }
518
+ const currentValues = new Set(Object.values(manifestFiles));
519
+ currentValues.add('media-manifest.json');
520
+ const staleKeys = allKeys.filter(key =>
521
+ FINGERPRINT_RE.test(key) && !currentValues.has(key)
522
+ );
523
+ if (staleKeys.length > 0) {
524
+ await deleteS3(config.region, config.mediaBucket!, staleKeys);
525
+ info(`Pruned ${staleKeys.length} stale media asset(s)`);
526
+ }
527
+
472
528
  if (mediaUploaded > 0 || mediaSkipped > 0) {
473
529
  success(`Media: ${mediaUploaded} new, ${mediaSkipped} unchanged (skipped)`);
474
530
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  // ─── Constants ──────────────────────────────────────────────────────────────
13
13
 
14
- const DOT_COMMANDS = ['.tables', '.columns', '.rpc', '.scope', '.user', '.logout', '.help', '.clear', '.exit'];
14
+ const DOT_COMMANDS = ['.tables', '.columns', '.rpc', '.scope', '.login', '.user', '.logout', '.help', '.clear', '.exit'];
15
15
 
16
16
  const MODEL_METHODS = ['find(', 'where(', 'all()', 'first()', 'last()', 'count()', 'table'];
17
17
  const CHAIN_METHODS = ['where(', 'orderBy(', 'limit(', 'offset(', 'first()', 'last()', 'count()', 'all()'];